mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-06 03:18:23 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user