Skip to content

Commit

Permalink
Merge pull request #229 from lollerfirst/sign-mint-quotes
Browse files Browse the repository at this point in the history
NUT-20 Signed Mint Payloads
  • Loading branch information
gandlafbtc authored Feb 22, 2025
2 parents 7944813 + 38a36a2 commit eff3e15
Show file tree
Hide file tree
Showing 9 changed files with 272 additions and 15 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/nutshell-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
73 changes: 65 additions & 8 deletions src/CashuWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -55,6 +56,7 @@ import {
stripDleq,
sumProofs
} from './utils.js';
import { signMintQuote } from './crypto/nut-20.js';
import {
OutputData,
OutputDataFactory,
Expand Down Expand Up @@ -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) {
Expand All @@ -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<LockedMintQuoteResponse> {
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
Expand All @@ -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<Array<Proof>>;
async mintProofs(
amount: number,
quote: string,
options?: MintProofOptions
): Promise<Array<Proof>>;
async mintProofs(
amount: number,
quote: string | MintQuoteResponse,
options?: MintProofOptions & { privateKey?: string }
): Promise<Array<Proof>> {
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) {
Expand All @@ -697,7 +741,6 @@ class CashuWallet {
sendAmounts: []
};
}

let newBlindingData: Array<OutputData> = [];
if (outputData) {
if (isOutputDataFactory(outputData)) {
Expand All @@ -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));
}
Expand Down
41 changes: 41 additions & 0 deletions src/crypto/nut-20.ts
Original file line number Diff line number Diff line change
@@ -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<SerializedBlindedMessage>
): 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<SerializedBlindedMessage>
): 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<SerializedBlindedMessage>,
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);
}
7 changes: 4 additions & 3 deletions src/model/MintInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export class MintInfo {
}

isSupported(num: 4 | 5): { disabled: boolean; params: Array<SwapMethod> };
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<WebSocketSupport> };
isSupported(num: 15): { supported: boolean; params?: Array<MPPMethod> };
isSupported(num: number) {
Expand All @@ -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: {
Expand All @@ -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 };
}
Expand Down
5 changes: 5 additions & 0 deletions src/model/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export type OutputAmounts = {
keepAmounts?: Array<number>;
};

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.
Expand Down
10 changes: 10 additions & 0 deletions src/model/types/mint/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ export type GetInfoResponse = {
// WebSockets
supported: Array<WebSocketSupport>;
};
'20'?: {
// Locked Mint Quote
supported: boolean;
};
};
motd?: string;
};
Expand Down Expand Up @@ -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
*/
Expand Down
8 changes: 8 additions & 0 deletions src/model/types/wallet/payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export type MintPayload = {
* Outputs (blinded messages) to be signed by the mint.
*/
outputs: Array<SerializedBlindedMessage>;
/**
* Public key the quote is locked to
*/
signature?: string;
};

/**
Expand All @@ -81,6 +85,10 @@ export type MintQuotePayload = {
* Description for the invoice
*/
description?: string;
/**
* Public key to lock the quote to
*/
pubkey?: string;
};

/**
Expand Down
16 changes: 14 additions & 2 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 =
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading

0 comments on commit eff3e15

Please sign in to comment.