Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions test/crypto/NUT28.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ import { schnorr, secp256k1 } from '@noble/curves/secp256k1.js';
import { hexToBytes, bytesToHex } from '@noble/hashes/utils.js';
import { describe, expect, test } from 'vitest';

import { Amount } from '../../src';
import {
pointFromHex,
deriveP2BKSecretKey,
deriveP2BKBlindedPubkeys,
deriveP2BKSecretKeys,
maybeDeriveP2BKPrivateKeys,
signP2PKProof,
verifyHTLCSpendingConditions,
verifyP2PKSpendingConditions,
} from '../../src/crypto';
import { type Proof } from '../../src/model/types';
import { hexToNumber, numberToHexPadded64 } from '../../src/utils';

describe('blinded pubkeys & scalar arithmetic', () => {
Expand Down Expand Up @@ -320,3 +326,74 @@ describe('NUT28 uncovered branches and guards', () => {
);
});
});

describe('NUT-28 P2BK example proof (nuts tests/28-tests.md)', () => {
// Verbatim from the NUT-28 test vectors: Bob's key P is blinded in data (slot 0)
const privKeyBob = 'ad37e8abd800be3e8272b14045873f4353327eedeb702b72ddcc5c5adff5129c';
const slot0Key = '47051623754422cb04bc24c0cfe2c1ddc8db1fcc18f0aa4b477df4aca2adc20e';
const proof: Proof = {
amount: Amount.from(64),
C: '0381855ddcc434a9a90b3564f29ef78e7271f8544d0056763b418b00e88525c0ff',
id: '009a1f293253e41e',
secret:
'["P2PK",{"nonce":"d4a17a88f5d0c09001f7b453c42c1f9d5a87363b1f6637a5a83fc31a6a3b7266","data":"03b7c03eb05a0a539cfc438e81bcf38b65b7bb8685e8790f9b853bfe3d77ad5315","tags":[]}]',
dleq: {
s: '6178978456c42eee8eefb50830fc3146be27b05619f04e3490dc596005f0cc78',
e: '23f2190b18bfd043d3a526103e15f4a938d646a6bf93b017e2bb7c85e1540b32',
r: 'd26a55aa39ca50957fdaf54036b01053b0de42048b96a6fb2a167e03f00d0a0f',
},
p2pk_e: '02a8cda4cf448bfce9a9e46e588c06ea1780fcb94e3bbdf3277f42995d403a8b0c',
};

test('derives the data spend key at slot 0', () => {
expect(maybeDeriveP2BKPrivateKeys(privKeyBob, proof)).toStrictEqual([slot0Key]);
});

test('spends with the slot 0 key', () => {
const signed = signP2PKProof(proof, slot0Key);
const result = verifyP2PKSpendingConditions(signed);
expect(result.success).toBe(true);
expect(result.path).toBe('MAIN');
});
});

describe('NUT-28 HTLC example proof (nuts tests/28-tests.md)', () => {
// Verbatim from the NUT-28 test vectors: the hashlock holds slot 0 (unblinded),
// Bob's key P is blinded at slot 1 (pubkeys) and again at slot 2 (refund).
const privKeyBob = 'ad37e8abd800be3e8272b14045873f4353327eedeb702b72ddcc5c5adff5129c';
const preimage = '0000000000000000000000000000000000000000000000000000000000000001'; // NUT-14 pair
const slot1Key = '9d1ffe00e1da5af5c882b1ea5ec8c18893e09349803c3c9e552823490af22458';
const slot2Key = '2770cf9f49f1f26eaef29d56a85483e8aabb2f3f1a6bec28ffa065a756bbfdb1';
const dleq = {
s: 'bd6ed079b954151898cadac38c3b8d3371c20d67e8c5f06af3cee4152ac317b4',
e: '9ec5b6f2095a8dc7d052a00e0bb050ac95e633e702575630cfd43cb58592d2a1',
r: 'e8349cf88e5a9f025f0072bf8a2db48d394bad9f7be3d8023f9ee90de1c1924d',
};
const proof: Proof = {
amount: Amount.from(64),
C: '0270aba098c920adafa1ce75acefb06d8cc541ef80270f70cc7b66375b789ed9be',
id: '009a1f293253e41e',
secret:
'["HTLC",{"nonce":"8b1f18aa85a2787903cfdc776fde0b8555bdb126eea02b05cd84de06a4f4b551","data":"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5","tags":[["pubkeys","0352fb6d93360b7c2538eedf3c861f32ea5883fceec9f3e573d9d84377420da838"],["locktime","1689418329"],["refund","03667361ca925065dcafea0a705ba49e75bdd7975751fcc933e05953463c79fff1"]]}]',
dleq,
p2pk_e: '02a8cda4cf448bfce9a9e46e588c06ea1780fcb94e3bbdf3277f42995d403a8b0c',
};

test('derives the pubkeys spend key at slot 1 and the refund key at slot 2', () => {
expect(maybeDeriveP2BKPrivateKeys(privKeyBob, proof)).toStrictEqual([slot1Key, slot2Key]);
});

test('spends via the receiver pathway with the preimage and the slot 1 key', () => {
const signed = signP2PKProof({ ...proof, witness: { preimage } }, slot1Key);
const result = verifyHTLCSpendingConditions(signed);
expect(result.success).toBe(true);
expect(result.path).toBe('MAIN');
});

test('spends via the refund pathway with the slot 2 key after locktime', () => {
const signed = signP2PKProof(proof, slot2Key);
const result = verifyHTLCSpendingConditions(signed);
expect(result.success).toBe(true);
expect(result.path).toBe('REFUND');
});
});
84 changes: 55 additions & 29 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
// docker rm -f -v nutshell

