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