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:
greenart7c3
2026-05-18 13:14:12 -03:00
committed by GitHub
15 changed files with 766 additions and 2 deletions
+1 -1
View File
@@ -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
}
}
@@ -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,
)
},
)
}
}
}
@@ -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"
+7
View File
@@ -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)
}
}
+1 -1
View File
@@ -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"