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
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
@@ -313,6 +314,19 @@ class CashuWalletState(
*/
suspend fun exportP2pkPrivkeyHex(): String? = walletPrivkeyHex()
/**
* Private keys that can sign a NUT-11 P2PK witness when redeeming a pasted
* `cashuA`/`cashuB` token — see [CashuWalletOps.redeemToken].
*
* `first` is the wallet's kind:17375 P2PK key (for tokens locked to our
* wallet key, e.g. an inbound nutzap handed over out-of-band). `second` is
* the account identity key, present ONLY for a local nsec signer — some
* senders (e.g. Bey Wallet's P2PK send) lock ecash directly to the
* recipient's npub, and only a local key can produce that raw signature.
* A remote (NIP-46) / external (NIP-55) signer yields null there.
*/
suspend fun redeemSigningKeys(): Pair<String?, String?> = walletPrivkeyHex() to (signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey()
private suspend fun walletPrivkeyHex(): String? =
_walletEvent.value?.let { evt ->
runCatching { evt.privkey(signer) }.getOrNull()
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps
import com.vitorpamplona.amethyst.commons.cashu.ops.MintQuoteStarted
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.cashu.ops.describeRedeemError
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -905,10 +906,25 @@ class CashuWalletViewModel : ViewModel() {
_redeemState.value = CashuRedeemFlowState.Redeeming
vm.launchSigner {
try {
val total = parsedTokens.sumOf { ops.redeemToken(trimmed, it.proofs, it.mint).amount }
// 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()
val total =
parsedTokens.sumOf {
ops
.redeemToken(
cashuToken = trimmed,
proofs = it.proofs,
mintUrl = it.mint,
walletP2pkPrivkeyHex = walletKey,
identityPrivkeyHex = identityKey,
).amount
}
_redeemState.value = CashuRedeemFlowState.Completed(total)
} catch (e: Exception) {
_redeemState.value = CashuRedeemFlowState.Error(describeMintError(e))
_redeemState.value =
CashuRedeemFlowState.Error(describeRedeemError(e, account!!.signer.pubKey))
}
}
}
@@ -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))
}
@@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.nip60Cashu.mintApi.RandomSecretFactory
import com.vitorpamplona.quartz.nip60Cashu.mintApi.SecretFactory
import com.vitorpamplona.quartz.nip60Cashu.mintApi.splitAmountIntoDenominations
import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK
import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PKUnredeemableException
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
@@ -630,9 +631,29 @@ class CashuWalletOps(
proofs: List<CashuProof>,
mintUrl: String,
nutzapEventId: String? = null,
/**
* The wallet's NIP-60 P2PK private key (kind:17375 `privkey`, hex).
* Used to sign the NUT-11 witness when the token is locked to our
* wallet key. Null when the wallet hasn't decrypted its kind:17375.
*/
walletP2pkPrivkeyHex: String? = null,
/**
* The account's Nostr identity private key (hex), available ONLY for a
* local nsec signer. Some senders (e.g. Bey Wallet's P2PK send) lock
* ecash directly to the recipient's npub, so we sign the witness with
* the identity key when the lock targets it. Null for remote (NIP-46)
* or external (NIP-55) signers — they can't produce a raw witness
* signature, so such a token surfaces as [P2PKUnredeemableException].
*/
identityPrivkeyHex: String? = null,
): RedeemCompleted {
if (proofs.isEmpty()) throw IllegalArgumentException("Token has no proofs")
val swap = ops(mintUrl).swap(proofs, targetSplit = null)
// Index our candidate signing keys by their x-only pubkey so a locked
// proof can be matched to the key that unlocks it. Empty when we hold
// no keys (e.g. CLI callers) — a locked token then throws a clear
// P2PKUnredeemableException instead of an unsigned swap.
val signingKeys = p2pkKeyIndex(walletP2pkPrivkeyHex, identityPrivkeyHex)
val swap = ops(mintUrl).redeemToken(proofs) { lockXOnly -> signingKeys[lockXOnly] }
val total = swap.keep.sumOf { it.amount }
// All output goes to "keep" since targetSplit was null.
@@ -1276,14 +1297,57 @@ data class RestoreOutcome(
/** Drop the leading parity byte if present so two pubkeys can be compared. */
private fun String.lastHex64(): String = if (length == 66) substring(2) else this
/**
* Index the given private keys by their 32-byte x-only pubkey hex, skipping
* blanks. Used by [CashuWalletOps.redeemToken] to resolve which of our keys (if
* any) unlocks a P2PK proof, comparing against the lock's x-only `data`.
*/
private fun p2pkKeyIndex(vararg privKeysHex: String?): Map<String, String> =
buildMap {
privKeysHex.forEach { hex ->
if (!hex.isNullOrBlank()) {
val xOnly =
Secp256k1
.pubKeyCompress(Secp256k1.pubkeyCreate(hex.hexToByteArray()))
.toHexKey()
.lastHex64()
put(xOnly, hex)
}
}
}
/** Catches mint HTTP / protocol errors and surfaces their detail message. */
fun describeMintError(e: Throwable): String =
when (e) {
is P2PKUnredeemableException -> "This ecash is locked to a public key this wallet can't sign for."
is MintHttpException -> "Mint error (HTTP ${e.httpStatus}): ${e.detail ?: e.message}"
is MintProtocolException -> "Mint refused: ${e.message}"
else -> e.message ?: e::class.simpleName ?: "Unknown error"
}
/**
* Error text for the redeem-token flow. Adds context [describeMintError] can't:
* when a token is P2PK-locked to the user's own [identityPubKeyHex] but we
* couldn't sign for it, it means the current signer is a bunker/external one
* that can't produce a raw witness — so point the user at claiming it elsewhere.
* Falls back to [describeMintError] for everything else.
*/
fun describeRedeemError(
e: Throwable,
identityPubKeyHex: String?,
): String =
if (e is P2PKUnredeemableException) {
val lock = e.lockPubKeyHex.lastHex64()
if (identityPubKeyHex != null && lock == identityPubKeyHex.lastHex64()) {
"This ecash is locked to your Nostr identity key, which the current login can't sign for " +
"(only a local key / nsec login can). Import your nsec into a Cashu wallet to claim it."
} else {
"This ecash is locked to a public key you don't control (${lock.take(12)}…) and can't be claimed here."
}
} else {
describeMintError(e)
}
/** A decrypted, unspent token event ready to be spent. */
data class TokenEntry(
val event: CashuTokenEvent,
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip60Cashu.p2pk
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
/**
* Thrown when a proof set contains a NUT-11 P2PK-locked proof that this wallet
* has no private key to sign for. [lockPubKeyHex] is the pubkey the proof is
* locked to, exactly as it appears in the secret (32-byte x-only or 33-byte
* compressed) — callers may compare it against the user's own keys to craft a
* tailored message (e.g. "locked to your identity key, redeem it elsewhere").
*/
class P2PKUnredeemableException(
val lockPubKeyHex: String,
) : RuntimeException("This ecash is locked to a public key this wallet can't sign for ($lockPubKeyHex).")
/**
* Attach NUT-11 unlock witnesses to any P2PK-locked proofs in [proofs] so the
* set can be spent at `/v1/swap`.
*
* Each proof's secret is inspected via [P2PK.parseSecret]:
* - a plain (non-P2PK) secret passes through unchanged;
* - a P2PK secret is signed with the private key returned by [signingKeyFor],
* which is invoked with the lock's 32-byte **x-only** pubkey hex (the parity
* prefix of a 33-byte compressed `data` is stripped first, since BIP-340
* verification — what the mint runs — is x-only).
*
* When [signingKeyFor] returns null for a locked proof, we hold no key for it
* and [P2PKUnredeemableException] is thrown (naming the original lock pubkey)
* rather than sending an unsigned swap the mint would reject with an opaque
* `witness is missing for p2pk signature` 400.
*/
fun signP2pkWitnesses(
proofs: List<CashuProof>,
signingKeyFor: (lockPubKeyXOnly: String) -> String?,
): List<CashuProof> =
proofs.map { proof ->
val parsed = P2PK.parseSecret(proof.secret) ?: return@map proof
val xOnly = parsed.pubKeyHex.xOnly()
val privKeyHex = signingKeyFor(xOnly) ?: throw P2PKUnredeemableException(parsed.pubKeyHex)
proof.copy(witness = P2PK.signWitness(proof.secret, privKeyHex))
}
/** True when any proof in the set carries a NUT-11 P2PK-locked secret. */
fun List<CashuProof>.anyP2pkLocked(): Boolean = any { P2PK.parseSecret(it.secret) != null }
/** Drop a 33-byte compressed pubkey's parity prefix, yielding the 32-byte x-only hex. */
private fun String.xOnly(): String = if (length == 66) substring(2) else this
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip60Cashu.p2pk
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* [signP2pkWitnesses] — the redeem-side counterpart to spending a pasted
* cashu token that may (or may not) be NUT-11 P2PK-locked.
*
* Reproduces the interop scenario reported against Bey Wallet, which
* P2PK-locks ecash to the recipient's Nostr identity pubkey: before the fix
* the redeem path sent the locked proofs to /v1/swap with no witness and the
* mint rejected them with `witness is missing for p2pk signature`. Here we
* assert the witness is produced and verifies under the lock pubkey.
*/
class P2PKRedeemTest {
// Deterministic key (== 1) — same construction as P2PKTest.
private val priv = "1".padStart(64, '0')
private val xOnlyPub =
Secp256k1Instance
.compressedPubKeyFor(priv.hexToByteArray())
.copyOfRange(1, 33)
.toHexKey()
private fun lockedProof(lockPubKeyHex: String) = CashuProof(id = "keyset1", amount = 4, secret = P2PK.lockedSecret(lockPubKeyHex), c = "c-hex")
private fun plainProof() = CashuProof(id = "keyset1", amount = 1, secret = "9a1b...plain-secret", c = "c-hex")
private fun witnessVerifies(
proof: CashuProof,
xOnlyHex: String,
): Boolean {
val witness = proof.witness ?: return false
val sigs = (Json.parseToJsonElement(witness) as JsonObject)["signatures"] as JsonArray
val sigHex = (sigs[0] as JsonPrimitive).content
return Secp256k1Instance.verifySchnorr(
signature = sigHex.hexToByteArray(),
hash = sha256(proof.secret.encodeToByteArray()),
pubKey = xOnlyHex.hexToByteArray(),
)
}
@Test
fun plainProofPassesThroughUnsigned() {
val proof = plainProof()
val out = signP2pkWitnesses(listOf(proof)) { error("resolver must not be called for a plain proof") }
assertEquals(1, out.size)
assertNull(out[0].witness, "a non-P2PK proof must not gain a witness")
assertEquals(proof, out[0])
}
@Test
fun lockedProofGetsVerifiableWitness() {
// Locked to the x-only key (Nostr-identity style, 64 hex).
val out =
signP2pkWitnesses(listOf(lockedProof(xOnlyPub))) { lockXOnly ->
assertEquals(xOnlyPub, lockXOnly, "resolver is queried by the lock's x-only pubkey")
priv
}
assertTrue(witnessVerifies(out[0], xOnlyPub), "witness must verify under the lock pubkey")
}
@Test
fun compressedLockResolvesByXOnly() {
// Locked to the 33-byte compressed form (02/03 prefix) — the resolver
// must still be asked by the 32-byte x-only pubkey, and the witness
// must verify (the mint runs x-only BIP-340).
val compressed = "02$xOnlyPub"
val out =
signP2pkWitnesses(listOf(lockedProof(compressed))) { lockXOnly ->
assertEquals(xOnlyPub, lockXOnly, "the parity prefix must be stripped before resolving")
priv
}
assertTrue(witnessVerifies(out[0], xOnlyPub))
}
@Test
fun unknownLockThrowsNamingThePubkey() {
val compressed = "02$xOnlyPub"
val e =
assertFailsWith<P2PKUnredeemableException> {
signP2pkWitnesses(listOf(lockedProof(compressed))) { null }
}
// The exception carries the lock exactly as it appears in the secret so
// callers can compare it against the user's own keys.
assertEquals(compressed, e.lockPubKeyHex)
}
@Test
fun mixedSetSignsOnlyTheLockedProofs() {
val plain = plainProof()
val locked = lockedProof(xOnlyPub)
val out = signP2pkWitnesses(listOf(plain, locked)) { priv }
assertNull(out[0].witness, "plain proof stays unsigned")
assertTrue(witnessVerifies(out[1], xOnlyPub), "locked proof is signed")
}
@Test
fun anyP2pkLockedDetectsLockedProofs() {
assertFalse(listOf(plainProof()).anyP2pkLocked())
assertTrue(listOf(plainProof(), lockedProof(xOnlyPub)).anyP2pkLocked())
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke
import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK
import com.vitorpamplona.quartz.nip60Cashu.p2pk.signP2pkWitnesses
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
@@ -215,6 +216,28 @@ class CashuMintOperations(
return swap(unlocked, targetSplit = null)
}
/**
* Redeem the proofs of an out-of-band token (a pasted `cashuA`/`cashuB`
* string) into fresh proofs in our wallet.
*
* Unlike [redeemNutzap] — which assumes every proof is P2PK-locked to our
* single wallet key — a pasted token may be plain, P2PK-locked, or a mix,
* and the lock may target any key. [signP2pkWitnesses] inspects each secret
* and, for locked proofs, asks [signingKeyFor] for the matching private key
* (by the lock's x-only pubkey). Plain proofs pass straight through.
*
* Throws [P2PKUnredeemableException] if a locked proof's key is unknown —
* caught upstream to show "this ecash is locked to a key you don't control"
* instead of leaking the mint's raw `witness is missing` 400.
*/
suspend fun redeemToken(
proofs: List<CashuProof>,
signingKeyFor: (lockPubKeyXOnly: String) -> String?,
): SwapResult {
if (proofs.isEmpty()) throw IllegalArgumentException("Nothing to redeem")
return swap(signP2pkWitnesses(proofs, signingKeyFor), targetSplit = null)
}
/**
* Swap our unlocked proofs so that [targetSplit] sats are returned as
* NUT-11 P2PK-locked proofs (recipient-spendable only with their