perf(cashu): avoid needless wallet-key decrypt and secret parse on redeem

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
This commit is contained in:
Claude
2026-07-27 01:21:29 +00:00
parent 63ff055b53
commit ee5efba88d
3 changed files with 34 additions and 6 deletions
@@ -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
@@ -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
@@ -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