mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
+22
-3
@@ -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)
|
||||
|
||||
+59
-3
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
+33
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user