feat(cashu): claim P2PK-locked tokens and clear errors when we can't

Pasting a P2PK-locked cashu token (NUT-11) into the wallet sent the proofs
to /v1/swap with no witness, so the mint rejected them with an opaque
`witness is missing for p2pk signature` 400. Only the NIP-61 nutzap path
signed witnesses; the generic redeem path had no P2PK support at all.

- quartz: add `signP2pkWitnesses` (pure, resolver-driven) + the
  `P2PKUnredeemableException` it throws when a locked proof's key is
  unknown, and a `CashuMintOperations.redeemToken` that signs then swaps.
- commons: `CashuWalletOps.redeemToken` now takes the wallet P2PK key and
  (local-signer-only) identity key, indexes them by x-only pubkey, and
  routes through the P2PK-aware path. Add `describeRedeemError`, which tells
  a user whose token is locked to their own identity key (e.g. Bey Wallet's
  P2PK send) — but who is on a bunker/external signer that can't sign a raw
  witness — to import their nsec elsewhere to claim it.
- amethyst: `CashuWalletState.redeemSigningKeys()` surfaces both keys
  (identity key only for a local NostrSignerInternal); the wallet ViewModel
  wires them in and reports via `describeRedeemError`.
- cli: `amy cashu receive token` passes the same keys and reports a distinct
  `p2pk_locked` error code.

Adds P2PKRedeemTest covering pass-through, x-only + compressed locks,
verifiable witnesses, and the unredeemable case.

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-26 23:39:49 +00:00
parent 552479a637
commit 63ff055b53
7 changed files with 342 additions and 4 deletions
@@ -26,6 +26,9 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.commands.route
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.token.CashuTokenB64Parser
/**
@@ -156,12 +159,25 @@ object CashuReceiveCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
return try {
// 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()
var total = 0L
var lastTokenEventId: String? = null
var lastHistoryEventId: String? = null
var mint = ""
for (t in parsed) {
val redeemed = ctx.cashuOps().redeemToken(raw, t.proofs, t.mint)
val redeemed =
ctx.cashuOps().redeemToken(
cashuToken = raw,
proofs = t.proofs,
mintUrl = t.mint,
walletP2pkPrivkeyHex = walletKey,
identityPrivkeyHex = identityKey,
)
total += redeemed.amount
lastTokenEventId = redeemed.tokenEvent.id
lastHistoryEventId = redeemed.historyEvent.id
@@ -176,6 +192,8 @@ object CashuReceiveCommands {
),
)
0
} catch (e: P2PKUnredeemableException) {
Output.error("p2pk_locked", "token is P2PK-locked to a key this wallet can't sign for (${e.lockPubKeyHex})")
} catch (e: Exception) {
Output.error("mint_proofs_spent", describe(e))
}