Skip to content

feat(crypto): deterministic P2PK & NUT-20 quote-lock key derivation - #697

Merged
robwoodgate merged 7 commits into
mainfrom
bip32-pubkey-gen
Jul 8, 2026
Merged

feat(crypto): deterministic P2PK & NUT-20 quote-lock key derivation#697
robwoodgate merged 7 commits into
mainfrom
bip32-pubkey-gen

Conversation

@robwoodgate

@robwoodgate robwoodgate commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

NUTS:

Summary

Adds BIP-32 derivation of deterministic signing keys under m/129373'/{purpose}'/0'/0'/{counter} (non-hardened counter), as defined for:

  • NUT-11: P2PK keys (purpose index 10)
  • NUT-20: quote locking keys (purpose index 20)

API

export type Bip32KeyPurpose = 'P2PK' | 'QuoteLock';

deriveKeyPair(seed, purpose, counter): { pubkey: string; privkey: string }            // one-shot
createKeyPairDeriver(seed, purpose): (counter) => { pubkey: string; privkey: string } // cached, for loops

purpose is passed by name ('P2PK' / 'QuoteLock'). Both return hex pubkey/privkey that drop straight into the existing lock/quote and signP2PKProofs APIs with zero conversion:

const { pubkey, privkey } = deriveKeyPair(seed, 'P2PK', counter);
await wallet.send(64, proofs, undefined, { send: { type: 'p2pk', options: { pubkey } } });
await wallet.send(64, lockedProofs, { privkey });

const { pubkey: qp, privkey: qk } = deriveKeyPair(seed, 'QuoteLock', counter);
const quote = await wallet.createLockedMintQuote(amount, qp);
await wallet.mintProofs('bolt11', amount, quote, { privkey: qk });

createKeyPairDeriver caches the shared parent derivation, so restore scans cost a single non-hardened child derivation per counter (~5x faster than re-walking the full path) while still returning the ready-to-use hex keypair:

const derive = createKeyPairDeriver(seed, 'P2PK');
for (let counter = 0; counter < gapLimit; counter++) {
  if (derive(counter).pubkey === targetPubkey) break; // matched
}

Scope

Crypto primitive only. Counter allocation and the quote/proof-to-counter mapping stay with the consumer; they need that mapping regardless, so a wallet-owned counter would add coupling without removing consumer state. No NUT-20 module changes needed: createLockedMintQuote/locked mint already accept the pubkey/privkey directly.

Testing

Verified against the NUT-11 / NUT-20 test vectors for both purposes, counters 0 to 4. npm run prtasks green (lint, format, api-extractor, full suite).

@github-project-automation github-project-automation Bot moved this to Backlog in cashu-ts Jun 17, 2026
@robwoodgate
robwoodgate force-pushed the bip32-pubkey-gen branch 2 times, most recently from ec4eb57 to 3ce032c Compare June 17, 2026 12:26
@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.21%. Comparing base (f568ed3) to head (3d199cf).
⚠️ Report is 40 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #697      +/-   ##
==========================================
+ Coverage   92.40%   95.21%   +2.80%     
==========================================
  Files          54       54              
  Lines        4990     5078      +88     
  Branches     1224     1254      +30     
