mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
+14
@@ -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()
|
||||
|
||||
+18
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -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))
|
||||
}
|
||||
|
||||
+65
-1
@@ -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
|
||||
+136
@@ -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())
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user