From ee5efba88d731de6c0ae2ede7bf52be7b27449f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 01:21:29 +0000 Subject: [PATCH] perf(cashu): avoid needless wallet-key decrypt and secret parse on redeem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the P2PK redeem support: - Gate the redeem signing-key gathering behind an actual P2PK lock. The wallet P2PK key is decrypted from kind:17375 via the signer — a network round-trip on a NIP-46 bunker (and a possible approval prompt). The common case (a plain, unlocked token) needs none of it, so only fetch keys when `anyP2pkLocked()` is true. Applies to both the wallet ViewModel and the amy CLI token-redeem path. - Fast-reject in `P2PK.parseSecret`: NUT-10 well-known secrets are JSON arrays, so bail before the throwing JSON parse when the string isn't one. Redeem parses every proof's secret once, so this drops a thrown+caught exception per plain proof (also benefits the nutzap redeem path). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4 --- .../loggedIn/wallet/CashuWalletViewModel.kt | 11 ++++++++++- .../commands/cashu/CashuReceiveCommands.kt | 10 +++++++--- .../quartz/nip60Cashu/p2pk/P2PK.kt | 19 +++++++++++++++++-- 3 files changed, 34 insertions(+), 6 deletions(-) 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