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
This commit is contained in:
Claude
2026-05-18 13:42:13 +00:00
parent 405337fc60
commit ec381c376e
3 changed files with 87 additions and 201 deletions
@@ -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<DecodedPsbtInput>,
val outputs: List<DecodedPsbtOutput>,
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",
)
}
@@ -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<DecodedPsbtOutput>) {
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"
+3 -10
View File
@@ -206,18 +206,11 @@
<string name="sign">Sign %1$s</string>
<string name="sign_message">sign message</string>
<string name="sign_psbt">sign PSBT</string>
<string name="psbt_summary">Sign %1$d of %2$d input(s)</string>
<string name="psbt_total_out">Total out</string>
<string name="psbt_sending">Sending</string>
<string name="psbt_change">Change</string>
<string name="psbt_fee">Fee</string>
<string name="psbt_inputs">Inputs</string>
<string name="psbt_outputs">Outputs</string>
<string name="psbt_raw">Raw PSBT (hex)</string>
<string name="psbt_input_signs_label">you sign</string>
<string name="psbt_amount_sats">%1$s sats</string>
<string name="psbt_unknown_value">unknown amount</string>
<string name="psbt_script">Script</string>
<string name="psbt_address">Address</string>
<string name="psbt_outpoint">Outpoint</string>
<string name="psbt_raw">Raw PSBT (hex)</string>
<string name="psbt_decode_error">Could not decode PSBT: %1$s</string>
<string name="signing_as">Signing as</string>
<string name="content">content</string>