From 63ff055b5392e922db384fea0d358bd7ee7ab25b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 23:39:49 +0000 Subject: [PATCH] feat(cashu): claim P2PK-locked tokens and clear errors when we can't MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4 --- .../model/nip60Cashu/CashuWalletState.kt | 14 ++ .../loggedIn/wallet/CashuWalletViewModel.kt | 20 ++- .../commands/cashu/CashuReceiveCommands.kt | 20 ++- .../commons/cashu/ops/CashuWalletOps.kt | 66 ++++++++- .../quartz/nip60Cashu/p2pk/P2PKRedeem.kt | 67 +++++++++ .../quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt | 136 ++++++++++++++++++ .../nip60Cashu/mintApi/CashuMintOperations.kt | 23 +++ 7 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 9ff58c3534..3f9c610a9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -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 = walletPrivkeyHex() to (signer as? NostrSignerInternal)?.keyPair?.privKey?.toHexKey() + private suspend fun walletPrivkeyHex(): String? = _walletEvent.value?.let { evt -> runCatching { evt.privkey(signer) }.getOrNull() 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 e48933c529..4b9ee64355 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 @@ -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)) } } } 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 a1eb3b0ca8..a195a4d870 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 @@ -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)) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index 530006ad33..67508412be 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -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, 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 = + 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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt new file mode 100644 index 0000000000..e1cbd959a8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeem.kt @@ -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, + signingKeyFor: (lockPubKeyXOnly: String) -> String?, +): List = + 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.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 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt new file mode 100644 index 0000000000..1c07d49076 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/p2pk/P2PKRedeemTest.kt @@ -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 { + 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()) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index 87bfe8e55d..bd43bfc9ca 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -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, + 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