From 405337fc60a484112fc3396373c0dac57c7cd436 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 11:35:06 +0000 Subject: [PATCH 1/3] Add sign_psbt support for NIP-55 and NIP-46 bunker requests Quartz 1.10.0 introduces NostrSigner.signPsbt for signing key-path P2TR inputs with the account's BIP-340 secp256k1 key. Wire it through both ingestion paths: nostrsigner:// intents + ContentProvider, and kind 24133 bunker requests. Approval screens decode the PSBT and show inputs, outputs, fee, and bech32m/bech32 addresses for the segwit script types this signer can produce. Updates compileSdk to 37 (required by Quartz 1.10.0). https://claude.ai/code/session_01LBVtkHSfr3NDRnYHeyPVVP --- app/build.gradle | 2 +- .../greenart7c3/nostrsigner/SignerProvider.kt | 94 +++++ .../greenart7c3/nostrsigner/models/Account.kt | 2 + .../nostrsigner/models/Permission.kt | 3 + .../nostrsigner/models/SignerType.kt | 1 + .../nostrsigner/service/BunkerRequestUtils.kt | 2 + .../nostrsigner/service/IntentUtils.kt | 41 +++ .../nostrsigner/service/PsbtDecoder.kt | 191 ++++++++++ .../components/BunkerSingleEventHomeScreen.kt | 72 ++++ .../components/IntentSingleEventHomeScreen.kt | 58 ++++ .../nostrsigner/ui/components/SignPsbt.kt | 326 ++++++++++++++++++ app/src/main/res/values/strings.xml | 14 + .../service/BunkerRequestUtilsTest.kt | 11 + .../nostrsigner/service/PsbtDecoderTest.kt | 67 ++++ gradle/libs.versions.toml | 2 +- 15 files changed, 884 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/service/PsbtDecoder.kt create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt create mode 100644 app/src/test/java/com/greenart7c3/nostrsigner/service/PsbtDecoderTest.kt 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..cd7dd371 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/PsbtDecoder.kt @@ -0,0 +1,191 @@ +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.inputTapInternalKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.inputWitnessUtxo +import com.vitorpamplona.quartz.utils.Hex + +@Immutable +data class DecodedPsbtInput( + val prevTxid: String, + val prevVout: Long, + val valueSats: Long?, + val scriptPubKeyHex: String?, + val address: String?, + val controlled: Boolean, +) + +@Immutable +data class DecodedPsbtOutput( + val valueSats: Long, + val scriptPubKeyHex: String, + val address: String?, +) + +@Immutable +data class DecodedPsbt( + val version: Long, + val lockTime: Long, + val inputs: List, + val outputs: List, + val feeSats: Long?, + val controlledInputCount: Int, + val parseError: String?, +) + +object PsbtDecoder { + 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 outputTotal = 0L + var inputTotal = 0L + var controlledCount = 0 + + val inputs = tx.inputs.mapIndexed { index, txIn -> + val witnessUtxo = psbt.inputWitnessUtxo(index) + val valueSats = witnessUtxo?.valueSats + val scriptPubKey = witnessUtxo?.scriptPubKey + if (valueSats == null) { + anyInputMissingValue = true + } else { + inputTotal += valueSats + } + val internalKey = psbt.inputTapInternalKey(index) + val controlled = internalKey != null && internalKey.contentEquals(xOnlyPubKey) + if (controlled) controlledCount++ + + DecodedPsbtInput( + prevTxid = txIn.outPoint.txid, + prevVout = txIn.outPoint.vout, + valueSats = valueSats, + scriptPubKeyHex = scriptPubKey?.let { Hex.encode(it) }, + address = scriptPubKey?.let { scriptPubKeyToAddress(it) }, + controlled = controlled, + ) + } + + val outputs = tx.outputs.map { txOut -> + outputTotal += txOut.valueSats + DecodedPsbtOutput( + valueSats = txOut.valueSats, + scriptPubKeyHex = Hex.encode(txOut.scriptPubKey), + address = scriptPubKeyToAddress(txOut.scriptPubKey), + ) + } + + val fee = if (anyInputMissingValue) null else inputTotal - outputTotal + + DecodedPsbt( + version = tx.version, + lockTime = tx.lockTime, + inputs = inputs, + outputs = outputs, + feeSats = fee, + controlledInputCount = controlledCount, + parseError = null, + ) + } catch (e: Exception) { + DecodedPsbt( + version = 0, + lockTime = 0, + inputs = emptyList(), + outputs = emptyList(), + feeSats = null, + controlledInputCount = 0, + 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..fc8be306 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt @@ -0,0 +1,326 @@ +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.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 + } + + SummaryCard(decoded) + Spacer(Modifier.size(8.dp)) + InputsCard(decoded) + Spacer(Modifier.size(8.dp)) + OutputsCard(decoded) + Spacer(Modifier.size(8.dp)) + RawPsbtCard(psbtHex) +} + +@Composable +private fun SummaryCard(decoded: DecodedPsbt) { + val totalOut = decoded.outputs.sumOf { it.valueSats } + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(8.dp)) { + Text( + stringResource( + R.string.psbt_summary, + decoded.controlledInputCount, + decoded.inputs.size, + ), + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.size(4.dp)) + LabeledRow(stringResource(R.string.psbt_total_out), formatSats(totalOut)) + decoded.feeSats?.let { + LabeledRow(stringResource(R.string.psbt_fee), formatSats(it)) + } + LabeledRow("Version", decoded.version.toString()) + if (decoded.lockTime != 0L) { + LabeledRow("Locktime", decoded.lockTime.toString()) + } + } + } +} + +@Composable +private fun InputsCard(decoded: DecodedPsbt) { + var expanded by remember { mutableStateOf(false) } + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${stringResource(R.string.psbt_inputs)} (${decoded.inputs.size})", + fontWeight = FontWeight.Bold, + ) + ToggleText(expanded) { expanded = !expanded } + } + if (expanded) { + decoded.inputs.forEachIndexed { i, input -> + if (i > 0) Spacer(Modifier.size(8.dp)) + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("#$i", fontWeight = FontWeight.SemiBold) + if (input.controlled) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + stringResource(R.string.psbt_input_signs_label), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + LabeledRow( + stringResource(R.string.psbt_outpoint), + "${input.prevTxid}:${input.prevVout}", + ) + LabeledRow( + "Value", + input.valueSats?.let { formatSats(it) } + ?: stringResource(R.string.psbt_unknown_value), + ) + input.address?.let { + LabeledRow(stringResource(R.string.psbt_address), it) + } ?: input.scriptPubKeyHex?.let { + LabeledRow(stringResource(R.string.psbt_script), it) + } + } + } + } + } + } +} + +@Composable +private fun OutputsCard(decoded: DecodedPsbt) { + var expanded by remember { mutableStateOf(true) } + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${stringResource(R.string.psbt_outputs)} (${decoded.outputs.size})", + fontWeight = FontWeight.Bold, + ) + ToggleText(expanded) { expanded = !expanded } + } + if (expanded) { + decoded.outputs.forEachIndexed { i, output -> + if (i > 0) Spacer(Modifier.size(8.dp)) + Column { + Text("#$i", fontWeight = FontWeight.SemiBold) + LabeledRow("Value", formatSats(output.valueSats)) + output.address?.let { + LabeledRow(stringResource(R.string.psbt_address), it) + } ?: LabeledRow(stringResource(R.string.psbt_script), output.scriptPubKeyHex) + } + } + } + } + } +} + +@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() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.psbt_raw), fontWeight = FontWeight.Bold) + ToggleText(expanded) { expanded = !expanded } + } + if (expanded) { + Text( + psbtHex, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + fontSize = 12.sp, + ) + } + } + } +} + +@Composable +private fun LabeledRow(label: String, value: String) { + Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text(label, fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text(value, fontSize = 14.sp) + } +} + +@Composable +private fun ToggleText(expanded: Boolean, onClick: () -> Unit) { + Text( + text = if (expanded) "Hide" else "Show", + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 4.dp), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.primary, + ) +} + +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..fa4d5afd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -205,6 +205,20 @@ wants you to %1$s Sign %1$s sign message + sign PSBT + Sign %1$d of %2$d input(s) + Total out + Fee + Inputs + Outputs + Raw PSBT (hex) + you sign + %1$s sats + unknown amount + Script + Address + Outpoint + 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" From ec381c376e26cb8be9a24c49c4644e4638892b5a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 13:42:13 +0000 Subject: [PATCH 2/3] Simplify PSBT approval screen to amount/change/addresses/fee Drop the inputs section, version/locktime, and the per-input details. The approval screen now shows just the data a user needs to authorise the transaction: each recipient address with its amount, any change going back to the signer, and the fee. Change is detected via PSBT_OUT_TAP_INTERNAL_KEY matching the account's x-only pubkey. https://claude.ai/code/session_01LBVtkHSfr3NDRnYHeyPVVP --- .../nostrsigner/service/PsbtDecoder.kt | 74 +++---- .../nostrsigner/ui/components/SignPsbt.kt | 201 ++++++------------ app/src/main/res/values/strings.xml | 13 +- 3 files changed, 87 insertions(+), 201 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/PsbtDecoder.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/PsbtDecoder.kt index cd7dd371..76fbe4ee 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/PsbtDecoder.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/PsbtDecoder.kt @@ -3,100 +3,74 @@ 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.inputTapInternalKey import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.inputWitnessUtxo import com.vitorpamplona.quartz.utils.Hex -@Immutable -data class DecodedPsbtInput( - val prevTxid: String, - val prevVout: Long, - val valueSats: Long?, - val scriptPubKeyHex: String?, - val address: String?, - val controlled: Boolean, -) - @Immutable data class DecodedPsbtOutput( val valueSats: Long, val scriptPubKeyHex: String, val address: String?, + val isChange: Boolean, ) @Immutable data class DecodedPsbt( - val version: Long, - val lockTime: Long, - val inputs: List, val outputs: List, + val totalSent: Long, + val totalChange: Long, val feeSats: Long?, - val controlledInputCount: Int, 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 outputTotal = 0L var inputTotal = 0L - var controlledCount = 0 - - val inputs = tx.inputs.mapIndexed { index, txIn -> - val witnessUtxo = psbt.inputWitnessUtxo(index) - val valueSats = witnessUtxo?.valueSats - val scriptPubKey = witnessUtxo?.scriptPubKey - if (valueSats == null) { - anyInputMissingValue = true - } else { - inputTotal += valueSats - } - val internalKey = psbt.inputTapInternalKey(index) - val controlled = internalKey != null && internalKey.contentEquals(xOnlyPubKey) - if (controlled) controlledCount++ - - DecodedPsbtInput( - prevTxid = txIn.outPoint.txid, - prevVout = txIn.outPoint.vout, - valueSats = valueSats, - scriptPubKeyHex = scriptPubKey?.let { Hex.encode(it) }, - address = scriptPubKey?.let { scriptPubKeyToAddress(it) }, - controlled = controlled, - ) + for (i in tx.inputs.indices) { + val value = psbt.inputWitnessUtxo(i)?.valueSats + if (value == null) anyInputMissingValue = true else inputTotal += value } - val outputs = tx.outputs.map { txOut -> - outputTotal += txOut.valueSats + 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 - outputTotal + val fee = if (anyInputMissingValue) null else inputTotal - totalOut DecodedPsbt( - version = tx.version, - lockTime = tx.lockTime, - inputs = inputs, outputs = outputs, + totalSent = totalSent, + totalChange = totalChange, feeSats = fee, - controlledInputCount = controlledCount, parseError = null, ) } catch (e: Exception) { DecodedPsbt( - version = 0, - lockTime = 0, - inputs = emptyList(), outputs = emptyList(), + totalSent = 0, + totalChange = 0, feeSats = null, - controlledInputCount = 0, parseError = e.message ?: e::class.simpleName ?: "parse error", ) } 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 index fc8be306..7a3bc04b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -30,6 +29,7 @@ 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 @@ -42,9 +42,7 @@ fun SignPsbt( onAccept: (RememberType) -> Unit, onReject: (RememberType) -> Unit, ) { - var rememberType by remember { - mutableStateOf(RememberType.NEVER) - } + var rememberType by remember { mutableStateOf(RememberType.NEVER) } Column(modifier) { LocalAppIcon(packageName) @@ -90,9 +88,7 @@ fun BunkerSignPsbt( onAccept: (RememberType) -> Unit, onReject: (RememberType) -> Unit, ) { - var rememberType by remember { - mutableStateOf(RememberType.NEVER) - } + var rememberType by remember { mutableStateOf(RememberType.NEVER) } Column(modifier) { val message = stringResource(R.string.sign_psbt) @@ -145,130 +141,68 @@ private fun PsbtBody(psbtHex: String, decoded: DecodedPsbt) { return } - SummaryCard(decoded) - Spacer(Modifier.size(8.dp)) - InputsCard(decoded) - Spacer(Modifier.size(8.dp)) - OutputsCard(decoded) - Spacer(Modifier.size(8.dp)) + 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 SummaryCard(decoded: DecodedPsbt) { - val totalOut = decoded.outputs.sumOf { it.valueSats } - Card(modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(8.dp)) { - Text( - stringResource( - R.string.psbt_summary, - decoded.controlledInputCount, - decoded.inputs.size, - ), - fontWeight = FontWeight.Bold, - ) - Spacer(Modifier.size(4.dp)) - LabeledRow(stringResource(R.string.psbt_total_out), formatSats(totalOut)) - decoded.feeSats?.let { - LabeledRow(stringResource(R.string.psbt_fee), formatSats(it)) - } - LabeledRow("Version", decoded.version.toString()) - if (decoded.lockTime != 0L) { - LabeledRow("Locktime", decoded.lockTime.toString()) - } - } - } -} - -@Composable -private fun InputsCard(decoded: DecodedPsbt) { - var expanded by remember { mutableStateOf(false) } +private fun SectionCard(title: String, totalSats: Long, entries: List) { Card(modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(8.dp)) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - "${stringResource(R.string.psbt_inputs)} (${decoded.inputs.size})", - fontWeight = FontWeight.Bold, - ) - ToggleText(expanded) { expanded = !expanded } + Text(title, fontWeight = FontWeight.Bold) + Text(formatSats(totalSats), fontWeight = FontWeight.Bold) } - if (expanded) { - decoded.inputs.forEachIndexed { i, input -> - if (i > 0) Spacer(Modifier.size(8.dp)) - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text("#$i", fontWeight = FontWeight.SemiBold) - if (input.controlled) { - Surface( - color = MaterialTheme.colorScheme.primaryContainer, - ) { - Text( - stringResource(R.string.psbt_input_signs_label), - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - ) - } - } - } - LabeledRow( - stringResource(R.string.psbt_outpoint), - "${input.prevTxid}:${input.prevVout}", - ) - LabeledRow( - "Value", - input.valueSats?.let { formatSats(it) } - ?: stringResource(R.string.psbt_unknown_value), - ) - input.address?.let { - LabeledRow(stringResource(R.string.psbt_address), it) - } ?: input.scriptPubKeyHex?.let { - LabeledRow(stringResource(R.string.psbt_script), it) - } - } - } - } - } - } -} - -@Composable -private fun OutputsCard(decoded: DecodedPsbt) { - var expanded by remember { mutableStateOf(true) } - Card(modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(8.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "${stringResource(R.string.psbt_outputs)} (${decoded.outputs.size})", - fontWeight = FontWeight.Bold, - ) - ToggleText(expanded) { expanded = !expanded } - } - if (expanded) { - decoded.outputs.forEachIndexed { i, output -> - if (i > 0) Spacer(Modifier.size(8.dp)) - Column { - Text("#$i", fontWeight = FontWeight.SemiBold) - LabeledRow("Value", formatSats(output.valueSats)) - output.address?.let { - LabeledRow(stringResource(R.string.psbt_address), it) - } ?: LabeledRow(stringResource(R.string.psbt_script), output.scriptPubKeyHex) - } + 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, + ) } } } @@ -283,12 +217,17 @@ private fun RawPsbtCard(psbtHex: String) { 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) - ToggleText(expanded) { expanded = !expanded } + Text( + text = if (expanded) "Hide" else "Show", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.primary, + ) } if (expanded) { Text( @@ -303,24 +242,4 @@ private fun RawPsbtCard(psbtHex: String) { } } -@Composable -private fun LabeledRow(label: String, value: String) { - Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { - Text(label, fontSize = 12.sp, fontWeight = FontWeight.Bold) - Text(value, fontSize = 14.sp) - } -} - -@Composable -private fun ToggleText(expanded: Boolean, onClick: () -> Unit) { - Text( - text = if (expanded) "Hide" else "Show", - modifier = Modifier - .clickable(onClick = onClick) - .padding(horizontal = 4.dp), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.primary, - ) -} - 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 fa4d5afd..725107b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -206,18 +206,11 @@ Sign %1$s sign message sign PSBT - Sign %1$d of %2$d input(s) - Total out + Sending + Change Fee - Inputs - Outputs - Raw PSBT (hex) - you sign %1$s sats - unknown amount - Script - Address - Outpoint + Raw PSBT (hex) Could not decode PSBT: %1$s Signing as content From 80d42ba3c80377c7d1009079a551a228feaa5931 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 13:56:29 +0000 Subject: [PATCH 3/3] Fix crash when expanding raw PSBT card The Text had a verticalScroll modifier but the parent Column has no bounded height, so Compose threw IllegalStateException on draw. Long hex wraps naturally; no inner scroll needed. https://claude.ai/code/session_01LBVtkHSfr3NDRnYHeyPVVP --- .../com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 index 7a3bc04b..57ed1556 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SignPsbt.kt @@ -8,8 +8,6 @@ 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.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -232,9 +230,7 @@ private fun RawPsbtCard(psbtHex: String) { if (expanded) { Text( psbtHex, - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), fontSize = 12.sp, ) }