import { secp256k1, schnorr } from '@noble/curves/secp256k1.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { hexToBytes, bytesToHex, randomBytes } from '@noble/hashes/utils.js';
import { vi, test, describe, expect } from 'vitest';

Expand All @@ -39,6 +38,8 @@ import {
type OutputType,
P2PKBuilder,
type MintQuoteBaseResponse,
createHTLCHash,
deriveP2BKSecretKeys,
getEncodedToken,
hexToNumber,
isBlsKeyset,
Expand Down Expand Up @@ -85,34 +86,25 @@ function expectNUT10SecretDataToEqual(p: Proof[], s: string) {
});
}

function expectBlindedSecretDataToEqualECDH(
proofs: Proof[],
bobPrivHex: Uint8Array, // receiver’s private key
bobPubHex: string, // receiver’s SEC1-compressed pubkey P
) {
// Asserts Bob's key unblinds from each proof's lock key at the NUT-28 slot for its kind:
// P2PK holds P′ in data (slot 0); HTLC holds the hashlock there, so its keys start at slot 1.
// The derivation primitives are pinned to the spec vectors in test/crypto/NUT28.test.ts.
function expectP2BKLockedToBob(proofs: Proof[], bobPriv: Uint8Array) {
for (const p of proofs) {
expect(p.p2pk_e).toBeDefined();

const E = secp256k1.Point.fromHex(p.p2pk_e as string);
const parsed = JSON.parse(p.secret) as ['P2PK', { data: string; tags?: string[][] }];
const blindedData = parsed[1].data; // this is P′ for slot 0

// Z = p · E
const pBig = secp256k1.Point.Fn.fromBytes(bobPrivHex);
const Z = E.multiply(pBig);
const Zx = Z.toBytes(false).slice(1, 33); // 32-byte X

// r = SHA-256(DST || Zx || kid || i=0) mod n, retry once if zero
const DST = new TextEncoder().encode('Cashu_P2BK_v1');
let r = secp256k1.Point.Fn.fromBytes(sha256(new Uint8Array([...DST, ...Zx, 0x00])));
if (r === 0n) {
r = secp256k1.Point.Fn.fromBytes(sha256(new Uint8Array([...DST, ...Zx, 0x00, 0xff])));
if (r === 0n) throw new Error('P2BK: tweak derivation failed in test');
}

const P = secp256k1.Point.fromHex(bobPubHex);
const Pprime = P.add(secp256k1.Point.BASE.multiply(r)).toHex(true);
expect(blindedData).toBe(Pprime);
const parsed = JSON.parse(p.secret) as [string, { data: string; tags?: string[][] }];
const isHTLC = parsed[0] === 'HTLC';
const lockKey = isHTLC ? parsed[1].tags?.find((t) => t[0] === 'pubkeys')?.[1] : parsed[1].data;
expect(lockKey).toBeDefined();
// Returns a spend key onl
// y if lockKey unblinds to Bob's pubkey at the expected slot
const derived = deriveP2BKSecretKeys(
p.p2pk_e as string,
bytesToHex(bobPriv),
lockKey as string,
!isHTLC,
);
expect(derived).toHaveLength(1);
}
}

Expand Down Expand Up @@ -446,7 +438,7 @@ describe('mint api', () => {
const p2pkOpts = new P2PKBuilder().addLockPubkey(pubKeyBob).blindKeys().toOptions();
const { send } = await wallet.ops.send(64, mintedProofs).asP2PK(p2pkOpts).run();
// console.log('P2BK SEND', send);
expectBlindedSecretDataToEqualECDH(send, privKeyBob, pubKeyBob);
expectP2BKLockedToBob(send, privKeyBob);
const encoded = getEncodedToken({ mint: mintUrl, proofs: send });
// console.log('P2BK token', encoded);

Expand Down Expand Up @@ -478,7 +470,7 @@ describe('mint api', () => {
const p2pkOpts = new P2PKBuilder().addLockPubkey(pubKeyBob).blindKeys().toOptions();
const { send } = await wallet.ops.send(64, mintedProofs).asP2PK(p2pkOpts).run();
// console.log('P2BK SEND', send);
expectBlindedSecretDataToEqualECDH(send, privKeyBob, pubKeyBob);
expectP2BKLockedToBob(send, privKeyBob);
const encoded = getEncodedToken({ mint: mintUrl, proofs: send });
// console.log('P2BK token', encoded);

Expand All @@ -488,6 +480,40 @@ describe('mint api', () => {

expect(sumProofs(proofs).equals(63)).toBeTruthy();
});

test('send and receive p2bk HTLC', async () => {
const wallet = new Wallet(mintUrl, { unit });
await wallet.loadMint();

const privKeyBob = secp256k1.utils.randomSecretKey();
const pubKeyBob = bytesToHex(secp256k1.getPublicKey(privKeyBob));
const { hash, preimage } = createHTLCHash();

// Mint some proofs
const request = await wallet.createMintQuoteBolt11(128);
await untilMintQuotePaid(wallet, request);
const mintedProofs = await wallet.mintProofsBolt11(128, request.quote);

// Send them HTLC locked to Bob with blinded keys
const p2pkOpts = new P2PKBuilder()
.addHashlock(hash)
.addLockPubkey(pubKeyBob)
.blindKeys()
.toOptions();
const { send } = await wallet.ops.send(64, mintedProofs).asP2PK(p2pkOpts).run();

// The hashlock stays unblinded in the data slot; Bob's key blinds at slot 1 (NUT-28)
expectNUT10SecretDataToEqual(send, hash);
expectP2BKLockedToBob(send, privKeyBob);

// Try and receive them with Bob's secret key and the preimage (should succeed)
const encoded = getEncodedToken({
mint: mintUrl,
proofs: send.map((p) => ({ ...p, witness: { preimage } })),
});
const proofs = await wallet.receive(encoded, { privkey: bytesToHex(privKeyBob) });
expect(sumProofs(proofs).equals(63)).toBeTruthy();
});
test('mint and melt p2pk', async () => {
const invoice =
'lnbc20u1p5tnrdtsp5xaus66jztyj4f4m9wuza7ay9994d5dals6dluvw80dduhhulgxvspp5gsdp48uz9x20etle8j7muweujzxd2w4ay2v6cwzwjy7pff44r4gqhp5jujtt4hgd57c5hskstzkjkxqtfmctfvpfc3wmt3h42a9f2p9sqcsxq9z0rgqcqpnrzjqvxr759n8jl5226n47zw6325pyffxqlpyrjh9ztswvnglhrmtcsfzrw8mqqqf2cqqqqqqqlgqqqqzhsqjq9qxpqysgq2rtnpkqzmwmuf6cw653s63552qf0hgst6xzdywkgekhz836ayrz572cm72r7ejj7w0ktgldlwfu33fpr9dxywx5wqy4tte7smpa9q4gqaaydvv';
Expand Down
41 changes: 41 additions & 0 deletions test/utils/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,47 @@ describe('test decode token', () => {
const result = utils.getDecodedToken(token, ['009a1f293253e41e']);
expect(result).toStrictEqual(v3Token);
});
test('testing NUT-28 example V4 token (nuts tests/28-tests.md)', () => {
// Pins the V4 wire format of the P2BK fields: `pe` (33-byte bstr) and `d` {e,s,r}
const token =
'cashuBo2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdGF0gaJhaUgAmh8pMlPkHmFwgqVhYRhAYXN4q1siUDJQSyIseyJub25jZSI6ImQ0YTE3YTg4ZjVkMGMwOTAwMWY3YjQ1M2M0MmMxZjlkNWE4NzM2M2IxZjY2MzdhNWE4M2ZjMzFhNmEzYjcyNjYiLCJkYXRhIjoiMDNiN2MwM2ViMDVhMGE1MzljZmM0MzhlODFiY2YzOGI2NWI3YmI4Njg1ZTg3OTBmOWI4NTNiZmUzZDc3YWQ1MzE1IiwidGFncyI6W119XWFjWCEDgYVd3MQ0qakLNWTynveOcnH4VE0AVnY7QYsA6IUlwP9hZKNhZVggI_IZCxi_0EPTpSYQPhX0qTjWRqa_k7AX4rt8heFUCzJhc1ggYXiXhFbELu6O77UIMPwxRr4nsFYZ8E40kNxZYAXwzHhhclgg0mpVqjnKUJV_2vVANrAQU7DeQgSLlqb7KhZ-A_ANCg9icGVYIQKozaTPRIv86ankbliMBuoXgPy5Tju98yd_QpldQDqLDKVhYRhAYXN5AWNbIkhUTEMiLHsibm9uY2UiOiI4YjFmMThhYTg1YTI3ODc5MDNjZmRjNzc2ZmRlMGI4NTU1YmRiMTI2ZWVhMDJiMDVjZDg0ZGUwNmE0ZjRiNTUxIiwiZGF0YSI6ImVjNDkxNmRkMjhmYzRjMTBkNzhlMjg3Y2E1ZDljYzUxZWUxYWU3M2NiZmRlMDhjNmIzNzMyNGNiZmFhYzhiYzUiLCJ0YWdzIjpbWyJwdWJrZXlzIiwiMDM1MmZiNmQ5MzM2MGI3YzI1MzhlZWRmM2M4NjFmMzJlYTU4ODNmY2VlYzlmM2U1NzNkOWQ4NDM3NzQyMGRhODM4Il0sWyJsb2NrdGltZSIsIjE2ODk0MTgzMjkiXSxbInJlZnVuZCIsIjAzNjY3MzYxY2E5MjUwNjVkY2FmZWEwYTcwNWJhNDllNzViZGQ3OTc1NzUxZmNjOTMzZTA1OTUzNDYzYzc5ZmZmMSJdXX1dYWNYIQJwq6CYySCtr6HOdazvsG2MxUHvgCcPcMx7ZjdbeJ7ZvmFko2FlWCCexbbyCVqNx9BSoA4LsFCsleYz5wJXVjDP1Dy1hZLSoWFzWCC9btB5uVQVGJjK2sOMO40zccINZ-jF8GrzzuQVKsMXtGFyWCDoNJz4jlqfAl8Acr-KLbSNOUutn3vj2AI_nukN4cGSTWJwZVghAqjNpM9Ei_zpqeRuWIwG6heA_LlOO73zJ39CmV1AOosM';
const expected = {
unit: 'sat',
mint: 'http://localhost:3338',
proofs: [
{
secret:
'["P2PK",{"nonce":"d4a17a88f5d0c09001f7b453c42c1f9d5a87363b1f6637a5a83fc31a6a3b7266","data":"03b7c03eb05a0a539cfc438e81bcf38b65b7bb8685e8790f9b853bfe3d77ad5315","tags":[]}]',
C: '0381855ddcc434a9a90b3564f29ef78e7271f8544d0056763b418b00e88525c0ff',
id: '009a1f293253e41e',
amount: Amount.from(64),
dleq: {
r: 'd26a55aa39ca50957fdaf54036b01053b0de42048b96a6fb2a167e03f00d0a0f',
s: '6178978456c42eee8eefb50830fc3146be27b05619f04e3490dc596005f0cc78',
e: '23f2190b18bfd043d3a526103e15f4a938d646a6bf93b017e2bb7c85e1540b32',
},
p2pk_e: '02a8cda4cf448bfce9a9e46e588c06ea1780fcb94e3bbdf3277f42995d403a8b0c',
},
{
secret:
'["HTLC",{"nonce":"8b1f18aa85a2787903cfdc776fde0b8555bdb126eea02b05cd84de06a4f4b551","data":"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5","tags":[["pubkeys","0352fb6d93360b7c2538eedf3c861f32ea5883fceec9f3e573d9d84377420da838"],["locktime","1689418329"],["refund","03667361ca925065dcafea0a705ba49e75bdd7975751fcc933e05953463c79fff1"]]}]',
C: '0270aba098c920adafa1ce75acefb06d8cc541ef80270f70cc7b66375b789ed9be',
id: '009a1f293253e41e',
amount: Amount.from(64),
dleq: {
r: 'e8349cf88e5a9f025f0072bf8a2db48d394bad9f7be3d8023f9ee90de1c1924d',
s: 'bd6ed079b954151898cadac38c3b8d3371c20d67e8c5f06af3cee4152ac317b4',
e: '9ec5b6f2095a8dc7d052a00e0bb050ac95e633e702575630cfd43cb58592d2a1',
},
p2pk_e: '02a8cda4cf448bfce9a9e46e588c06ea1780fcb94e3bbdf3277f42995d403a8b0c',
},
],
};
const result = utils.getDecodedToken(token, ['009a1f293253e41e']);
expect(result).toStrictEqual(expected);
// and the P2BK fields must survive re-encoding byte-for-byte
expect(utils.getEncodedToken(result)).toBe(token);
});
});

describe('test getTokenMetadata', () => {
Expand Down
Loading