From f7f0ab9f2bd732ace0e0f549106840bcafd7d8a8 Mon Sep 17 00:00:00 2001 From: Rob Woodgate Date: Tue, 14 Jul 2026 17:25:19 +0100 Subject: [PATCH] test(p2bk): pin the NUT-28 example proofs and V4 token vector Pins the P2PK and HTLC example proofs from the NUT-28 test vectors through the wallet derive/sign/verify path, and the example V4 token through decode and byte-stable re-encode (pe and dleq wire format). Adds an HTLC P2BK integration test (hashlock unblinded at slot 0, lock key at slot 1, receive with preimage). The ITest slot check now uses deriveP2BKSecretKeys, which has no legacy slot 0 fallback, in place of hand-rolled ECDH; the derivation itself is anchored by the fixed vectors in the unit tests. --- test/crypto/NUT28.test.ts | 77 +++++++++++++++++++++++++++++++++++ test/integration.test.ts | 84 +++++++++++++++++++++++++-------------- test/utils/core.test.ts | 41 +++++++++++++++++++ 3 files changed, 173 insertions(+), 29 deletions(-) diff --git a/test/crypto/NUT28.test.ts b/test/crypto/NUT28.test.ts index c7334af31..1c9b522d5 100644 --- a/test/crypto/NUT28.test.ts +++ b/test/crypto/NUT28.test.ts @@ -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', () => { @@ -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'); + }); +}); diff --git a/test/integration.test.ts b/test/integration.test.ts index 164ff9b94..3bec3af8b 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -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'; @@ -39,6 +38,8 @@ import { type OutputType, P2PKBuilder, type MintQuoteBaseResponse, + createHTLCHash, + deriveP2BKSecretKeys, getEncodedToken, hexToNumber, isBlsKeyset, @@ -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); } } @@ -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); @@ -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); @@ -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'; diff --git a/test/utils/core.test.ts b/test/utils/core.test.ts index 9325b05d0..09cfa5bc1 100644 --- a/test/utils/core.test.ts +++ b/test/utils/core.test.ts @@ -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', () => {