mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
Merge pull request #438 from greenart7c3/claude/add-sign-psbt-nip55-WKptV
Add PSBT signing support via Intent and NIP-46
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = 'com.greenart7c3.nostrsigner'
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.greenart7c3.nostrsigner"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@ enum class SignerType {
|
||||
PING,
|
||||
INVALID,
|
||||
SWITCH_RELAYS,
|
||||
SIGN_PSBT,
|
||||
}
|
||||
|
||||
@@ -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 -> ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DecodedPsbtOutput>,
|
||||
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<Int>()
|
||||
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
|
||||
}
|
||||
}
|
||||
+72
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
@@ -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
|
||||
|
||||
@@ -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<DecodedPsbtOutput>) {
|
||||
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"
|
||||
@@ -205,6 +205,13 @@
|
||||
<string name="wants_you_to">wants you to %1$s</string>
|
||||
<string name="sign">Sign %1$s</string>
|
||||
<string name="sign_message">sign message</string>
|
||||
<string name="sign_psbt">sign PSBT</string>
|
||||
<string name="psbt_sending">Sending</string>
|
||||
<string name="psbt_change">Change</string>
|
||||
<string name="psbt_fee">Fee</string>
|
||||
<string name="psbt_amount_sats">%1$s sats</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>
|
||||
<string name="amber_is_running_in_background">Amber is running in background</string>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user