diff --git a/.github/workflows/nutshell-integration.yml b/.github/workflows/nutshell-integration.yml index ef25b0c1..7ca351ee 100644 --- a/.github/workflows/nutshell-integration.yml +++ b/.github/workflows/nutshell-integration.yml @@ -8,8 +8,7 @@ jobs: steps: - name: Pull and start mint run: | - docker run -d -p 3338:3338 --name nutshell -e MINT_LIGHTNING_BACKEND=FakeWallet -e MINT_INPUT_FEE_PPK=100 -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.16.2 poetry run mint - + docker run -d -p 3338:3338 --name nutshell -e MINT_LIGHTNING_BACKEND=FakeWallet -e MINT_INPUT_FEE_PPK=100 -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.16.4 poetry run mint - name: Check running containers run: docker ps diff --git a/src/CashuWallet.ts b/src/CashuWallet.ts index 03efd064..f0795b22 100644 --- a/src/CashuWallet.ts +++ b/src/CashuWallet.ts @@ -42,7 +42,8 @@ import { type Token, MPPOption, MeltQuoteOptions, - SwapTransaction + SwapTransaction, + LockedMintQuoteResponse } from './model/types/index.js'; import { SubscriptionCanceller } from './model/types/wallet/websocket.js'; import { @@ -55,6 +56,7 @@ import { stripDleq, sumProofs } from './utils.js'; +import { signMintQuote } from './crypto/nut-20.js'; import { OutputData, OutputDataFactory, @@ -655,6 +657,7 @@ class CashuWallet { * Requests a mint quote form the mint. Response returns a Lightning payment request for the requested given amount and unit. * @param amount Amount requesting for mint. * @param description optional description for the mint quote + * @param pubkey optional public key to lock the quote to * @returns the mint will return a mint quote with a Lightning invoice for minting tokens of the specified amount and unit */ async createMintQuote(amount: number, description?: string) { @@ -666,6 +669,36 @@ class CashuWallet { return await this.mint.createMintQuote(mintQuotePayload); } + /** + * Requests a mint quote from the mint that is locked to a public key. + * @param amount Amount requesting for mint. + * @param pubkey public key to lock the quote to + * @param description optional description for the mint quote + * @returns the mint will return a mint quote with a Lightning invoice for minting tokens of the specified amount and unit. + * The quote will be locked to the specified `pubkey`. + */ + async createLockedMintQuote( + amount: number, + pubkey: string, + description?: string + ): Promise { + const { supported } = (await this.getMintInfo()).isSupported(20); + if (!supported) { + throw new Error('Mint does not support NUT-20'); + } + const mintQuotePayload: MintQuotePayload = { + unit: this._unit, + amount: amount, + description: description, + pubkey: pubkey + }; + const res = await this.mint.createMintQuote(mintQuotePayload); + if (!res.pubkey) { + throw new Error('Mint returned unlocked mint quote'); + } + return res as LockedMintQuoteResponse; + } + /** * Gets an existing mint quote from the mint. * @param quote Quote ID @@ -678,17 +711,28 @@ class CashuWallet { /** * Mint proofs for a given mint quote * @param amount amount to request - * @param quote ID of mint quote + * @param {string} quote - ID of mint quote (when quote is a string) + * @param {LockedMintQuote} quote - containing the quote ID and unlocking private key (when quote is a LockedMintQuote) * @param {MintProofOptions} [options] - Optional parameters for configuring the Mint Proof operation * @returns proofs */ + async mintProofs( + amount: number, + quote: MintQuoteResponse, + options: MintProofOptions & { privateKey: string } + ): Promise>; async mintProofs( amount: number, quote: string, options?: MintProofOptions + ): Promise>; + async mintProofs( + amount: number, + quote: string | MintQuoteResponse, + options?: MintProofOptions & { privateKey?: string } ): Promise> { let { outputAmounts } = options || {}; - const { counter, pubkey, p2pk, keysetId, proofsWeHave, outputData } = options || {}; + const { counter, pubkey, p2pk, keysetId, proofsWeHave, outputData, privateKey } = options || {}; const keyset = await this.getKeys(keysetId); if (!outputAmounts && proofsWeHave) { @@ -697,7 +741,6 @@ class CashuWallet { sendAmounts: [] }; } - let newBlindingData: Array = []; if (outputData) { if (isOutputDataFactory(outputData)) { @@ -723,10 +766,24 @@ class CashuWallet { p2pk ); } - const mintPayload: MintPayload = { - outputs: newBlindingData.map((d) => d.blindedMessage), - quote: quote - }; + let mintPayload: MintPayload; + if (typeof quote !== 'string') { + if (!privateKey) { + throw new Error('Can not sign locked quote without private key'); + } + const blindedMessages = newBlindingData.map((d) => d.blindedMessage) + const mintQuoteSignature = signMintQuote(privateKey, quote.quote, blindedMessages); + mintPayload = { + outputs: blindedMessages, + quote: quote.quote, + signature: mintQuoteSignature + }; + } else { + mintPayload = { + outputs: newBlindingData.map((d) => d.blindedMessage), + quote: quote + }; + } const { signatures } = await this.mint.mint(mintPayload); return newBlindingData.map((d, i) => d.toProof(signatures[i], keyset)); } diff --git a/src/crypto/nut-20.ts b/src/crypto/nut-20.ts new file mode 100644 index 00000000..18a3fa37 --- /dev/null +++ b/src/crypto/nut-20.ts @@ -0,0 +1,41 @@ +import { schnorr } from '@noble/curves/secp256k1'; +import { SerializedBlindedMessage } from '../model/types'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { sha256 } from '@noble/hashes/sha256'; + +function constructMessage( + quote: string, + blindedMessages: Array +): Uint8Array { + let message = quote; + for (const blindedMessage of blindedMessages) { + message += blindedMessage.B_; + } + const msgbytes = new TextEncoder().encode(message); + return sha256(msgbytes); +} + +export function signMintQuote( + privkey: string, + quote: string, + blindedMessages: Array +): string { + const message = constructMessage(quote, blindedMessages); + const privkeyBytes = hexToBytes(privkey); + const signature = schnorr.sign(message, privkeyBytes); + return bytesToHex(signature); +} + +export function verifyMintQuoteSignature( + pubkey: string, + quote: string, + blindedMessages: Array, + signature: string +): boolean { + const sigbytes = hexToBytes(signature); + let pubkeyBytes = hexToBytes(pubkey); + if (pubkeyBytes.length !== 33) return false; + pubkeyBytes = pubkeyBytes.slice(1); + const message = constructMessage(quote, blindedMessages); + return schnorr.verify(sigbytes, message, pubkeyBytes); +} diff --git a/src/model/MintInfo.ts b/src/model/MintInfo.ts index 8965a3f1..e76da4b2 100644 --- a/src/model/MintInfo.ts +++ b/src/model/MintInfo.ts @@ -8,7 +8,7 @@ export class MintInfo { } isSupported(num: 4 | 5): { disabled: boolean; params: Array }; - isSupported(num: 7 | 8 | 9 | 10 | 11 | 12 | 14): { supported: boolean }; + isSupported(num: 7 | 8 | 9 | 10 | 11 | 12 | 14 | 20): { supported: boolean }; isSupported(num: 17): { supported: boolean; params?: Array }; isSupported(num: 15): { supported: boolean; params?: Array }; isSupported(num: number) { @@ -23,7 +23,8 @@ export class MintInfo { case 10: case 11: case 12: - case 14: { + case 14: + case 20: { return this.checkGenericNut(num); } case 17: { @@ -37,7 +38,7 @@ export class MintInfo { } } } - private checkGenericNut(num: 7 | 8 | 9 | 10 | 11 | 12 | 14) { + private checkGenericNut(num: 7 | 8 | 9 | 10 | 11 | 12 | 14 | 20) { if (this._mintInfo.nuts[num]?.supported) { return { supported: true }; } diff --git a/src/model/types/index.ts b/src/model/types/index.ts index bbc5003f..d6881f05 100644 --- a/src/model/types/index.ts +++ b/src/model/types/index.ts @@ -9,6 +9,11 @@ export type OutputAmounts = { keepAmounts?: Array; }; +export type LockedMintQuote = { + id: string; + privkey: string; +}; + /** * @param {ReceiveOptions} [options] - Optional configuration for token processing: * - `keysetId`: Override the default keyset ID with a custom one fetched from the `/keysets` endpoint. diff --git a/src/model/types/mint/responses.ts b/src/model/types/mint/responses.ts index 06ee8806..69ac1a14 100644 --- a/src/model/types/mint/responses.ts +++ b/src/model/types/mint/responses.ts @@ -103,6 +103,10 @@ export type GetInfoResponse = { // WebSockets supported: Array; }; + '20'?: { + // Locked Mint Quote + supported: boolean; + }; }; motd?: string; }; @@ -178,8 +182,14 @@ export type MintQuoteResponse = { * Timestamp of when the quote expires */ expiry: number; + /** + * Public key the quote is locked to + */ + pubkey?: string; } & ApiError; +export type LockedMintQuoteResponse = MintQuoteResponse & { pubkey: string }; + /** * Response from the mint after requesting a mint */ diff --git a/src/model/types/wallet/payloads.ts b/src/model/types/wallet/payloads.ts index c848df30..c703b519 100644 --- a/src/model/types/wallet/payloads.ts +++ b/src/model/types/wallet/payloads.ts @@ -63,6 +63,10 @@ export type MintPayload = { * Outputs (blinded messages) to be signed by the mint. */ outputs: Array; + /** + * Public key the quote is locked to + */ + signature?: string; }; /** @@ -81,6 +85,10 @@ export type MintQuotePayload = { * Description for the invoice */ description?: string; + /** + * Public key to lock the quote to + */ + pubkey?: string; }; /** diff --git a/test/integration.test.ts b/test/integration.test.ts index 99a849cb..fcb60072 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -5,7 +5,6 @@ import dns from 'node:dns'; import { test, describe, expect } from 'vitest'; import { vi } from 'vitest'; import { schnorr, secp256k1 } from '@noble/curves/secp256k1'; -import { bytesToHex } from '@noble/curves/abstract/utils'; import { CheckStateEnum, MeltQuoteState, @@ -26,7 +25,7 @@ import { sumProofs } from '../src/utils.js'; import { OutputData, OutputDataFactory } from '../src/model/OutputData.js'; -import { hexToBytes, randomBytes } from '@noble/hashes/utils'; +import { hexToBytes, bytesToHex, randomBytes } from '@noble/hashes/utils'; dns.setDefaultResultOrder('ipv4first'); const externalInvoice = @@ -381,6 +380,19 @@ describe('mint api', () => { }); mint.disconnectWebSocket(); }, 10000); + test('mint with signed quote and payload', async () => { + const mint = new CashuMint(mintUrl); + const wallet = new CashuWallet(mint); + + const privkey = 'd56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac'; + const pubkey = '02' + bytesToHex(schnorr.getPublicKey(hexToBytes(privkey))); + + const quote = await wallet.createLockedMintQuote(63, pubkey); + const proofs = await wallet.mintProofs(63, quote, { privateKey: privkey }); + + expect(proofs).toBeDefined(); + expect(proofs.length).toBeGreaterThan(0); + }); }); describe('dleq', () => { test('mint and check dleq', async () => { diff --git a/test/quoteSignature.test.ts b/test/quoteSignature.test.ts new file mode 100644 index 00000000..12cffefa --- /dev/null +++ b/test/quoteSignature.test.ts @@ -0,0 +1,124 @@ +import { test, describe, expect } from 'vitest'; +import { MintPayload } from '../src/model/types/wallet/payloads'; +import { signMintQuote, verifyMintQuoteSignature } from '../src/crypto/nut-20'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { schnorr } from '@noble/curves/secp256k1'; + +describe('mint quote signatures', () => { + test('valid signature verification', () => { + let mintRequest = { + quote: '9d745270-1405-46de-b5c5-e2762b4f5e00', + outputs: [ + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79' + } + ], + signature: + 'd4b386f21f7aa7172f0994ee6e4dd966539484247ea71c99b81b8e09b1bb2acbc0026a43c221fd773471dc30d6a32b04692e6837ddaccf0830a63128308e4ee0' + } as MintPayload; + const sig = mintRequest.signature!; + const quote = mintRequest.quote; + const pubkey = '03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac'; + const blindedMessages = mintRequest.outputs; + expect(verifyMintQuoteSignature(pubkey, quote, blindedMessages, sig)).toBe(true); + }); + test('invalid signature verification', () => { + let mintRequest = { + quote: '9d745270-1405-46de-b5c5-e2762b4f5e00', + outputs: [ + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79' + } + ], + signature: + 'cb2b8e7ea69362dfe2a07093f2bbc319226db33db2ef686c940b5ec976bcbfc78df0cd35b3e998adf437b09ee2c950bd66dfe9eb64abd706e43ebc7c669c36c3' + } as MintPayload; + const sig = mintRequest.signature!; + const quote = mintRequest.quote; + const pubkey = '03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac'; + const blindedMessages = mintRequest.outputs; + expect(verifyMintQuoteSignature(pubkey, quote, blindedMessages, sig)).toBe(false); + }); + test('signature creation', () => { + let mintRequest = { + quote: '9d745270-1405-46de-b5c5-e2762b4f5e00', + outputs: [ + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53' + }, + { + amount: 1, + id: '00456a94ab4e1c46', + B_: '02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79' + } + ] + } as MintPayload; + const quote = mintRequest.quote; + const privkey = 'd56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac'; + const pubkey = '02' + bytesToHex(schnorr.getPublicKey(hexToBytes(privkey))); + const blindedMessages = mintRequest.outputs; + const signature = signMintQuote(privkey, quote, blindedMessages); + expect(verifyMintQuoteSignature(pubkey, quote, blindedMessages, signature)).toBe(true); + }); +});