feat(cashu): wire NUT-20 signed mint quote into start/complete flow

Mints that support NUT-20 require the wallet to prove ownership of the
quote when redeeming it for proofs. Without this, anyone who observes
the quote id can race to /v1/mint/bolt11 and steal the freshly-minted
proofs. The earlier commit landed the protocol primitive
(MintQuoteSignature); this one threads it through the live flow.

What's here:

- CashuMintQuoteEvent gets a JSON content shape — `{"quote_id":
  "...", "p2pk_priv": "..."}` instead of the bare quote-id string.
  Backwards compatible: build() falls back to the plain-string shape
  when no signing key is supplied, and decrypt() tries JSON first then
  treats parse failure as a legacy plain string. Existing wallets keep
  reading their old kind:7374 events; new ones carry the NUT-20 key.

- New CashuMintQuoteEvent.decrypt() returns both the quote id and the
  optional signing privkey in one go. quoteId() / signingPrivkey()
  are thin accessors over decrypt() so call sites that don't care
  about the key stay unchanged.

- CashuMintOperations.requestMintQuote(amountSats, signingPubkey?)
  forwards the pubkey to the mint. Always-on is safe: mints that
  don't support NUT-20 ignore the extra field per spec.

- CashuMintOperations.mintProofs(quote, amountSats, signingPrivkey?)
  signs `sha256(quote || B_0 || B_1 || …)` with the privkey and
  attaches the signature when present. Null signingPrivkey skips
  NUT-20 entirely (legacy events that don't carry a key).

- CashuWalletOps.startMintFromLightning generates a fresh per-quote
  keypair, sends the pubkey to the mint, and stashes the privkey
  inside the encrypted kind:7374. Ephemeral keypair per quote keeps
  the privacy property — no observable correlation between quotes.

- CashuWalletOps.completeMintFromLightning reads back both fields
  from the kind:7374 in one decrypt() call and forwards them to
  mintProofs. Resume-from-relaunch works because the key lives with
  the quote event (which is restored on app start), not in memory.

Compatibility:
- New quote events carry the NUT-20 key; old ones don't and skip the
  signature. Either way the on-wire shape of /v1/mint/bolt11 is
  identical to NUT-04 when signature is null.
- Pre-NUT-20 mints ignore the unknown pubkey field — JSON tolerance
  per the spec.
- Persistence: the privkey is co-located with the quote in the same
  NIP-44-encrypted blob, so backup/restore semantics are unchanged.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:44 +00:00
parent d7e447428c
commit f6b3c40b3f
3 changed files with 114 additions and 9 deletions
@@ -155,12 +155,20 @@ class CashuWalletOps(
mintUrl: String,
amountSats: Long,
): MintQuoteStarted {
val response = ops(mintUrl).requestMintQuote(amountSats)
// NUT-20: generate a fresh per-quote keypair, bind the mint quote
// to it, and persist the privkey inside the encrypted kind:7374
// so the resume-on-next-launch path can sign the matching mint
// request later. Mints that don't support NUT-20 ignore the
// pubkey field (per spec), so always-on is safe.
val signingPriv = Bdhke.randomScalar()
val signingPub = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(signingPriv)).toHexKey()
val response = ops(mintUrl).requestMintQuote(amountSats, signingPubkey = signingPub)
val quoteTemplate =
CashuMintQuoteEvent.build(
quoteId = response.quote,
mintUrl = mintUrl,
signer = signer,
signingPrivkey = signingPriv.toHexKey(),
)
val quoteEvent = signer.sign(quoteTemplate)
publish(quoteEvent)
@@ -205,8 +213,19 @@ class CashuWalletOps(
amountSats: Long,
): MintCompleted {
seedWarmer()
val quoteId = quoteEvent.quoteId(signer)
val minted = ops(mintUrl).mintProofs(quoteId, amountSats)
// NUT-20: pull both the quote id and the persisted signing privkey
// out of the kind:7374 in one decrypt. Legacy quote events stored
// only the quote id (string), so signingPrivkey is null in that
// case and we skip the signature — mints that need it will
// reject, but old quotes opened pre-NUT-20 wouldn't have had a
// pubkey on them anyway, so the mint should accept.
val decryptedQuote = quoteEvent.decrypt(signer)
val minted =
ops(mintUrl).mintProofs(
quote = decryptedQuote.quoteId,
amountSats = amountSats,
signingPrivkey = decryptedQuote.p2pkPriv,
)
val tokenContent = minted.toTokenContent(mintUrl)
val tokenTemplate = CashuTokenEvent.build(tokenContent, signer)
@@ -30,6 +30,9 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* NIP-60 Cashu Mint Quote Event (kind:7374).
@@ -52,9 +55,39 @@ class CashuMintQuoteEvent(
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/**
* Decrypts the content to get the quote ID.
* Decrypt the full quote payload — quote id plus, when present, the
* NUT-20 signing privkey that binds this quote to a specific wallet
* keypair. The content schema has two historic shapes:
* - pre-NUT-20: plain string == the quote id
* - post-NUT-20: JSON `{"quote_id": "...", "p2pk_priv": "..."}`
* The decode tries the JSON shape first; on parse failure it treats
* the entire decrypted payload as the quote id (legacy events).
*/
suspend fun quoteId(signer: NostrSigner): String = signer.nip44Decrypt(content, pubKey)
suspend fun decrypt(signer: NostrSigner): Decrypted {
val plaintext = signer.nip44Decrypt(content, pubKey)
return runCatching { jsonCodec.decodeFromString<Decrypted>(plaintext) }
.getOrElse { Decrypted(quoteId = plaintext, p2pkPriv = null) }
}
/**
* Decrypts the content to get the quote ID. Kept for backwards
* compatibility with callers that don't care about the NUT-20 key.
*/
suspend fun quoteId(signer: NostrSigner): String = decrypt(signer).quoteId
/**
* The NUT-20 signing private key for this quote, when one was generated
* at quote-creation time. Null for pre-NUT-20 events or quotes the
* wallet chose not to bind to a key.
*/
suspend fun signingPrivkey(signer: NostrSigner): String? = decrypt(signer).p2pkPriv
/** Decrypted kind:7374 content. See [decrypt]. */
@Serializable
data class Decrypted(
@SerialName("quote_id") val quoteId: String,
@SerialName("p2pk_priv") val p2pkPriv: String? = null,
)
/**
* Gets the mint URL from the public tags.
@@ -73,6 +106,15 @@ class CashuMintQuoteEvent(
quoteId: String,
mintUrl: String,
signer: NostrSigner,
/**
* NUT-20: the ephemeral signing private key (32-byte hex) the
* wallet committed to when opening this mint quote. Carried
* inside the encrypted content so the resume-on-next-launch
* path can pick it up and sign the matching mint request.
* Null skips NUT-20 entirely — older wallets / mints stay
* compatible (plain-string content shape).
*/
signingPrivkey: String? = null,
expirationTimestamp: Long = TimeUtils.now() + TWO_WEEKS_SECONDS,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CashuMintQuoteEvent>.() -> Unit = {},
@@ -82,7 +124,19 @@ class CashuMintQuoteEvent(
add(arrayOf("mint", mintUrl))
initializer()
}.let { template ->
val encryptedContent = signer.nip44Encrypt(quoteId, signer.pubKey)
val payload =
if (signingPrivkey != null) {
jsonCodec.encodeToString(
Decrypted.serializer(),
Decrypted(quoteId = quoteId, p2pkPriv = signingPrivkey),
)
} else {
// Legacy plain-string shape for events that don't carry
// a signing key — keeps wire compatibility with any
// pre-NUT-20 reader that expects a bare quote id.
quoteId
}
val encryptedContent = signer.nip44Encrypt(payload, signer.pubKey)
EventTemplate<CashuMintQuoteEvent>(
template.createdAt,
@@ -91,5 +145,7 @@ class CashuMintQuoteEvent(
encryptedContent,
)
}
private val jsonCodec = Json { ignoreUnknownKeys = true }
}
}
@@ -53,9 +53,19 @@ class CashuMintOperations(
* The wallet should publish a kind:7374 quote event before returning to the
* UI so that an interrupted flow can be recovered on next login.
*/
suspend fun requestMintQuote(amountSats: Long): MintQuoteBolt11ResponseDto =
suspend fun requestMintQuote(
amountSats: Long,
/**
* NUT-20: optional 33-byte compressed pubkey hex that binds this
* quote to a wallet keypair. Mints that support NUT-20 will refuse
* to honour [mintProofs] without a matching signature. Mints that
* don't ignore the field (extra-JSON tolerance), so always-on is
* safe — no feature detection needed at quote time.
*/
signingPubkey: String? = null,
): MintQuoteBolt11ResponseDto =
client.mintQuoteBolt11(
MintQuoteBolt11RequestDto(unit = "sat", amount = amountSats),
MintQuoteBolt11RequestDto(unit = "sat", amount = amountSats, pubkey = signingPubkey),
)
suspend fun mintQuoteStatus(quote: String): MintQuoteBolt11ResponseDto = client.mintQuoteBolt11Status(quote)
@@ -72,15 +82,35 @@ class CashuMintOperations(
suspend fun mintProofs(
quote: String,
amountSats: Long,
/**
* NUT-20: when [requestMintQuote] was called with `signingPubkey`,
* the matching mint request MUST carry a BIP-340 Schnorr signature
* from the same key over `sha256(quote || B_0 || B_1 || …)`.
* Pass the 32-byte private key (hex) here and the operation
* computes + attaches the signature; pass null to skip NUT-20 and
* fall back to NUT-04 behaviour.
*/
signingPrivkey: String? = null,
): MintedProofs {
val keyset = fetchKeyset()
val outputs = createBlindedOutputs(amountSats, keyset)
val outputDtos = outputs.map { it.toDto() }
val signature =
signingPrivkey?.let {
MintQuoteSignature.sign(
quoteId = quote,
blindedMessageHexes = outputDtos.map { dto -> dto.bTick },
signingPrivkey = it.hexToByteArray(),
)
}
val response =
client.mintBolt11(
MintBolt11RequestDto(
quote = quote,
outputs = outputs.map { it.toDto() },
outputs = outputDtos,
signature = signature,
),
)
val proofs = unblindAll(outputs, response.signatures, keyset)