diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index 4b9ee64355..04ef93ea46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpException +import com.vitorpamplona.quartz.nip60Cashu.p2pk.anyP2pkLocked import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.Log @@ -909,7 +910,15 @@ class CashuWalletViewModel : ViewModel() { // Keys that can unlock a P2PK-locked token: our wallet key, and // — for a local nsec login only — the identity key (some senders, // e.g. Bey Wallet, P2PK-lock ecash straight to the recipient npub). - val (walletKey, identityKey) = state.redeemSigningKeys() + // Only gathered when a proof is actually locked: redeemSigningKeys() + // decrypts the kind:17375 privkey, which is a signer round-trip on + // a bunker/external signer we shouldn't pay for a plain token. + val (walletKey, identityKey) = + if (parsedTokens.any { it.proofs.anyP2pkLocked() }) { + state.redeemSigningKeys() + } else { + null to null + } val total = parsedTokens.sumOf { ops diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt index a195a4d870..99c013e9f8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PKUnredeemableException +import com.vitorpamplona.quartz.nip60Cashu.p2pk.anyP2pkLocked import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser /** @@ -162,9 +163,12 @@ object CashuReceiveCommands { // Keys that can unlock a P2PK-locked token: the wallet's kind:17375 // key, plus — for a local key account — the identity key (some // senders, e.g. Bey Wallet, P2PK-lock ecash to the recipient npub). - val snap = ctx.cashuSnapshot() - val walletKey = snap.walletEvent?.let { runCatching { it.privkey(ctx.signer) }.getOrNull() } - val identityKey = (ctx.signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey() + // Only decrypt the wallet key when a proof is actually locked (a + // signer round-trip on bunker accounts, wasted on a plain token). + val locked = parsed.any { it.proofs.anyP2pkLocked() } + val walletKey = + if (locked) ctx.cashuSnapshot().walletEvent?.let { runCatching { it.privkey(ctx.signer) }.getOrNull() } else null + val identityKey = if (locked) (ctx.signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey() else null var total = 0L var lastTokenEventId: String? = null var lastHistoryEventId: String? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt index 2d6393c896..e20e8f50a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PK.kt @@ -92,8 +92,13 @@ object P2PK { * Parse a Cashu P2PK secret string. Returns null if the string is not a * NUT-11 P2PK secret. */ - fun parseSecret(secret: String): ParsedP2pk? = - try { + fun parseSecret(secret: String): ParsedP2pk? { + // Fast reject before the (throwing) JSON parse: NUT-10 well-known + // secrets are a JSON array (`["P2PK", …]`), while a plain Cashu secret + // is opaque hex/base64. Redeeming a token calls this once per proof, so + // skipping the parse+exception for the common plain case matters. + if (!secret.looksLikeJsonArray()) return null + return try { val arr = json.parseToJsonElement(secret) as? JsonArray ?: return null if (arr.size < 2) return null val kind = (arr[0] as? JsonPrimitive)?.content @@ -105,6 +110,16 @@ object P2PK { } catch (_: Exception) { null } + } + + /** True when the first non-whitespace char is `[` — i.e. it may be a JSON array. */ + private fun String.looksLikeJsonArray(): Boolean { + for (c in this) { + if (c.isWhitespace()) continue + return c == '[' + } + return false + } /** * BIP-340 Schnorr signature over `sha256(secret_bytes)` used as the unlock