Skip to content
Closed
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
78 changes: 78 additions & 0 deletions packages/adapter-tests/src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ export type MigrationTestOptions<TRepositories extends Repositories = Repositori
*/
completedToFinalizedMigration: string;

/**
* The migration ID/version that adds derivationPath to keypairs.
* For SQL adapters: '016_keypair_derivation_path'
* For IndexedDB: '15'
*/
keypairDerivationPathMigration?: string;

/**
* Logger for debugging test failures.
*/
Expand Down Expand Up @@ -122,6 +129,7 @@ export function runMigrationTests<TRepositories extends Repositories = Repositor
createRepositories,
createRepositoriesAtMigration,
completedToFinalizedMigration,
keypairDerivationPathMigration,
logger,
} = options;

Expand Down Expand Up @@ -562,5 +570,75 @@ export function runMigrationTests<TRepositories extends Repositories = Repositor
}
});
});

if (keypairDerivationPathMigration) {
describe('keypair derivationPath migration', () => {
it('should backfill derivationPath from derivationIndex', async () => {
const { dispose, runRemainingMigrations, rawInsert, rawQuery } =
await createRepositoriesAtMigration(keypairDerivationPathMigration);

try {
await rawInsert('coco_cashu_keypairs', {
publicKey: 'pk-derivation-0',
secretKey: '00',
createdAt: 1000,
derivationIndex: 0,
});

await rawInsert('coco_cashu_keypairs', {
publicKey: 'pk-derivation-7',
secretKey: '01',
createdAt: 1001,
derivationIndex: 7,
});

await runRemainingMigrations();

const keypairs = await rawQuery<{
publicKey: string;
derivationIndex: number | null;
derivationPath: string | null;
}>('coco_cashu_keypairs');

const keypair0 = keypairs.find((kp) => kp.publicKey === 'pk-derivation-0');
const keypair7 = keypairs.find((kp) => kp.publicKey === 'pk-derivation-7');

expect(keypair0?.derivationPath).toBe("m/129373'/10'/0'/0'/0");
expect(keypair7?.derivationPath).toBe("m/129373'/10'/0'/0'/7");
} finally {
await dispose();
}
});

it('should not override existing derivationPath', async () => {
const { dispose, runRemainingMigrations, rawInsert, rawQuery } =
await createRepositoriesAtMigration(keypairDerivationPathMigration);

try {
await rawInsert('coco_cashu_keypairs', {
publicKey: 'pk-derivation-existing',
secretKey: '02',
createdAt: 1002,
derivationIndex: 3,
derivationPath: "m/129373'/10'/0'/0'/999",
});

await runRemainingMigrations();

const keypairs = await rawQuery<{
publicKey: string;
derivationPath: string | null;
}>('coco_cashu_keypairs', { publicKey: 'pk-derivation-existing' });

expect(keypairs).toHaveLength(1);
const keypair = keypairs[0];
expect(keypair).toBeDefined();
expect(keypair?.derivationPath).toBe("m/129373'/10'/0'/0'/999");
} finally {
await dispose();
}
});
});
}
});
}
2 changes: 1 addition & 1 deletion packages/adapter-tests/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
Expand Down
1 change: 1 addition & 0 deletions packages/core/models/Keypair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export type Keypair = {
publicKeyHex: string;
secretKey: Uint8Array;
derivationIndex?: number;
derivationPath?: string;
};
10 changes: 8 additions & 2 deletions packages/core/repositories/memory/MemoryKeyRingRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,22 @@ export class MemoryKeyRingRepository implements KeyRingRepository {

// Preserve existing derivationIndex if new one is not provided
let derivationIndex = keyPair.derivationIndex;
if (derivationIndex == null) {
let derivationPath = keyPair.derivationPath;

if (derivationIndex == null || derivationPath == null) {
const existing = this.keyPairs.get(keyPair.publicKeyHex);
if (existing?.derivationIndex != null) {
if (derivationIndex == null && existing?.derivationIndex != null) {
derivationIndex = existing.derivationIndex;
}
if (derivationPath == null && existing?.derivationPath != null) {
derivationPath = existing.derivationPath;
}
}

this.keyPairs.set(keyPair.publicKeyHex, {
...keyPair,
derivationIndex,
derivationPath,
});
}

Expand Down
12 changes: 6 additions & 6 deletions packages/core/services/KeyRingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Proof } from '@cashu/cashu-ts';
import type { Logger } from '@core/logging';
import type { KeyRingRepository } from '@core/repositories';
import type { Keypair } from '@core/models/Keypair';
import { schnorr } from '@noble/curves/secp256k1.js';
import { schnorr, secp256k1 } from '@noble/curves/secp256k1.js';
import { bytesToHex } from '@noble/curves/utils.js';
import { sha256 } from '@noble/hashes/sha2.js';
import type { SeedService } from '@core/services/SeedService.ts';
Expand All @@ -29,7 +29,7 @@ export class KeyRingService {
const nextDerivationIndex = lastDerivationIndex + 1;
const seed = await this.seedService.getSeed();
const hdKey = HDKey.fromMasterSeed(seed);
const derivationPath = `m/129373'/10'/0'/0'/${nextDerivationIndex}`;
const derivationPath = `m/129372'/10'/0'/0'/${nextDerivationIndex}`;
const { privateKey: secretKey } = hdKey.derive(derivationPath);
if (!secretKey) {
throw new Error('Failed to derive secret key');
Expand All @@ -39,6 +39,7 @@ export class KeyRingService {
publicKeyHex,
secretKey,
derivationIndex: nextDerivationIndex,
derivationPath,
});
this.logger?.debug('New key pair generated', { publicKeyHex });
if (options?.dumpSecretKey) {
Expand Down Expand Up @@ -102,11 +103,10 @@ export class KeyRingService {

/**
* Converts a secret key to its corresponding public key in SEC1 compressed format.
* Note: schnorr.getPublicKey() returns a 32-byte x-only public key (BIP340).
* We prepend '02' to create a 33-byte SEC1 compressed format as expected by Cashu.
* Note: secp256k1.getPublicKey() returns a 33-byte compressed public key by default.
*/
private getPublicKeyHex(secretKey: Uint8Array): string {
const publicKey = schnorr.getPublicKey(secretKey);
return '02' + bytesToHex(publicKey);
const publicKey = secp256k1.getPublicKey(secretKey);
return bytesToHex(publicKey);
}
}
55 changes: 50 additions & 5 deletions packages/core/test/unit/KeyRingService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { KeyRingService } from '../../services/KeyRingService.ts';
import { SeedService } from '../../services/SeedService.ts';
import { MemoryKeyRingRepository } from '../../repositories/memory/MemoryKeyRingRepository.ts';
import { bytesToHex } from '@noble/curves/utils.js';
import { schnorr } from '@noble/curves/secp256k1.js';
import { schnorr, secp256k1 } from '@noble/curves/secp256k1.js';
import type { Proof } from '@cashu/cashu-ts';

// Mock seed for deterministic testing
Expand All @@ -28,8 +28,8 @@ describe('KeyRingService', () => {
const result = await service.generateNewKeyPair();

expect(result.publicKeyHex).toBeDefined();
expect(result.publicKeyHex.length).toBe(66); // 32 bytes * 2 for hex + '02' prefix
expect(result.publicKeyHex.startsWith('02')).toBe(true);
expect(result.publicKeyHex.length).toBe(66); // 33 bytes * 2 for hex
expect(result.publicKeyHex.startsWith('02') || result.publicKeyHex.startsWith('03')).toBe(true);
expect('secretKey' in result).toBe(false);

// Verify it was stored in the repository
Expand Down Expand Up @@ -80,6 +80,9 @@ describe('KeyRingService', () => {
expect(stored1?.derivationIndex).toBe(0);
expect(stored2?.derivationIndex).toBe(1);
expect(stored3?.derivationIndex).toBe(2);
expect(stored1?.derivationPath).toBe("m/129372'/10'/0'/0'/0");
expect(stored2?.derivationPath).toBe("m/129372'/10'/0'/0'/1");
expect(stored3?.derivationPath).toBe("m/129372'/10'/0'/0'/2");
});

it('derives deterministic keys from the same seed', async () => {
Expand Down Expand Up @@ -162,8 +165,8 @@ describe('KeyRingService', () => {
const secretKey = schnorr.utils.randomSecretKey();
const result = await service.addKeyPair(secretKey);

// The public key should have '02' prefix for compressed format
const publicKeyHex = '02' + bytesToHex(schnorr.getPublicKey(secretKey));
// The public key should be in compressed format (starts with 02 or 03)
const publicKeyHex = bytesToHex(secp256k1.getPublicKey(secretKey));
const stored = await repo.getPersistedKeyPair(publicKeyHex);

expect(stored).not.toBeNull();
Expand Down Expand Up @@ -484,4 +487,46 @@ describe('KeyRingService', () => {
);
});
});

describe('P2PK Test Vectors', () => {
// Mnemonic: half depart obvious quality work element tank gorilla view sugar picture humble
// Seed (calculated via PBKDF2): dd44ee516b0647e80b488e8dcc56d736a148f15276bef588b37057476d4b2b25780d3688a32b37353d6995997842c0fd8b412475c891c16310471fbc86dcbda8
const TEST_VECTOR_SEED_HEX =
'dd44ee516b0647e80b488e8dcc56d736a148f15276bef588b37057476d4b2b25780d3688a32b37353d6995997842c0fd8b412475c891c16310471fbc86dcbda8';

// Convert hex seed to Uint8Array
const seedBytes = new Uint8Array(
TEST_VECTOR_SEED_HEX.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
);

const EXPECTED_PUBKEYS = [
'03381fbf0996b81d49c35bae17a70d71db9a9e802b1af5c2516fc90381f4741e06', // index 0
'039bbb7a9cd234da13a113cdd8e037a25c66bbf3a77139d652786a1d7e9d73e600', // index 1
'02ffd52ed54761750d75b67342544cc8da8a0994f84c46d546e0ab574dd3651a29', // index 2
'02751ab780960ff177c2300e440fddc0850238a78782a1cab7b0ae03c41978d92d', // index 3
'0391a9ba1c3caf39ca0536d44419a6ceeda922ee61aa651a72a60171499c02b423', // index 4
];

let vectorService: KeyRingService;
let vectorRepo: MemoryKeyRingRepository;

beforeEach(() => {
vectorRepo = new MemoryKeyRingRepository();
const vectorSeedService = new SeedService(async () => seedBytes);
vectorService = new KeyRingService(vectorRepo, vectorSeedService);
});

it('generates correct public keys for test vectors', async () => {
let i = 0;
for (const expected of EXPECTED_PUBKEYS) {
const keyPair = await vectorService.generateNewKeyPair();
expect(keyPair.publicKeyHex).toBe(expected);

// Also verify the derivation index is correct in storage
const stored = await vectorRepo.getPersistedKeyPair(keyPair.publicKeyHex);
expect(stored?.derivationIndex).toBe(i);
i++;
}
});
});
});
27 changes: 20 additions & 7 deletions packages/expo-sqlite/src/repositories/KeyRingRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ export class ExpoKeyRingRepository implements KeyRingRepository {
publicKey: string;
secretKey: string;
derivationIndex: number | null;
derivationPath: string | null;
}>(
'SELECT publicKey, secretKey, derivationIndex FROM coco_cashu_keypairs WHERE publicKey = ? LIMIT 1',
'SELECT publicKey, secretKey, derivationIndex, derivationPath FROM coco_cashu_keypairs WHERE publicKey = ? LIMIT 1',
[publicKey],
);
if (!row) return null;
Expand All @@ -26,6 +27,7 @@ export class ExpoKeyRingRepository implements KeyRingRepository {
publicKeyHex: row.publicKey,
secretKey: secretKeyBytes,
derivationIndex: row.derivationIndex ?? undefined,
derivationPath: row.derivationPath ?? undefined,
};
} catch (error) {
throw new Error(
Expand All @@ -40,12 +42,19 @@ export class ExpoKeyRingRepository implements KeyRingRepository {
const secretKeyHex = bytesToHex(keyPair.secretKey);

await this.db.run(
`INSERT INTO coco_cashu_keypairs (publicKey, secretKey, createdAt, derivationIndex)
VALUES (?, ?, ?, ?)
`INSERT INTO coco_cashu_keypairs (publicKey, secretKey, createdAt, derivationIndex, derivationPath)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(publicKey) DO UPDATE SET
secretKey=excluded.secretKey,
derivationIndex=COALESCE(excluded.derivationIndex, coco_cashu_keypairs.derivationIndex)`,
[keyPair.publicKeyHex, secretKeyHex, Date.now(), keyPair.derivationIndex ?? null],
derivationIndex=COALESCE(excluded.derivationIndex, coco_cashu_keypairs.derivationIndex),
derivationPath=COALESCE(excluded.derivationPath, coco_cashu_keypairs.derivationPath)`,
[
keyPair.publicKeyHex,
secretKeyHex,
Date.now(),
keyPair.derivationIndex ?? null,
keyPair.derivationPath ?? null,
],
);
}

Expand All @@ -58,7 +67,8 @@ export class ExpoKeyRingRepository implements KeyRingRepository {
publicKey: string;
secretKey: string;
derivationIndex: number | null;
}>('SELECT publicKey, secretKey, derivationIndex FROM coco_cashu_keypairs');
derivationPath: string | null;
}>('SELECT publicKey, secretKey, derivationIndex, derivationPath FROM coco_cashu_keypairs');

return rows.map((row) => {
try {
Expand All @@ -67,6 +77,7 @@ export class ExpoKeyRingRepository implements KeyRingRepository {
publicKeyHex: row.publicKey,
secretKey: secretKeyBytes,
derivationIndex: row.derivationIndex ?? undefined,
derivationPath: row.derivationPath ?? undefined,
};
} catch (error) {
throw new Error(
Expand All @@ -83,8 +94,9 @@ export class ExpoKeyRingRepository implements KeyRingRepository {
publicKey: string;
secretKey: string;
derivationIndex: number | null;
derivationPath: string | null;
}>(
'SELECT publicKey, secretKey, derivationIndex FROM coco_cashu_keypairs ORDER BY createdAt DESC LIMIT 1',
'SELECT publicKey, secretKey, derivationIndex, derivationPath FROM coco_cashu_keypairs ORDER BY createdAt DESC LIMIT 1',
);
if (!row) return null;

Expand All @@ -94,6 +106,7 @@ export class ExpoKeyRingRepository implements KeyRingRepository {
publicKeyHex: row.publicKey,
secretKey: secretKeyBytes,
derivationIndex: row.derivationIndex ?? undefined,
derivationPath: row.derivationPath ?? undefined,
};
} catch (error) {
throw new Error(
Expand Down
11 changes: 11 additions & 0 deletions packages/expo-sqlite/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,17 @@ const MIGRATIONS: readonly Migration[] = [
ALTER TABLE coco_cashu_melt_operations ADD COLUMN effectiveFee INTEGER;
`,
},
{
id: '017_keypair_derivation_path',
run: async (db: ExpoSqliteDb) => {
await db.exec('ALTER TABLE coco_cashu_keypairs ADD COLUMN derivationPath TEXT');
await db.exec(`
UPDATE coco_cashu_keypairs
SET derivationPath = 'm/129373''/10''/0''/0''/' || derivationIndex
WHERE derivationIndex IS NOT NULL AND derivationPath IS NULL
`);
},
},
];

// Export for testing
Expand Down
30 changes: 30 additions & 0 deletions packages/indexeddb/src/lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,4 +386,34 @@ export async function ensureSchema(db: IdbDb): Promise<void> {
mint.updatedAt = 0;
});
});

// Version 15: Add derivationPath to keypairs
db.version(15)
.stores({
coco_cashu_mints: '&mintUrl, name, updatedAt, trusted',
coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit',
coco_cashu_counters: '&[mintUrl+keysetId]',
coco_cashu_proofs:
'&[mintUrl+secret], [mintUrl+state], [mintUrl+id+state], state, mintUrl, id, usedByOperationId, createdByOperationId',
coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl',
coco_cashu_melt_quotes: '&[mintUrl+quote], state, mintUrl',
coco_cashu_history:
'++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]',
coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex, derivationPath',
coco_cashu_send_operations: '&id, state, mintUrl',
coco_cashu_melt_operations: '&id, state, mintUrl, [mintUrl+quoteId]',
coco_cashu_receive_operations: '&id, state, mintUrl',
})
.upgrade(async (tx) => {
const table = tx.table('coco_cashu_keypairs');
const keypairs = await table.toArray();

for (const keypair of keypairs) {
if (keypair.derivationPath == null && keypair.derivationIndex != null) {
await table.update(keypair.publicKey, {
derivationPath: `m/129373'/10'/0'/0'/${keypair.derivationIndex}`,
});
}
}
});
}
Loading
Loading