diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt index b6fbe71335..58252ba80d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/quote/CashuMintQuoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/quote/CashuMintQuoteEvent.kt index 1e6d7cbd35..5dde71e5bb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/quote/CashuMintQuoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/quote/CashuMintQuoteEvent.kt @@ -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(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.() -> 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( template.createdAt, @@ -91,5 +145,7 @@ class CashuMintQuoteEvent( encryptedContent, ) } + + private val jsonCodec = Json { ignoreUnknownKeys = true } } } 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 eea34b7188..6ec520e503 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 @@ -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)