==========================================
+ Hits         4611     4835     +224     
+ Misses        160      103      -57     
+ Partials      219      140      -79     
Flag Coverage Δ
integration 38.28% <10.00%> (-0.40%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Add BIP-32 derivation of signing keys under m/129373'/{purpose}'/0'/0'/{counter}
for NUT-11 (P2PK, index 10) and NUT-20 (quote locking, index 20).

- deriveKeyPair(seed, purpose, counter) -> { pubkey, privkey } (both hex),
  dropping straight into the existing lock/quote and signP2PKProofs APIs
- createSecretKeyDeriver(seed, purpose) caches the shared parent for restore
  scans (single non-hardened child derivation per counter)
- Bip32KeyPurposes is a single-source name->index map; purpose passed by name

Counter allocation and quote/proof<->counter mapping stay with the consumer.
Verified against the NUT-11 / NUT-20 test vectors for both purposes, counters 0-4.
New "Derive Keys" usage page covering deriveKeyPair / createSecretKeyDeriver
for recoverable P2PK (NUT-11) and quote-locking (NUT-20) keys, plus index and
README entries and a cross-link from the Create P2PK recipe.
@robwoodgate robwoodgate added the nut hold This PR is held pending the merge of a new or updated NUT label Jun 17, 2026
Rename createSecretKeyDeriver to createKeyPairDeriver and return the hex
{ pubkey, privkey } directly. The cached loop primitive now yields the same
ready-to-use format as deriveKeyPair, so restore scans drop the manual
getPubKeyFromPrivKey + bytesToHex wiring and are no longer nudged toward the
slower per-call deriveKeyPair.
@robwoodgate robwoodgate added this to the v5.0 milestone Jun 20, 2026
The non-hardened counter child enables watch-only pubkey derivation from
the parent xpub, but exporting that xpub alongside any counter's private
key allows recovery of the parent key and all sibling keys.
@robwoodgate robwoodgate added the experimental release PR is in current @experimental release. Install: npm i @cashu/cashu-ts@experimental label Jul 1, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

🧬 Mutation testing (incremental)

✅ Mutation score: 89.4% (break threshold: 80%) — repo-wide 5221 killed / 535 survived / 84 no-coverage.

Surviving mutants in files this PR changed (20):

  • crypto/NUT13.ts:126 BlockStatement: {}
  • crypto/NUT13.ts:127 StringLiteral: ""
  • crypto/NUT13.ts:32 StringLiteral: ""
  • crypto/NUT13.ts:185 ConditionalExpression: false
  • crypto/NUT13.ts:185 LogicalOperator: secret === null && blindingFactor === null
  • crypto/NUT13.ts:185 ConditionalExpression: false
  • crypto/NUT13.ts:185 ConditionalExpression: false
  • crypto/NUT13.ts:185 BlockStatement: {}
  • crypto/NUT13.ts:186 StringLiteral: ""
  • crypto/NUT13.ts:213 EqualityOperator: attempt <= 1 << 16
  • crypto/NUT13.ts:217 ConditionalExpression: false
  • crypto/NUT13.ts:217 EqualityOperator: x > BLS_FR_ORDER
  • crypto/NUT13.ts:221 StringLiteral: ""
  • crypto/NUT13.ts:227 ConditionalExpression: false
  • crypto/NUT13.ts:227 EqualityOperator: x > SECP256K1_N
  • crypto/NUT13.ts:227 ArithmeticOperator: x + SECP256K1_N
  • crypto/NUT13.ts:229 ConditionalExpression: false
  • crypto/NUT13.ts:229 BlockStatement: {}
  • crypto/NUT13.ts:230 StringLiteral: ""
  • crypto/NUT13.ts:126 ConditionalExpression: false

Full report (all files): download the mutation-report-pr artifact from this run. Codecov shows lines this PR left uncovered; this shows changed lines that run but aren’t asserted.

@robwoodgate robwoodgate removed nut hold This PR is held pending the merge of a new or updated NUT experimental release PR is in current @experimental release. Install: npm i @cashu/cashu-ts@experimental labels Jul 8, 2026
@robwoodgate
robwoodgate merged commit 042e56c into main Jul 8, 2026
19 checks passed
@robwoodgate
robwoodgate deleted the bip32-pubkey-gen branch July 8, 2026 11:42
@github-project-automation github-project-automation Bot moved this from Backlog to Done in cashu-ts Jul 8, 2026
robwoodgate added a commit that referenced this pull request Jul 9, 2026
🤖 I have created a release *beep* *boop*
---


##
[5.0.0-rc.3](v5.0.0-rc.2...v5.0.0-rc.3)
(2026-07-08)


### ⚠ BREAKING CHANGES

* **crypto:** align NUT-29 batch quote signatures with amended spec
([#675](#675))
* **nut04/05:** quote accounting and custom payment method base structs
([#698](#698))

### Features

* **crypto:** deterministic P2PK & NUT-20 quote-lock key derivation
([#697](#697))
([042e56c](042e56c))
* **nut04/05:** quote accounting and custom payment method base structs
([#698](#698))
([0e373f9](0e373f9))
* **nut29:** add batch quote checks
([#768](#768))
([854d91f](854d91f))


### Bug Fixes

* **crypto:** align NUT-29 batch quote signatures with amended spec
([#675](#675))
([f3a3841](f3a3841))
* **p2bk:** align HTLC key slots with the [data, ...pubkeys, ...refund]
order (NUT-28) ([#763](#763))
([bb0b766](bb0b766))
* **p2pk:** cap NUT-28 locking slots at 11 (P2PK 11 keys, HTLC 10)
([#753](#753))
([80a55b9](80a55b9))
* **scripts:** probe docker tags with and without v prefix
([#726](#726))
([8667aca](8667aca))
* **utils:** throw CTSError for malformed token templates
([#742](#742))
([c60cd35](c60cd35))


### Miscellaneous Chores

* **deps:** bump actions/setup-node from 4 to 6 in
/.github/actions/integration-against-mint in the github-actions group
([#729](#729))
([b94af5f](b94af5f))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant