diff --git a/README.md b/README.md
index a4d77b6cf..ccdd5df7c 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,7 @@ We provide comprehensive examples categorized by use case:
- **[WalletOps](./docs-src/wallet_ops/wallet_ops.md)**: Translation Builder Usage Recipes.
- **[WalletEvents](./docs-src/wallet_events/wallet_events.md)**: Event Subscriptions
- **[Deterministic Counters](./docs-src/deterministic_counters.md)**: Deterministic counters (persist, inspect, bump).
+- **[Derive Keys](./docs-src/usage/derive_keys.md)**: Derive recoverable P2PK / NUT-20 keys from the wallet seed.
### Note: Builder hooks vs Global events
diff --git a/docs-src/usage/create_p2pk.md b/docs-src/usage/create_p2pk.md
index bee2b9a08..7083c6498 100644
--- a/docs-src/usage/create_p2pk.md
+++ b/docs-src/usage/create_p2pk.md
@@ -18,3 +18,6 @@ const privkey = '5d...'; // private key for pubkey
const receiveProofs = await wallet2.receive(token, {privkey});
// store receiveProofs in your app ..
```
+
+> Tip: derive `pubkey`/`privkey` deterministically from the wallet seed so the lock key is
+> recoverable without a separate backup. See [Derive Keys](./derive_keys.md).
diff --git a/docs-src/usage/derive_keys.md b/docs-src/usage/derive_keys.md
new file mode 100644
index 000000000..81a4aaec2
--- /dev/null
+++ b/docs-src/usage/derive_keys.md
@@ -0,0 +1,74 @@
+# Documents › [Usage Examples](../usage/usage_index.md) › **Derive Keys**
+
+# Derive deterministic P2PK & quote-locking keys
+
+Derive secp256k1 signing keys deterministically from the wallet seed, so the keys you lock
+proofs (NUT-11 P2PK) or mint quotes (NUT-20) to are **recoverable**: no separate key backup,
+just the seed. Keys follow the BIP-32 path `m/129373'/{purpose}'/0'/0'/{counter}`, as defined for
+[NUT-11](https://github.com/cashubtc/nuts/blob/main/11.md) (P2PK) and
+[NUT-20](https://github.com/cashubtc/nuts/blob/main/20.md) (quote locking).
+
+```typescript
+import { deriveKeyPair } from '@cashu/cashu-ts';
+
+// `seed` is the same Uint8Array you passed to `new Wallet(url, { bip39seed: seed })`.
+// `purpose` is 'P2PK' or 'QuoteLock'; `counter` is yours to allocate and persist (see "Counters").
+const { pubkey, privkey } = deriveKeyPair(seed, 'P2PK', counter); // both hex strings
+```
+
+`pubkey` and `privkey` are hex, so they drop straight into the lock/quote/sign APIs with no
+conversion.
+
+## P2PK: lock a send, recover the key to receive it
+
+```typescript
+import { deriveKeyPair, getEncodedToken } from '@cashu/cashu-ts';
+
+const counter = 0; // your next unused P2PK counter
+const { pubkey, privkey } = deriveKeyPair(seed, 'P2PK', counter);
+
+const { send } = await wallet.ops.send(32, proofs).asP2PK({ pubkey }).run();
+const token = getEncodedToken({ mint: mintUrl, proofs: send });
+
+// Later: re-derive the same key from seed + counter to unlock the proofs:
+const { privkey: recovered } = deriveKeyPair(seed, 'P2PK', counter);
+const receiveProofs = await wallet.receive(token, { privkey: recovered });
+```
+
+## NUT-20: lock a mint quote
+
+```typescript
+import { deriveKeyPair } from '@cashu/cashu-ts';
+
+const counter = 0; // your next unused quote-lock counter
+const { pubkey, privkey } = deriveKeyPair(seed, 'QuoteLock', counter);
+
+const quote = await wallet.createLockedMintQuote(64, pubkey);
+// ...pay the quote's BOLT11 invoice...
+const proofs = await wallet.ops.mint(64, quote).privkey(privkey).run();
+```
+
+## Counters
+
+A counter only tells you _how many_ keys exist, never _what each was for_, so this library does
+**not** track them. You own the allocation and the mapping (`quote -> counter`,
+`proof/pubkey -> counter`) in your app storage. Use the next unused integer for each new key, and
+persist it alongside whatever the key locks.
+
+## Restore scans
+
+To find which counter a recovered pubkey belongs to, scan counters and match. For tight loops use
+`createKeyPairDeriver`, which caches the shared parent derivation (one child derivation per counter
+instead of re-walking the full path):
+
+```typescript
+import { createKeyPairDeriver } from '@cashu/cashu-ts';
+
+const derive = createKeyPairDeriver(seed, 'P2PK'); // cached; returns (counter) => { pubkey, privkey }
+for (let counter = 0; counter < gapLimit; counter++) {
+ if (derive(counter).pubkey === targetPubkey) {
+ // matched: counter found
+ break;
+ }
+}
+```
diff --git a/docs-src/usage/usage_index.md b/docs-src/usage/usage_index.md
index 6c12fe238..139a434f3 100644
--- a/docs-src/usage/usage_index.md
+++ b/docs-src/usage/usage_index.md
@@ -27,6 +27,7 @@ If you are building a wallet integration from scratch, read these in order:
| [Mint Token](./mint_token.md) | Create proofs from a paid quote, including two-step mint flows. |
| [Create Token](./create_token.md) | Send standard Cashu tokens to another wallet. |
| [Create P2PK](./create_p2pk.md) | Send tokens locked to a public key. |
+| [Derive Keys](./derive_keys.md) | Derive recoverable P2PK / NUT-20 keys deterministically from the wallet seed. |
| [Get Token](./get_token.md) | Inspect token metadata before wallet creation or decode it after load. |
| [Melt Token](./melt_token.md) | Pay BOLT11 invoices or other payment methods with wallet proofs. |
| [Bolt12](./bolt12.md) | Work with reusable BOLT12 offers for minting and melting. |
diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md
index 979c288a3..2e9cab84f 100644
--- a/etc/cashu-ts.api.md
+++ b/etc/cashu-ts.api.md
@@ -219,6 +219,9 @@ export function batchVerifyUnblindedSignatureBls(items: Array<{
secret: Uint8Array;
}>): boolean;
+// @public
+export type Bip32KeyPurpose = 'P2PK' | 'QuoteLock';
+
// @public
export function blindMessage(secret: Uint8Array, r?: bigint): RawBlindedMessage;
@@ -352,6 +355,12 @@ export function createHTLCHash(preimage?: string): {
// @public
export function createHTLCsecret(hash: string, tags?: string[][]): string;
+// @public
+export function createKeyPairDeriver(seed: Uint8Array, purpose: Bip32KeyPurpose): (counter: number) => {
+ pubkey: string;
+ privkey: string;
+};
+
// @public
export function createNewMintKeys(pow2height: IntRange<0, 65>, seed?: Uint8Array, options?: {
expiry?: number;
@@ -393,6 +402,12 @@ export type CurvePoint = {
// @public
export function decodePaymentRequest(paymentRequest: string): PaymentRequest_2;
+// @public
+export function deriveKeyPair(seed: Uint8Array, purpose: Bip32KeyPurpose, counter: number): {
+ pubkey: string;
+ privkey: string;
+};
+
// @public
export function deriveKeysetId(keys: Keys, options?: DeriveKeysetIdOptions): string;
diff --git a/src/crypto/NUT13.ts b/src/crypto/NUT13.ts
index 43ddd4d0c..e84d57a7a 100644
--- a/src/crypto/NUT13.ts
+++ b/src/crypto/NUT13.ts
@@ -1,4 +1,4 @@
-import { numberToBytesBE } from '@noble/curves/utils.js';
+import { bytesToHex, numberToBytesBE } from '@noble/curves/utils.js';
import { hmac } from '@noble/hashes/hmac.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { HDKey } from '@scure/bip32';
@@ -7,10 +7,28 @@ import { CTSError } from '../model/Errors';
import { Bytes, isBase64String } from '../utils';
import { BLS_FR_ORDER } from './curve_bls';
+import { getPubKeyFromPrivKey } from './curve_secp';
import { getKeysetIdInt, isBlsKeyset } from './curves';
const STANDARD_DERIVATION_PATH = `m/129372'/0'`;
+/**
+ * Purpose of a deterministically-derived key, selecting the index in the BIP-32 path
+ * `m/129373'/{index}'/0'/0'/{counter}`.
+ *
+ * - `P2PK`: NUT-11 P2PK signing key.
+ * - `QuoteLock`: NUT-20 quote locking key.
+ */
+export type Bip32KeyPurpose = 'P2PK' | 'QuoteLock';
+
+/**
+ * Path purpose index per {@link Bip32KeyPurpose}.
+ */
+const PURPOSE_INDEX: Record = {
+ P2PK: 10,
+ QuoteLock: 20,
+};
+
const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
enum DerivationKind {
@@ -48,6 +66,70 @@ export function deriveSecretAndBlindingFactor(
return derive(counter);
}
+/**
+ * Derives the deterministic keypair for one counter under the BIP-32 path
+ * `m/129373'/{purpose}'/0'/0'/{counter}` (the counter child is non-hardened).
+ *
+ * @remarks
+ * Used for NUT-11 P2PK keys and NUT-20 quote locking keys. Both fields are hex: `pubkey` drops into
+ * the lock/quote APIs and `privkey` into `signP2PKProofs`. To scan many counters from the same
+ * seed, prefer {@link createKeyPairDeriver}, which caches the shared parent.
+ *
+ * The counter child is non-hardened so the parent xpub can derive counter pubkeys for watch-only
+ * use. Consequently, never export the parent xpub alongside any counter's private key: with both,
+ * the parent private key (and thus every counter's key) can be recovered.
+ * @param seed - Wallet seed used for deterministic derivation.
+ * @param purpose - Key purpose (`'P2PK'` or `'QuoteLock'`), which selects the path's purpose index.
+ * @param counter - Non-hardened BIP-32 child index.
+ * @returns The derived keypair, both hex-encoded: compressed (02/03) `pubkey` and `privkey`.
+ * @throws {@link CTSError} If the counter is not a non-hardened index (integer below 2^31) or
+ * derivation produces an invalid private key.
+ */
+export function deriveKeyPair(
+ seed: Uint8Array,
+ purpose: Bip32KeyPurpose,
+ counter: number,
+): { pubkey: string; privkey: string } {
+ const derive = createKeyPairDeriver(seed, purpose);
+ return derive(counter);
+}
+
+/**
+ * Creates a deterministic keypair deriver for a seed/purpose pair.
+ *
+ * @remarks
+ * Caches the parent `m/129373'/{purpose}'/0'/0'` derivation once so each per-counter call is a
+ * single non-hardened child derivation. This is ~5x faster than re-traversing the full path per
+ * counter, so it is the path to use for restore loops scanning many counters. Each call returns a
+ * ready-to-use hex keypair; for a single counter use {@link deriveKeyPair}.
+ *
+ * The counter child is non-hardened so the parent xpub can derive counter pubkeys for watch-only
+ * use. Consequently, never export the parent xpub alongside any counter's private key: with both,
+ * the parent private key (and thus every counter's key) can be recovered.
+ * @param seed - Wallet seed used for deterministic derivation.
+ * @param purpose - Key purpose, which selects the path's purpose index.
+ * @returns A function mapping a non-hardened counter to its hex keypair.
+ */
+export function createKeyPairDeriver(
+ seed: Uint8Array,
+ purpose: Bip32KeyPurpose,
+): (counter: number) => { pubkey: string; privkey: string } {
+ const index = PURPOSE_INDEX[purpose];
+ const parentKey = HDKey.fromMasterSeed(seed).derive(`m/129373'/${index}'/0'/0'`);
+ return (counter: number) => {
+ // deriveChild silently hardens indices >= 2^31, which xpub-only derivation cannot follow.
+ if (!Number.isInteger(counter) || counter < 0 || counter >= 0x80000000) {
+ throw new CTSError('Counter must be a non-hardened BIP-32 index (0 <= counter < 2^31)');
+ }
+ const secretKey = parentKey.deriveChild(counter).privateKey;
+ /* c8 ignore next */
+ if (secretKey === null) {
+ throw new CTSError('Could not derive secret key');
+ }
+ return { pubkey: bytesToHex(getPubKeyFromPrivKey(secretKey)), privkey: bytesToHex(secretKey) };
+ };
+}
+
// ------------------------------
// Internal helpers
// ------------------------------
diff --git a/test/crypto/NUT13-p2pk.test.ts b/test/crypto/NUT13-p2pk.test.ts
new file mode 100644
index 000000000..ffd0fcbd5
--- /dev/null
+++ b/test/crypto/NUT13-p2pk.test.ts
@@ -0,0 +1,61 @@
+import { hexToBytes } from '@noble/hashes/utils.js';
+import { describe, expect, test } from 'vitest';
+
+import { type Bip32KeyPurpose, createKeyPairDeriver, deriveKeyPair } from '../../src/crypto';
+
+// BIP39 seed (no passphrase) for the mnemonic in the NUT-11 / NUT-20 test vectors:
+// "half depart obvious quality work element tank gorilla view sugar picture humble"
+const SEED = hexToBytes(
+ 'dd44ee516b0647e80b488e8dcc56d736a148f15276bef588b37057476d4b2b25' +
+ '780d3688a32b37353d6995997842c0fd8b412475c891c16310471fbc86dcbda8',
+);
+
+// Expected compressed (02/03-prefixed) public keys from the PR test vectors.
+const VECTORS: Record = {
+ P2PK: [
+ '021693d45f4fdf610ae641fedb0944fb460fbb8264f21c19d2626c3da755fcbbcb',
+ '0395461ab678058c0ed6aa39f38dda490eaa163e9ad27070b23ec3d06b41e07535',
+ '02a05e4e593a633e9b4405f01c9632c8afde24cb613017a1aee56fd76291ad26d1',
+ '033addea25c3873b93d67d536c61c9d9c993f6efd8b9dfa657951b66b5001e51dd',
+ '03c964bdf42fc82b6c574615746eeca37527a24f1fdfc1b34a732c53843b5744a5',
+ ],
+ QuoteLock: [
+ '03062837166e56114b59a4d1fd3a5a812bf7aadc1dde758428cf943d80acd41539',
+ '02b47d9d41725f5ce6f08c874835cef25376cb1e95f6cb073fef52ca8fd986cf15',
+ '029acbd3a46fd75bc05ba0226d0b4d909b2fb6e96c80544a094a1a3567737e44d3',
+ '0373e4a42fbe0a4e18aadb57cf500b655f2446b4071ee579121d2ed8905bcc49c2',
+ '02b8709bfce17c10f1864f5218844533ae60930d52089669b317d8b5f474eec071',
+ ],
+};
+
+describe('deterministic P2PK / quote-lock key derivation (NUT-11, NUT-20)', () => {
+ test.each(['P2PK', 'QuoteLock'] as const)(
+ '%s matches spec test vectors for counters 0-4',
+ (purpose) => {
+ const expected = VECTORS[purpose];
+ const derive = createKeyPairDeriver(SEED, purpose);
+ for (let counter = 0; counter < expected.length; counter++) {
+ const pair = deriveKeyPair(SEED, purpose, counter);
+ expect(pair.privkey).toHaveLength(64); // 32 bytes, hex
+ expect(pair.pubkey).toBe(expected[counter]);
+ // cached factory must agree with the one-shot keypair
+ expect(derive(counter)).toEqual(pair);
+ }
+ },
+ );
+
+ test('P2PK and QuoteLock purposes diverge for the same seed/counter', () => {
+ expect(deriveKeyPair(SEED, 'P2PK', 0).pubkey).not.toBe(
+ deriveKeyPair(SEED, 'QuoteLock', 0).pubkey,
+ );
+ });
+
+ // deriveChild would silently harden indices >= 2^31, breaking xpub watch-only derivation
+ test.each([0x80000000, -1, 1.5, Number.NaN])('rejects invalid counter %s', (counter) => {
+ expect(() => deriveKeyPair(SEED, 'P2PK', counter)).toThrow('non-hardened');
+ });
+
+ test('accepts the maximum non-hardened counter', () => {
+ expect(deriveKeyPair(SEED, 'P2PK', 0x7fffffff).pubkey).toMatch(/^0[23][0-9a-f]{64}$/);
+ });
+});