diff --git a/app/build.gradle b/app/build.gradle index 964cb94b..72177889 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,7 +7,7 @@ plugins { android { namespace = 'com.greenart7c3.nostrsigner' - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId "com.greenart7c3.nostrsigner" diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt index a1916389..ffe8f89f 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt @@ -461,6 +461,100 @@ class SignerProvider : ContentProvider() { return cursor } + "content://$appId.SIGN_PSBT" -> { + val psbtHex = projection?.first() + if (psbtHex == null) { + Log.d(Amber.TAG, "No psbt") + return null + } + val npub = IntentUtils.parsePubKey(projection[2]) + if (npub == null) { + Log.d(Amber.TAG, "No npub") + return null + } + val account = LocalPreferences.loadFromEncryptedStorageSync(context!!, npub) + if (account == null) { + Log.d(Amber.TAG, "No account from storage") + return null + } + val database = Amber.instance.getDatabase(account.npub) + val historyDatabase = Amber.instance.getHistoryDatabase(account.npub) + val permission = + database + .dao() + .getPermission( + packageName, + "SIGN_PSBT", + ) + val signPolicy = database.dao().getSignPolicy(packageName) + val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null + if (!isRemembered) { + scope.launch { + historyDatabase.dao().addHistory( + listOf( + HistoryEntity( + 0, + packageName, + uriString.replace("content://$appId.", ""), + null, + TimeUtils.now(), + false, + content = psbtHex, + ), + ), + account.npub, + ) + } + val cursor = + MatrixCursor(arrayOf("rejected")).also { + it.addRow(arrayOf("true")) + } + + return cursor + } + + val result = try { + runBlocking { account.signPsbt(psbtHex) } + } catch (e: Exception) { + Log.d(Amber.TAG, "Failed to sign psbt", e) + scope.launch { + val logDb = Amber.instance.getLogDatabase(account.npub) + logDb.dao().insertLog( + LogEntity( + 0, + packageName, + "SIGN_PSBT", + e.message ?: "Could not sign the psbt", + System.currentTimeMillis(), + ), + ) + } + return null + } + + scope.launch { + historyDatabase.dao().addHistory( + listOf( + HistoryEntity( + 0, + packageName, + "SIGN_PSBT", + null, + TimeUtils.now(), + true, + content = psbtHex, + ), + ), + account.npub, + ) + } + + val localCursor = MatrixCursor(arrayOf("signature", "event", "result")).also { + it.addRow(arrayOf(result, result, result)) + } + + return localCursor + } "content://$appId.PING" -> { val npub = if (projection != null && projection.isNotEmpty()) IntentUtils.parsePubKey(projection[0]) else null val account = if (npub != null) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt index a857439c..f2cf5e34 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt @@ -89,6 +89,8 @@ class Account( suspend fun decrypt(encryptedContent: String, fromPublicKey: String): String = signer.decrypt(encryptedContent, fromPublicKey) + suspend fun signPsbt(psbtHex: String): String = signer.signPsbt(psbtHex) + suspend fun seedWords() = runCatching { DataStoreAccess.getEncryptedKey(Amber.instance, npub, DataStoreAccess.SEED_WORDS) }.getOrNull() ?: "" fun decryptZapEvent( diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/Permission.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/Permission.kt index 9c6db4be..c0e08308 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/Permission.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/Permission.kt @@ -86,6 +86,9 @@ data class Permission( "decrypt_zap_event" -> { context.getString(R.string.decrypt_private_zaps) } + "sign_psbt" -> { + context.getString(R.string.sign_psbt) + } "encrypt_clear_text" -> { context.getString(R.string.encrypt_clear_text) } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/SignerType.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/SignerType.kt index 4ada5151..da0e34d1 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/SignerType.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/SignerType.kt @@ -13,4 +13,5 @@ enum class SignerType { PING, INVALID, SWITCH_RELAYS, + SIGN_PSBT, } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt index 05e19b6e..0358f2f7 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt @@ -232,6 +232,7 @@ object BunkerRequestUtils { "decrypt_zap_event" -> SignerType.DECRYPT_ZAP_EVENT "ping" -> SignerType.PING "switch_relays" -> SignerType.SWITCH_RELAYS + "sign_psbt" -> SignerType.SIGN_PSBT else -> SignerType.INVALID } @@ -244,6 +245,7 @@ object BunkerRequestUtils { } "nip04_encrypt", "nip04_decrypt", "nip44_encrypt", "nip44_decrypt", "decrypt_zap_event" -> bunkerRequest.params.getOrElse(1) { "" } "ping" -> "pong" + "sign_psbt" -> bunkerRequest.params.first() else -> "" } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt index 38b973f5..4c87efa5 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -142,6 +142,8 @@ object IntentUtils { SignerType.NIP44_DECRYPT "decrypt_zap_event" -> SignerType.DECRYPT_ZAP_EVENT + "sign_psbt" -> + SignerType.SIGN_PSBT else -> SignerType.INVALID } @@ -319,6 +321,23 @@ object IntentUtils { encryptedData = null, ) } + SignerType.SIGN_PSBT -> { + IntentData( + data = localData, + name = appName, + type = type, + pubKey = pubKey, + id = intent.getStringExtra("id") ?: "", + callBackUrl = callbackUrl, + compression = compressionType, + returnType = returnType, + permissions = listOf(), + currentAccount = "", + route = route, + event = null, + encryptedData = null, + ) + } else -> null } } @@ -503,6 +522,28 @@ object IntentUtils { encryptedData = null, ) } + SignerType.SIGN_PSBT -> { + var npub = intent.getStringExtra("current_user") + if (npub != null) { + npub = parsePubKey(npub) + } + + IntentData( + data = data, + name = name, + type = type, + pubKey = pubKey, + id = id, + callBackUrl = intent.extras?.getString("callbackUrl"), + compression = compressionType, + returnType = returnType, + permissions = permissions?.map { Permission(it.type.trim(), it.kind, it.checked) }, + currentAccount = npub ?: account.npub, + route = route, + event = null, + encryptedData = null, + ) + } else -> null } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/PsbtDecoder.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/PsbtDecoder.kt new file mode 100644 index 00000000..76fbe4ee --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/PsbtDecoder.kt @@ -0,0 +1,165 @@ +package com.greenart7c3.nostrsigner.service + +import androidx.compose.runtime.Immutable +import com.greenart7c3.nostrsigner.models.Account +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.inputWitnessUtxo +import com.vitorpamplona.quartz.utils.Hex + +@Immutable +data class DecodedPsbtOutput( + val valueSats: Long, + val scriptPubKeyHex: String, + val address: String?, + val isChange: Boolean, +) + +@Immutable +data class DecodedPsbt( + val outputs: List, + val totalSent: Long, + val totalChange: Long, + val feeSats: Long?, + val parseError: String?, +) + +object PsbtDecoder { + // BIP-174 PSBT output key type for PSBT_OUT_TAP_INTERNAL_KEY (x-only pubkey of a taproot change output). + private const val PSBT_OUT_TAP_INTERNAL_KEY = 0x05 + + fun decode(psbtHex: String, account: Account): DecodedPsbt = try { + val psbt = Psbt.parse(psbtHex) + val tx = psbt.unsignedTx + val xOnlyPubKey = account.signer.keyPair.pubKey + + var anyInputMissingValue = false + var inputTotal = 0L + for (i in tx.inputs.indices) { + val value = psbt.inputWitnessUtxo(i)?.valueSats + if (value == null) anyInputMissingValue = true else inputTotal += value + } + + var totalOut = 0L + var totalSent = 0L + var totalChange = 0L + val outputs = tx.outputs.mapIndexed { index, txOut -> + totalOut += txOut.valueSats + val taprootInternalKey = psbt.outputs[index].get(PSBT_OUT_TAP_INTERNAL_KEY) + val isChange = taprootInternalKey != null && taprootInternalKey.contentEquals(xOnlyPubKey) + if (isChange) totalChange += txOut.valueSats else totalSent += txOut.valueSats + + DecodedPsbtOutput( + valueSats = txOut.valueSats, + scriptPubKeyHex = Hex.encode(txOut.scriptPubKey), + address = scriptPubKeyToAddress(txOut.scriptPubKey), + isChange = isChange, + ) + } + + val fee = if (anyInputMissingValue) null else inputTotal - totalOut + + DecodedPsbt( + outputs = outputs, + totalSent = totalSent, + totalChange = totalChange, + feeSats = fee, + parseError = null, + ) + } catch (e: Exception) { + DecodedPsbt( + outputs = emptyList(), + totalSent = 0, + totalChange = 0, + feeSats = null, + parseError = e.message ?: e::class.simpleName ?: "parse error", + ) + } + + // Recognises segwit witness program script shapes and encodes them as bech32/bech32m mainnet addresses. + // - P2WPKH (BIP-141): OP_0 <20> -> bech32 with witver=0 + // - P2WSH (BIP-141): OP_0 <32> -> bech32 with witver=0 + // - P2TR (BIP-341): OP_1 <32> -> bech32m with witver=1 + // Returns null for everything else (legacy P2PKH/P2SH and non-standard scripts). + fun scriptPubKeyToAddress(script: ByteArray, hrp: String = "bc"): String? { + if (script.size == 22 && script[0] == 0x00.toByte() && script[1] == 0x14.toByte()) { + return encodeSegwit(hrp, 0, script.copyOfRange(2, 22)) + } + if (script.size == 34 && script[0] == 0x00.toByte() && script[1] == 0x20.toByte()) { + return encodeSegwit(hrp, 0, script.copyOfRange(2, 34)) + } + if (script.size == 34 && script[0] == 0x51.toByte() && script[1] == 0x20.toByte()) { + return encodeSegwit(hrp, 1, script.copyOfRange(2, 34)) + } + return null + } + + private const val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + private const val BECH32_CONST = 1L + private const val BECH32M_CONST = 0x2bc830a3L + + private fun encodeSegwit(hrp: String, witver: Int, program: ByteArray): String? { + val program5 = convertBits(program, 8, 5, true) ?: return null + val data = IntArray(program5.size + 1) + data[0] = witver + for (i in program5.indices) data[i + 1] = program5[i] + val constant = if (witver == 0) BECH32_CONST else BECH32M_CONST + val checksum = createChecksum(hrp, data, constant) + val combined = data + checksum + val sb = StringBuilder(hrp.length + 1 + combined.size) + sb.append(hrp).append('1') + for (v in combined) sb.append(BECH32_CHARSET[v]) + return sb.toString() + } + + private fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray? { + var acc = 0 + var bits = 0 + val maxv = (1 shl toBits) - 1 + val result = mutableListOf() + for (b in data) { + val v = b.toInt() and 0xff + if (v ushr fromBits != 0) return null + acc = (acc shl fromBits) or v + bits += fromBits + while (bits >= toBits) { + bits -= toBits + result.add((acc ushr bits) and maxv) + } + } + if (pad) { + if (bits > 0) result.add((acc shl (toBits - bits)) and maxv) + } else if (bits >= fromBits || ((acc shl (toBits - bits)) and maxv) != 0) { + return null + } + return result.toIntArray() + } + + private fun polymod(values: IntArray): Long { + val gen = longArrayOf(0x3b6a57b2L, 0x26508e6dL, 0x1ea119faL, 0x3d4233ddL, 0x2a1462b3L) + var chk = 1L + for (v in values) { + val top = chk ushr 25 + chk = ((chk and 0x1ffffffL) shl 5) xor v.toLong() + for (i in 0..4) { + if (((top ushr i) and 1L) == 1L) chk = chk xor gen[i] + } + } + return chk + } + + private fun hrpExpand(hrp: String): IntArray { + val ret = IntArray(hrp.length * 2 + 1) + for (i in hrp.indices) ret[i] = hrp[i].code ushr 5 + ret[hrp.length] = 0 + for (i in hrp.indices) ret[hrp.length + 1 + i] = hrp[i].code and 31 + return ret + } + + private fun createChecksum(hrp: String, data: IntArray, constant: Long): IntArray { + val values = hrpExpand(hrp) + data + IntArray(6) + val mod = polymod(values) xor constant + val ret = IntArray(6) + for (i in 0..5) ret[i] = ((mod ushr (5 * (5 - i))) and 31L).toInt() + return ret + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt index 0f775b12..798fbaa8 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt @@ -37,6 +37,7 @@ import com.greenart7c3.nostrsigner.models.SignerType import com.greenart7c3.nostrsigner.models.kindToNip import com.greenart7c3.nostrsigner.models.toPermissionType import com.greenart7c3.nostrsigner.service.BunkerRequestUtils +import com.greenart7c3.nostrsigner.service.PsbtDecoder import com.greenart7c3.nostrsigner.service.RelayUrlUtils import com.greenart7c3.nostrsigner.service.isPrivateEvent import com.greenart7c3.nostrsigner.service.model.AmberEvent @@ -933,6 +934,77 @@ fun BunkerSingleEventHomeScreen( ) }, ) + } else if (type == SignerType.SIGN_PSBT) { + val psbtHex = bunkerRequest.request.params.first() + val permission = + applicationEntity?.permissions?.firstOrNull { + it.pkKey == key && it.type == type.toString() + } + + val acceptUntil = permission?.acceptUntil ?: 0 + val rejectUntil = permission?.rejectUntil ?: 0 + + val acceptOrReject = if (rejectUntil == 0L && acceptUntil == 0L) { + null + } else if (rejectUntil > TimeUtils.now() && rejectUntil > 0) { + false + } else if (acceptUntil > TimeUtils.now() && acceptUntil > 0) { + true + } else { + null + } + + val decoded = remember(psbtHex) { PsbtDecoder.decode(psbtHex, account) } + + BunkerSignPsbt( + modifier = modifier, + psbtHex = psbtHex, + decoded = decoded, + shouldRunOnAccept = acceptOrReject, + appName = appName, + onAccept = { + Amber.instance.applicationIOScope.launch(Dispatchers.IO) { + try { + val result = account.signPsbt(psbtHex) + BunkerRequestUtils.sendResult( + context = context, + account = account, + key = key, + response = result, + bunkerRequest = bunkerRequest, + kind = null, + onLoading = onLoading, + permissions = null, + appName = appName, + signPolicy = null, + shouldCloseApplication = bunkerRequest.closeApplication, + rememberType = it, + ) + } catch (e: Exception) { + coroutineScope.launch { + Toast.makeText( + context, + e.message ?: "Could not sign the PSBT", + Toast.LENGTH_SHORT, + ).show() + } + onLoading(false) + } + } + }, + onReject = { + BunkerRequestUtils.sendRejection( + key = key, + account = account, + bunkerRequest = bunkerRequest, + appName = appName, + rememberType = it, + signerType = type, + kind = null, + onLoading = onLoading, + ) + }, + ) } } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt index 8e1e30d5..8e64a237 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt @@ -31,6 +31,7 @@ import com.greenart7c3.nostrsigner.models.SignerType import com.greenart7c3.nostrsigner.models.kindToNip import com.greenart7c3.nostrsigner.models.toPermissionType import com.greenart7c3.nostrsigner.service.IntentUtils +import com.greenart7c3.nostrsigner.service.PsbtDecoder import com.greenart7c3.nostrsigner.service.RelayUrlUtils import com.greenart7c3.nostrsigner.service.isPrivateEvent import com.greenart7c3.nostrsigner.service.model.AmberEvent @@ -159,6 +160,63 @@ fun IntentSingleEventHomeScreen( ) } + SignerType.SIGN_PSBT -> { + val permission = + applicationEntity?.permissions?.firstOrNull { + it.pkKey == key && it.type == intentData.type.toString() + } + + val acceptOrReject = IntentUtils.isRemembered(applicationEntity?.application?.signPolicy, permission) + val decoded = remember(intentData.data) { PsbtDecoder.decode(intentData.data, account) } + + SignPsbt( + modifier = modifier, + psbtHex = intentData.data, + decoded = decoded, + shouldRunOnAccept = acceptOrReject, + packageName = packageName, + onAccept = { rememberType -> + Amber.instance.applicationIOScope.launch(Dispatchers.IO) { + try { + val result = account.signPsbt(intentData.data) + IntentUtils.sendResult( + context, + packageName, + account, + key, + clipboardManager, + result, + result, + intentData, + null, + onLoading, + onRemoveIntentData = onRemoveIntentData, + rememberType = rememberType, + ) + } catch (e: Exception) { + ToastManager.toast( + title = context.getString(R.string.warning), + message = e.message ?: "Could not sign the PSBT", + ) + onLoading(false) + } + } + }, + onReject = { rememberType -> + IntentUtils.sendRejection( + key = key, + account = account, + intentData = intentData, + appName = appName, + rememberType = rememberType, + onLoading = onLoading, + onRemoveIntentData = onRemoveIntentData, + kind = null, + ) + }, + ) + } + SignerType.NIP04_DECRYPT, SignerType.NIP04_ENCRYPT, SignerType.NIP44_ENCRYPT, SignerType.NIP44_DECRYPT, SignerType.DECRYPT_ZAP_EVENT -> { val nip = when (intentData.type) { SignerType.NIP04_DECRYPT, SignerType.NIP04_ENCRYPT -> 4 diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt new file mode 100644 index 00000000..57ed1556 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt @@ -0,0 +1,241 @@ +package com.greenart7c3.nostrsigner.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.greenart7c3.nostrsigner.R +import com.greenart7c3.nostrsigner.service.DecodedPsbt +import com.greenart7c3.nostrsigner.service.DecodedPsbtOutput +import com.greenart7c3.nostrsigner.ui.RememberType + +@Composable +fun SignPsbt( + modifier: Modifier, + psbtHex: String, + decoded: DecodedPsbt, + shouldRunOnAccept: Boolean?, + packageName: String?, + onAccept: (RememberType) -> Unit, + onReject: (RememberType) -> Unit, +) { + var rememberType by remember { mutableStateOf(RememberType.NEVER) } + + Column(modifier) { + LocalAppIcon(packageName) + + val message = stringResource(R.string.sign_psbt) + + Text( + buildAnnotatedString { + append(stringResource(R.string.requests_message, message)) + }, + fontSize = 18.sp, + ) + Spacer(Modifier.size(8.dp)) + + PsbtBody(psbtHex = psbtHex, decoded = decoded) + + Spacer(Modifier.weight(1f)) + + RememberMyChoice( + shouldRunOnAccept, + packageName, + false, + onAccept, + onReject, + ) { + rememberType = it + } + + AcceptRejectButtons( + onAccept = { onAccept(rememberType) }, + onReject = { onReject(rememberType) }, + ) + } +} + +@Composable +fun BunkerSignPsbt( + modifier: Modifier, + psbtHex: String, + decoded: DecodedPsbt, + shouldRunOnAccept: Boolean?, + appName: String, + onAccept: (RememberType) -> Unit, + onReject: (RememberType) -> Unit, +) { + var rememberType by remember { mutableStateOf(RememberType.NEVER) } + + Column(modifier) { + val message = stringResource(R.string.sign_psbt) + + Text( + buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(appName) + } + append(" requests $message") + }, + fontSize = 18.sp, + ) + Spacer(Modifier.size(8.dp)) + + PsbtBody(psbtHex = psbtHex, decoded = decoded) + + Spacer(Modifier.weight(1f)) + + RememberMyChoice( + shouldRunOnAccept, + null, + true, + onAccept, + onReject, + ) { + rememberType = it + } + + AcceptRejectButtons( + onAccept = { onAccept(rememberType) }, + onReject = { onReject(rememberType) }, + ) + } +} + +@Composable +private fun PsbtBody(psbtHex: String, decoded: DecodedPsbt) { + if (decoded.parseError != null) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(8.dp)) { + Text( + stringResource(R.string.psbt_decode_error, decoded.parseError), + color = MaterialTheme.colorScheme.error, + ) + } + } + Spacer(Modifier.size(8.dp)) + RawPsbtCard(psbtHex) + return + } + + val sending = decoded.outputs.filterNot { it.isChange } + val change = decoded.outputs.filter { it.isChange } + + if (sending.isNotEmpty()) { + SectionCard( + title = stringResource(R.string.psbt_sending), + totalSats = decoded.totalSent, + entries = sending, + ) + Spacer(Modifier.size(8.dp)) + } + + if (change.isNotEmpty()) { + SectionCard( + title = stringResource(R.string.psbt_change), + totalSats = decoded.totalChange, + entries = change, + ) + Spacer(Modifier.size(8.dp)) + } + + decoded.feeSats?.let { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.psbt_fee), fontWeight = FontWeight.Bold) + Text(formatSats(it)) + } + } + Spacer(Modifier.size(8.dp)) + } + + RawPsbtCard(psbtHex) +} + +@Composable +private fun SectionCard(title: String, totalSats: Long, entries: List) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, fontWeight = FontWeight.Bold) + Text(formatSats(totalSats), fontWeight = FontWeight.Bold) + } + entries.forEach { output -> + Spacer(Modifier.size(6.dp)) + Column { + Text( + output.address ?: output.scriptPubKeyHex, + fontSize = 13.sp, + ) + Text( + formatSats(output.valueSats), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun RawPsbtCard(psbtHex: String) { + var expanded by remember { mutableStateOf(false) } + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.psbt_raw), fontWeight = FontWeight.Bold) + Text( + text = if (expanded) "Hide" else "Show", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.primary, + ) + } + if (expanded) { + Text( + psbtHex, + modifier = Modifier.fillMaxWidth(), + fontSize = 12.sp, + ) + } + } + } +} + +private fun formatSats(sats: Long): String = "$sats sats" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 019c32a4..725107b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -205,6 +205,13 @@ wants you to %1$s Sign %1$s sign message + sign PSBT + Sending + Change + Fee + %1$s sats + Raw PSBT (hex) + Could not decode PSBT: %1$s Signing as content Amber is running in background diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtilsTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtilsTest.kt index d6e625f3..38047eee 100644 --- a/app/src/test/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtilsTest.kt +++ b/app/src/test/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtilsTest.kt @@ -110,6 +110,11 @@ class BunkerRequestUtilsTest { assertEquals(SignerType.SWITCH_RELAYS, BunkerRequestUtils.getTypeFromBunker(mockBunkerRequest("switch_relays"))) } + @Test + fun `getTypeFromBunker returns SIGN_PSBT for sign_psbt`() { + assertEquals(SignerType.SIGN_PSBT, BunkerRequestUtils.getTypeFromBunker(mockBunkerRequest("sign_psbt"))) + } + @Test fun `getTypeFromBunker returns INVALID for unknown method`() { assertEquals(SignerType.INVALID, BunkerRequestUtils.getTypeFromBunker(mockBunkerRequest("unknown_method"))) @@ -168,6 +173,12 @@ class BunkerRequestUtilsTest { assertEquals("event_json_string", BunkerRequestUtils.getDataFromBunker(request)) } + @Test + fun `getDataFromBunker returns first param for sign_psbt`() { + val request = mockBunkerRequest("sign_psbt", arrayOf("70736274ff01abcd")) + assertEquals("70736274ff01abcd", BunkerRequestUtils.getDataFromBunker(request)) + } + @Test fun `getDataFromBunker returns empty string when second param is missing for encrypt or decrypt`() { val request = mockBunkerRequest("nip04_encrypt", emptyArray()) diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/service/PsbtDecoderTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/service/PsbtDecoderTest.kt new file mode 100644 index 00000000..5c0d97df --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/service/PsbtDecoderTest.kt @@ -0,0 +1,67 @@ +package com.greenart7c3.nostrsigner.service + +import com.vitorpamplona.quartz.utils.Hex +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PsbtDecoderTest { + @Test + fun `scriptPubKeyToAddress decodes P2TR as bech32m`() { + val script = Hex.decode("5120a3eb3f9b18bf1ea7a3d4eddbb33c0d34c4d2e9a45dc12e7e98aa3d345e8b3c4f") + assertEquals( + "bc1p504nlxcchu020g75ahdmx0qdxnzd96dythqjul5c4g7ngh5t838s9ug0nc", + PsbtDecoder.scriptPubKeyToAddress(script), + ) + } + + @Test + fun `scriptPubKeyToAddress decodes P2WPKH as bech32`() { + val script = Hex.decode("0014751e76e8199196d454941c45d1b3a323f1433bd6") + assertEquals( + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + PsbtDecoder.scriptPubKeyToAddress(script), + ) + } + + @Test + fun `scriptPubKeyToAddress decodes P2WSH as bech32`() { + val script = Hex.decode("00200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20") + assertEquals( + "bc1qqypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3c8g7rusqyp0mu0", + PsbtDecoder.scriptPubKeyToAddress(script), + ) + } + + @Test + fun `scriptPubKeyToAddress returns null for P2PKH script`() { + // OP_DUP OP_HASH160 <20> ... OP_EQUALVERIFY OP_CHECKSIG -- legacy P2PKH, not supported + val script = Hex.decode("76a914751e76e8199196d454941c45d1b3a323f1433bd688ac") + assertNull(PsbtDecoder.scriptPubKeyToAddress(script)) + } + + @Test + fun `scriptPubKeyToAddress returns null for P2SH script`() { + val script = Hex.decode("a914751e76e8199196d454941c45d1b3a323f1433bd687") + assertNull(PsbtDecoder.scriptPubKeyToAddress(script)) + } + + @Test + fun `scriptPubKeyToAddress returns null for empty script`() { + assertNull(PsbtDecoder.scriptPubKeyToAddress(ByteArray(0))) + } + + @Test + fun `scriptPubKeyToAddress returns null for wrong-length witness program`() { + // OP_0 with 21 bytes (not 20 or 32) is not a valid v0 witness program + val script = Hex.decode("0015751e76e8199196d454941c45d1b3a323f1433bd600") + assertNull(PsbtDecoder.scriptPubKeyToAddress(script)) + } + + @Test + fun `scriptPubKeyToAddress uses provided hrp for testnet-like prefixes`() { + val script = Hex.decode("0014751e76e8199196d454941c45d1b3a323f1433bd6") + val tb = PsbtDecoder.scriptPubKeyToAddress(script, hrp = "tb") + assertEquals("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", tb) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 34f675c8..329e36fa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ junitVersion = "1.3.0" lifecycle_version = "2.10.0" material3 = "1.4.0" nav_version = "2.9.8" -quartz = "1.08.0" +quartz = "1.10.0" compose_ui = "1.11.0" roomKtx = "2.8.4" securityCryptoKtx = "1.1.0"