diff --git a/bun.lock b/bun.lock index 9d9d660de..dd345d75a 100644 --- a/bun.lock +++ b/bun.lock @@ -109,8 +109,12 @@ "packages/wallet-sdk": { "name": "@agicash/wallet-sdk", "dependencies": { + "@agicash/breez-sdk-spark": "0.13.5-1", "@agicash/opensecret": "catalog:", "@cashu/cashu-ts": "3.6.1", + "@noble/hashes": "1.8.0", + "@scure/bip39": "1.6.0", + "@stablelib/base64": "catalog:", "@supabase/supabase-js": "2.95.2", "jwt-decode": "4.0.0", }, diff --git a/packages/wallet-sdk/package.json b/packages/wallet-sdk/package.json index bf65478f2..e531b85f3 100644 --- a/packages/wallet-sdk/package.json +++ b/packages/wallet-sdk/package.json @@ -11,8 +11,12 @@ "test": "bun test" }, "dependencies": { + "@agicash/breez-sdk-spark": "0.13.5-1", "@agicash/opensecret": "catalog:", "@cashu/cashu-ts": "3.6.1", + "@noble/hashes": "1.8.0", + "@scure/bip39": "1.6.0", + "@stablelib/base64": "catalog:", "@supabase/supabase-js": "2.95.2", "jwt-decode": "4.0.0" }, diff --git a/packages/wallet-sdk/src/internal/account-handle-resolver.test.ts b/packages/wallet-sdk/src/internal/account-handle-resolver.test.ts new file mode 100644 index 000000000..3cbcf8c84 --- /dev/null +++ b/packages/wallet-sdk/src/internal/account-handle-resolver.test.ts @@ -0,0 +1,345 @@ +import { NetworkError } from '@cashu/cashu-ts'; +import { describe, expect, test } from 'bun:test'; +import { getAccountBalance } from './account-balance'; +import { + type EncryptedCashuProofRow, + LiveAccountHandleResolver, +} from './account-handle-resolver'; +import { MintMetadataCache } from './cashu-wallet'; +import { dbAccountToAccount } from './db-account'; +import type { Encryption } from './encryption'; +import { type BreezRuntime, SparkWalletCache } from './spark-wallet'; +import type { AgicashDbAccountWithProofs } from './db-account'; + +const MINT_URL = 'https://mint.example.com'; +const MNEMONIC = 'test test test test test test test test test test test junk'; + +// -- Fakes ---------------------------------------------------------------------------------- + +/** A fake {@link Encryption} that returns the queued plaintext in order (no real crypto). */ +function fakeEncryption(plaintexts: unknown[]): Encryption { + return { + decryptBatch: async (data: readonly string[]) => + plaintexts.slice(0, data.length) as never, + }; +} + +/** Online fake Mint factory (one active 'sat' keyset). */ +function onlineMintCache(): MintMetadataCache { + return new MintMetadataCache( + () => 1000, + (_url) => + ({ + getInfo: async () => ({ name: 'T', version: 'x', nuts: {} }), + getKeySets: async () => ({ + keysets: [ + { id: '00abcd', unit: 'sat', active: true, input_fee_ppk: 0 }, + ], + }), + getKeys: async () => ({ + keysets: [ + { + id: '00abcd', + unit: 'sat', + keys: { '1': `02${'a'.repeat(64)}`, '2': `02${'b'.repeat(64)}` }, + }, + ], + }), + }) as never, + ); +} + +/** Offline fake Mint factory (every call throws NetworkError). */ +function offlineMintCache(): MintMetadataCache { + return new MintMetadataCache( + () => 1000, + (_url) => { + const t = () => { + throw new NetworkError('offline'); + }; + return { getInfo: t, getKeySets: t, getKeys: t } as never; + }, + ); +} + +/** Mock Breez runtime: connect → wallet with a fixed balance. */ +function mockBreezRuntime(balanceSats = 4242): BreezRuntime { + return { + defaultConfig: () => ({}) as never, + initLogging: async () => { + /* no-op logger */ + }, + connect: async () => ({ getInfo: async () => ({ balanceSats }) }) as never, + } as unknown as BreezRuntime; +} + +function encryptedProofRow( + over: Partial = {}, +): EncryptedCashuProofRow { + return { + id: 'p1', + accountId: 'acct-cashu', + userId: 'user-1', + keysetId: '00abcd', + amount: 'ENC(amount)', + secret: 'ENC(secret)', + unblindedSignature: 'C-value', + publicKeyY: 'Y-value', + dleq: undefined, + witness: undefined, + state: 'UNSPENT', + version: 1, + createdAt: '2026-01-01T00:00:00.000Z', + reservedAt: null, + spentAt: null, + ...over, + }; +} + +function baseDeps() { + return { + getCashuWalletSeed: async () => undefined, + getSparkWalletMnemonic: async () => MNEMONIC, + sparkStorageDir: '/tmp/spark-test', + breezRuntime: mockBreezRuntime(), + }; +} + +// -- resolveCashu --------------------------------------------------------------------------- + +describe('LiveAccountHandleResolver.resolveCashu', () => { + test('decrypts proofs (amount→number, secret→string) + builds an online wallet', async () => { + const resolver = new LiveAccountHandleResolver({ + ...baseDeps(), + // Two proofs → 4 ciphertexts interleaved [amount,secret,amount,secret]. + encryption: fakeEncryption([21, 'secret-a', 9, 'secret-b']), + mintCache: onlineMintCache(), + sparkCache: new SparkWalletCache(), + breezApiKey: 'k', + }); + + const { wallet, isOnline, proofs } = await resolver.resolveCashu({ + mintUrl: MINT_URL, + currency: 'BTC', + encryptedProofs: [ + encryptedProofRow({ id: 'p1' }), + encryptedProofRow({ id: 'p2' }), + ], + }); + + expect(isOnline).toBe(true); + expect(wallet.unit).toBe('sat'); + expect(proofs).toHaveLength(2); + expect(proofs[0]).toMatchObject({ + id: 'p1', + amount: 21, + secret: 'secret-a', + }); + expect(proofs[1]).toMatchObject({ + id: 'p2', + amount: 9, + secret: 'secret-b', + }); + // dleq/witness re-parsed via cashu-ts ProofSchema (undefined stays undefined). + expect(proofs[0].dleq).toBeUndefined(); + }); + + test('parses a real dleq via ProofSchema', async () => { + const resolver = new LiveAccountHandleResolver({ + ...baseDeps(), + encryption: fakeEncryption([1, 'sec']), + mintCache: onlineMintCache(), + sparkCache: new SparkWalletCache(), + breezApiKey: 'k', + }); + + const { proofs } = await resolver.resolveCashu({ + mintUrl: MINT_URL, + currency: 'BTC', + encryptedProofs: [ + encryptedProofRow({ dleq: { s: '01', e: '02' } as never }), + ], + }); + + expect(proofs[0].dleq).toEqual({ s: '01', e: '02' } as never); + }); + + test('no proofs → empty array (no decrypt call)', async () => { + let called = false; + const resolver = new LiveAccountHandleResolver({ + ...baseDeps(), + encryption: { + decryptBatch: async () => { + called = true; + return [] as never; + }, + }, + mintCache: onlineMintCache(), + sparkCache: new SparkWalletCache(), + breezApiKey: 'k', + }); + + const { proofs } = await resolver.resolveCashu({ + mintUrl: MINT_URL, + currency: 'BTC', + encryptedProofs: [], + }); + + expect(proofs).toEqual([]); + expect(called).toBe(false); + }); + + test('offline mint → isOnline false but proofs still decrypt', async () => { + const resolver = new LiveAccountHandleResolver({ + ...baseDeps(), + encryption: fakeEncryption([100, 'sec']), + mintCache: offlineMintCache(), + sparkCache: new SparkWalletCache(), + breezApiKey: 'k', + }); + + const { isOnline, proofs } = await resolver.resolveCashu({ + mintUrl: MINT_URL, + currency: 'BTC', + encryptedProofs: [encryptedProofRow()], + }); + + expect(isOnline).toBe(false); + expect(proofs[0].amount).toBe(100); + }); +}); + +// -- resolveSpark --------------------------------------------------------------------------- + +describe('LiveAccountHandleResolver.resolveSpark', () => { + test('with a breezApiKey: connects + returns balance + online', async () => { + const resolver = new LiveAccountHandleResolver({ + ...baseDeps(), + encryption: fakeEncryption([]), + mintCache: onlineMintCache(), + sparkCache: new SparkWalletCache(), + breezApiKey: 'k', + breezRuntime: mockBreezRuntime(7777), + }); + + const { isOnline, balance } = await resolver.resolveSpark({ + network: 'MAINNET', + }); + + expect(isOnline).toBe(true); + expect(balance?.toNumber('sat')).toBe(7777); + }); + + test('without a breezApiKey: offline stub + null balance (cashu-only mode)', async () => { + const resolver = new LiveAccountHandleResolver({ + ...baseDeps(), + encryption: fakeEncryption([]), + mintCache: onlineMintCache(), + sparkCache: new SparkWalletCache(), + breezApiKey: undefined, + }); + + const { wallet, isOnline, balance } = await resolver.resolveSpark({ + network: 'MAINNET', + }); + + expect(isOnline).toBe(false); + expect(balance).toBeNull(); + // The stub throws on use, identifying the missing key. + expect(() => + (wallet as unknown as { getInfo: () => unknown }).getInfo(), + ).toThrow('no breezApiKey'); + }); +}); + +// -- getBalance over the real (decrypted) proofs -------------------------------------------- + +describe('getAccountBalance with real decrypted proofs', () => { + test('a cashu account balance = sum(decrypted proof amounts)', async () => { + const resolver = new LiveAccountHandleResolver({ + ...baseDeps(), + encryption: fakeEncryption([21, 'sa', 9, 'sb', 12, 'sc']), + mintCache: onlineMintCache(), + sparkCache: new SparkWalletCache(), + breezApiKey: 'k', + }); + + const row: AgicashDbAccountWithProofs = { + id: 'acct-cashu', + name: 'Mint', + type: 'cashu', + purpose: 'transactional', + state: 'active', + currency: 'BTC', + details: { + mint_url: MINT_URL, + is_test_mint: false, + keyset_counters: {}, + }, + created_at: '2026-01-01T00:00:00.000Z', + expires_at: null, + user_id: 'user-1', + version: 1, + cashu_proofs: [ + { + id: 'p1', + account_id: 'acct-cashu', + user_id: 'user-1', + keyset_id: '00abcd', + amount: 'ENC', + secret: 'ENC', + unblinded_signature: 'C', + public_key_y: 'Y', + dleq: null, + witness: null, + state: 'UNSPENT', + version: 1, + created_at: '2026-01-01T00:00:00.000Z', + reserved_at: null, + }, + { + id: 'p2', + account_id: 'acct-cashu', + user_id: 'user-1', + keyset_id: '00abcd', + amount: 'ENC', + secret: 'ENC', + unblinded_signature: 'C', + public_key_y: 'Y', + dleq: null, + witness: null, + state: 'UNSPENT', + version: 1, + created_at: '2026-01-01T00:00:00.000Z', + reserved_at: null, + }, + { + id: 'p3', + account_id: 'acct-cashu', + user_id: 'user-1', + keyset_id: '00abcd', + amount: 'ENC', + secret: 'ENC', + unblinded_signature: 'C', + public_key_y: 'Y', + dleq: null, + witness: null, + state: 'UNSPENT', + version: 1, + created_at: '2026-01-01T00:00:00.000Z', + reserved_at: null, + }, + ], + }; + + const account = await dbAccountToAccount(row, resolver); + const balance = getAccountBalance(account); + + // 21 + 9 + 12 = 42 sats. + expect(balance?.toNumber('sat')).toBe(42); + expect(account.type).toBe('cashu'); + if (account.type === 'cashu') { + expect(account.proofs).toHaveLength(3); + } + }); +}); diff --git a/packages/wallet-sdk/src/internal/account-handle-resolver.ts b/packages/wallet-sdk/src/internal/account-handle-resolver.ts index 47f3bdd7f..585b50834 100644 --- a/packages/wallet-sdk/src/internal/account-handle-resolver.ts +++ b/packages/wallet-sdk/src/internal/account-handle-resolver.ts @@ -18,18 +18,29 @@ * This module is the explicit injection seam between the two slices: * - `AccountHandleResolver` is the interface the account repository depends on to fill in * the deferred `wallet` (+ a cashu account's `isOnline` and decrypted `proofs`). - * - {@link DeferredAccountHandleResolver} is the Slice-2 implementation: it constructs - * accounts from the DB fields Slice 2 owns and leaves the live handle deferred — any - * *use* of `wallet` (or decrypted `proofs`) throws {@link NotImplementedError} via a - * lazy stub. Slice 3 swaps in the real resolver (mint/Breez init + proof decryption) - * with no change to the repository. - * - * This keeps PR4 small (no mint/Breez/encryption wiring) while making the account read/ - * write surface — `list` / `get` / `getDefault` / `add` / `setDefault` / `getBalance` - * (spark, and cashu once Slice 3 decrypts proofs) / `suggestFor` — real and reviewable. + * - {@link DeferredAccountHandleResolver} is the Slice-2 stub: it constructs accounts from + * the DB fields Slice 2 owns and leaves the live handle deferred — any *use* of `wallet` + * (or decrypted `proofs`) throws {@link NotImplementedError} via a lazy stub. (Still used + * by the repository's DB-mapping unit tests, which exercise row→domain in isolation.) + * - {@link LiveAccountHandleResolver} is the REAL Slice-3 implementation (this PR): it + * initialises the cashu mint wallet ({@link getInitializedCashuWallet}) + decrypts the + * proofs (OpenSecret-derived key + ECIES) and connects the spark Breez wallet + * ({@link getInitializedSparkWallet}). `Sdk.create` wires THIS resolver into the + * repository — no change to the repository itself (the whole point of the seam). * * @module */ +import { + type MintMetadataCache, + getInitializedCashuWallet, +} from './cashu-wallet'; +import type { Encryption } from './encryption'; +import { ProofSchema } from './lib-cashu-wallet'; +import { + type BreezRuntime, + type SparkWalletCache, + getInitializedSparkWallet, +} from './spark-wallet'; import { NotImplementedError } from '../errors'; import type { CashuProof } from '../types/account'; import type { @@ -37,6 +48,7 @@ import type { ExtendedCashuWallet, SparkNetwork, } from '../types/dependencies'; +import { z } from 'zod/mini'; import type { Currency } from '../types/money'; /** Inputs needed to (eventually) initialise a cashu account's live wallet + proofs. */ @@ -160,3 +172,139 @@ export class DeferredAccountHandleResolver implements AccountHandleResolver { }; } } + +/** Dependencies the live resolver needs (the per-user secrets + memos + Breez config). */ +export type LiveAccountHandleResolverDeps = { + /** Decrypts the encrypted `(amount, secret)` proof ciphertext (OpenSecret-derived key). */ + encryption: Encryption; + /** The user's BIP39 cashu wallet seed (master `getCashuWalletSeed`), or undefined. */ + getCashuWalletSeed: () => Promise; + /** Per-mint protocol-metadata memo (1 h staleTime), shared across resolves. */ + mintCache: MintMetadataCache; + /** The user's spark wallet seed mnemonic (master `getSparkWalletMnemonic`). */ + getSparkWalletMnemonic: () => Promise; + /** Per-(mnemonic,network) connected-spark-wallet memo, shared across resolves. */ + sparkCache: SparkWalletCache; + /** The Breez API key (`SdkConfig.breezApiKey`); spark resolves to a stub when absent. */ + breezApiKey?: string; + /** Breez storage directory (master `'./.spark-data'`). */ + sparkStorageDir: string; + /** Optional Breez-runtime injection (tests pass a mock; default = the native module). */ + breezRuntime?: BreezRuntime; +}; + +/** + * The REAL {@link AccountHandleResolver} (Slice 3): builds each account's LIVE wallet handle + * and fills in the connection-bound fields the Slice-2 stub deferred — a cashu account's + * decrypted `proofs` + live mint wallet, a spark account's live Breez wallet + balance. + * + * Mirrors master `account-repository.toAccount`'s per-type construction, re-housed + * framework-free: + * - cashu: in parallel, initialise the mint wallet ({@link getInitializedCashuWallet}, the + * 1 h-memo'd networked keyset/keys fetch with master's 10 s timeout) and DECRYPT the + * proofs (master `decryptCashuProofs`: `encryption.decryptBatch` of the interleaved + * `amount`/`secret` ciphertext, then re-parse `dleq`/`witness` via cashu-ts `ProofSchema`). + * - spark: connect the Breez wallet ({@link getInitializedSparkWallet}) and read its balance. + * When no `breezApiKey` is configured, spark stays a labelled stub + offline (the SDK can + * run cashu-only without Breez). + * + * The mint/spark memos are constructor-injected so they live as long as the resolver (one + * per `Sdk` instance) and are dropped on `Sdk.destroy()`. + */ +export class LiveAccountHandleResolver implements AccountHandleResolver { + constructor(private readonly deps: LiveAccountHandleResolverDeps) {} + + /** + * Resolve a cashu account's live wallet, online flag, and decrypted proofs. + * + * @param request - the mint URL, currency, and encrypted proof rows. + * @returns the live cashu handle. + */ + async resolveCashu( + request: CashuHandleRequest, + ): Promise { + const seed = await this.deps.getCashuWalletSeed(); + const [{ wallet, isOnline }, proofs] = await Promise.all([ + getInitializedCashuWallet({ + cache: this.deps.mintCache, + mintUrl: request.mintUrl, + currency: request.currency, + bip39seed: seed, + }), + this.decryptProofs(request.encryptedProofs), + ]); + return { wallet, isOnline, proofs }; + } + + /** + * Resolve a spark account's live wallet, online flag, and balance. When no Breez API key + * is configured the wallet is a labelled stub and the account is reported offline. + * + * @param request.network - the spark network. + * @returns the live spark handle. + */ + async resolveSpark(request: { + network: SparkNetwork; + }): Promise { + if (!this.deps.breezApiKey) { + return { + wallet: deferredHandle( + 'BreezSdk (no breezApiKey configured)', + ), + isOnline: false, + balance: null, + }; + } + const mnemonic = await this.deps.getSparkWalletMnemonic(); + const { wallet, balance, isOnline } = await getInitializedSparkWallet({ + cache: this.deps.sparkCache, + mnemonic, + network: request.network, + storageDir: this.deps.sparkStorageDir, + apiKey: this.deps.breezApiKey, + runtime: this.deps.breezRuntime, + }); + return { wallet, isOnline, balance }; + } + + /** + * Decrypt the encrypted proof rows to domain {@link CashuProof}s. Ported from master + * `account-repository.decryptCashuProofs`: the `amount` + `secret` ciphertext is batch- + * decrypted in one ECIES call (interleaved, order-preserving), the `amount` re-parsed as a + * `number` and `secret` as a `string`, and `dleq`/`witness` re-validated via cashu-ts + * `ProofSchema`. The remaining fields are copied straight off the row. + */ + private async decryptProofs( + rows: CashuHandleRequest['encryptedProofs'], + ): Promise { + if (rows.length === 0) { + return []; + } + const encryptedData = rows.flatMap((p) => [p.amount, p.secret]); + const decryptedData = + await this.deps.encryption.decryptBatch(encryptedData); + + return rows.map((row, index) => { + const i = index * 2; + const amount = z.number().parse(decryptedData[i]); + const secret = z.string().parse(decryptedData[i + 1]); + return { + id: row.id, + accountId: row.accountId, + userId: row.userId, + keysetId: row.keysetId, + amount, + secret, + unblindedSignature: row.unblindedSignature, + publicKeyY: row.publicKeyY, + dleq: ProofSchema.shape.dleq.parse(row.dleq), + witness: ProofSchema.shape.witness.parse(row.witness), + state: row.state, + version: row.version, + createdAt: row.createdAt, + reservedAt: row.reservedAt, + spentAt: row.spentAt, + }; + }); + } +} diff --git a/packages/wallet-sdk/src/internal/cashu-wallet.test.ts b/packages/wallet-sdk/src/internal/cashu-wallet.test.ts new file mode 100644 index 000000000..f80022f71 --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-wallet.test.ts @@ -0,0 +1,156 @@ +import { NetworkError } from '@cashu/cashu-ts'; +import { describe, expect, test } from 'bun:test'; +import { MintMetadataCache, getInitializedCashuWallet } from './cashu-wallet'; + +// -- A minimal fake cashu-ts `Mint` returning just enough for the online path --------------- + +const MINT_URL = 'https://mint.example.com'; + +/** Minimal valid mint responses for a 'sat' (BTC) mint with one active keyset. */ +function fakeMintResponses() { + return { + info: { name: 'Test', version: 'x', nuts: {} }, + keysets: { + keysets: [{ id: '00abcd', unit: 'sat', active: true, input_fee_ppk: 0 }], + }, + keys: { + keysets: [ + { + id: '00abcd', + unit: 'sat', + keys: { '1': `02${'a'.repeat(64)}`, '2': `02${'b'.repeat(64)}` }, + }, + ], + }, + }; +} + +/** Build a fake `Mint`-shaped client; `calls` counts how many were constructed. */ +function makeMintFactory(behaviour: 'online' | 'network-error' = 'online'): { + createMint: (url: string) => never; + calls: () => number; +} { + let calls = 0; + const r = fakeMintResponses(); + const createMint = (_url: string) => { + calls++; + if (behaviour === 'network-error') { + const throwNet = () => { + throw new NetworkError('offline'); + }; + return { + getInfo: throwNet, + getKeySets: throwNet, + getKeys: throwNet, + } as never; + } + return { + getInfo: async () => r.info, + getKeySets: async () => r.keysets, + getKeys: async () => r.keys, + } as never; + }; + return { createMint, calls: () => calls }; +} + +describe('MintMetadataCache', () => { + test('memoises within the TTL (one fetch for repeated gets)', async () => { + const { createMint, calls } = makeMintFactory('online'); + const cache = new MintMetadataCache(() => 1000, createMint); + + await cache.get(MINT_URL); + await cache.get(MINT_URL); + + expect(calls()).toBe(1); + }); + + test('refetches once the entry is older than the 1 h TTL', async () => { + const { createMint, calls } = makeMintFactory('online'); + let now = 0; + const cache = new MintMetadataCache(() => now, createMint); + + await cache.get(MINT_URL); + now = 1000 * 60 * 60 + 1; // just past 1 h + await cache.get(MINT_URL); + + expect(calls()).toBe(2); + }); + + test('does NOT cache a rejected fetch (next get retries)', async () => { + let calls = 0; + let mode: 'fail' | 'ok' = 'fail'; + const r = fakeMintResponses(); + const cache = new MintMetadataCache( + () => 1000, + (_url) => { + calls++; + if (mode === 'fail') { + throw new NetworkError('boom'); + } + return { + getInfo: async () => r.info, + getKeySets: async () => r.keysets, + getKeys: async () => r.keys, + } as never; + }, + ); + + await expect(cache.get(MINT_URL)).rejects.toBeInstanceOf(NetworkError); + mode = 'ok'; + await expect(cache.get(MINT_URL)).resolves.toBeDefined(); + expect(calls).toBe(2); + }); +}); + +describe('getInitializedCashuWallet', () => { + test('online: loads keys + reports isOnline true', async () => { + const { createMint } = makeMintFactory('online'); + const cache = new MintMetadataCache(() => 1000, createMint); + + const { wallet, isOnline } = await getInitializedCashuWallet({ + cache, + mintUrl: MINT_URL, + currency: 'BTC', + }); + + expect(isOnline).toBe(true); + expect(wallet.unit).toBe('sat'); + expect(wallet.mint.mintUrl).toBe(MINT_URL); + }); + + test('offline (NetworkError): returns a wallet with isOnline false', async () => { + const { createMint } = makeMintFactory('network-error'); + const cache = new MintMetadataCache(() => 1000, createMint); + + const { wallet, isOnline } = await getInitializedCashuWallet({ + cache, + mintUrl: MINT_URL, + currency: 'BTC', + }); + + expect(isOnline).toBe(false); + // The wallet is still constructed (offline-tolerant), just without loaded keys. + expect(wallet.unit).toBe('sat'); + }); + + test('reachable mint with no active keyset for the currency throws', async () => { + const cache = new MintMetadataCache( + () => 1000, + (_url) => + ({ + getInfo: async () => ({ name: 'T', version: 'x', nuts: {} }), + // only an INACTIVE keyset → no active keyset for sat + getKeySets: async () => ({ + keysets: [ + { id: '00ff', unit: 'sat', active: false, input_fee_ppk: 0 }, + ], + }), + getKeys: async () => ({ keysets: [] }), + }) as never, + ); + + await expect( + getInitializedCashuWallet({ cache, mintUrl: MINT_URL, currency: 'BTC' }), + ).rejects.toThrow('No active keyset'); + }); +}); diff --git a/packages/wallet-sdk/src/internal/cashu-wallet.ts b/packages/wallet-sdk/src/internal/cashu-wallet.ts new file mode 100644 index 000000000..a1ebddcfe --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-wallet.ts @@ -0,0 +1,205 @@ +/** + * Internal cashu wallet initialiser — Slice 3 (cashu + spark). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/shared/cashu.ts#getInitializedCashuWallet` (+ the + * `mintInfoQueryOptions` / `allMintKeysetsQueryOptions` / `mintKeysQueryOptions` it reads). + * Builds a cashu account's LIVE handle: an {@link ExtendedCashuWallet} (cashu-ts wallet + + * seed) with the mint's protocol metadata (info / keysets / active keys) loaded from cache, + * and an `isOnline` flag. The contract calls this the per-mint protocol-metadata memo + * (§0 state kind 2, honoring the 1 h staleTime). + * + * Re-housing vs master: + * - Master fetches the three mint endpoints through a `QueryClient` (1 h `staleTime` memo) + * and races them against a 10 s timeout that `queryClient.cancelQueries` cancels. Here a + * framework-free {@link MintMetadataCache} provides the same 1 h memo (keyed by mint URL), + * and the timeout rejects with a cashu-ts `NetworkError` (the same offline signal master + * keys off) — there is no query to cancel, so the in-flight fetches are simply abandoned. + * - The `measureOperation` performance wrapper is dropped (web-only telemetry, §3). + * - On `NetworkError` (offline OR timeout) it returns a wallet with NO loaded keys + + * `isOnline: false`, exactly as master; any other error propagates. + * + * The memo is INSTANCE state (held by the resolver), not a module global — multiple SDK + * instances do not share it, and `Sdk.destroy()` drops it with the resolver. + * + * @module + */ +import { + KeyChain, + type Mint as MintClass, + Mint, + NetworkError, +} from '@cashu/cashu-ts'; +import type { GetKeysResponse, GetKeysetsResponse } from '@cashu/cashu-ts'; +import { getCashuUnit } from './lib-cashu'; +import { + type ExtendedCashuWallet, + ExtendedMintInfo, + getCashuProtocolUnit, + getCashuWallet, +} from './lib-cashu-wallet'; +import type { Currency } from '../types/money'; + +/** How long fetched mint metadata stays fresh — matches master's 1 h `staleTime`. */ +const MINT_METADATA_TTL_MS = 1000 * 60 * 60; + +/** How long to wait for the networked mint metadata before declaring the mint offline. */ +const MINT_REQUEST_TIMEOUT_MS = 10_000; + +/** The mint protocol metadata needed to load a wallet from cache. */ +type MintMetadata = { + mintInfo: ExtendedMintInfo; + allMintKeysets: GetKeysetsResponse; + mintActiveKeys: GetKeysResponse; +}; + +type CacheEntry = { + fetchedAt: number; + value: Promise; +}; + +/** + * A tiny per-mint-URL TTL memo for the (info / keysets / keys) protocol metadata — the + * framework-free stand-in for master's `QueryClient` 1 h `staleTime` cache. A stale or + * absent entry triggers one fetch; concurrent callers within the window share it. A fetch + * that rejects is NOT cached (the next call retries), so a transient mint outage does not + * pin an offline result for an hour. + */ +export class MintMetadataCache { + private readonly entries = new Map(); + + /** + * @param now - clock injection for tests (defaults to `Date.now`). + * @param createMint - mint-client factory injection for tests (defaults to cashu-ts `Mint`). + */ + constructor( + private readonly now: () => number = Date.now, + private readonly createMint: (mintUrl: string) => MintClass = (mintUrl) => + new Mint(mintUrl), + ) {} + + /** Fetch (or return memoised) the protocol metadata for `mintUrl`. */ + get(mintUrl: string): Promise { + const existing = this.entries.get(mintUrl); + if (existing && this.now() - existing.fetchedAt < MINT_METADATA_TTL_MS) { + return existing.value; + } + const value = this.fetch(mintUrl); + this.entries.set(mintUrl, { fetchedAt: this.now(), value }); + // Don't pin a rejected fetch — drop it so the next call retries. + value.catch(() => { + if (this.entries.get(mintUrl)?.value === value) { + this.entries.delete(mintUrl); + } + }); + return value; + } + + /** Drop all memoised metadata (called when the resolver / SDK is torn down). */ + clear(): void { + this.entries.clear(); + } + + private async fetch(mintUrl: string): Promise { + const mint = this.createMint(mintUrl); + const [info, allMintKeysets, mintActiveKeys] = await Promise.all([ + mint.getInfo(), + mint.getKeySets(), + mint.getKeys(), + ]); + return { + mintInfo: new ExtendedMintInfo(info), + allMintKeysets, + mintActiveKeys, + }; + } +} + +/** + * Race `promise` against a {@link MINT_REQUEST_TIMEOUT_MS} timer that rejects with a cashu-ts + * `NetworkError` — so a hung mint surfaces the SAME offline signal master's timeout does. + */ +function withMintTimeout(promise: Promise): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout( + () => reject(new NetworkError('Mint request timed out')), + MINT_REQUEST_TIMEOUT_MS, + ), + ), + ]); +} + +/** + * Initialise a cashu wallet with offline handling, mirroring master + * `getInitializedCashuWallet`. Fetches the mint's protocol metadata (memoised, timed out), + * builds an {@link ExtendedCashuWallet} for the account's unit, and loads the active keyset's + * keys into it from cache. If the mint is offline or times out (a `NetworkError`), returns a + * keyless wallet with `isOnline: false`; any other error propagates. + * + * @param params.cache - the per-mint metadata memo (held by the resolver). + * @param params.mintUrl - the mint URL. + * @param params.currency - the account currency (selects the cashu unit + keyset). + * @param params.bip39seed - optional BIP39 seed for deterministic-secret operations. + * @returns the live wallet + whether the mint was reachable. + * @throws Error if the mint is reachable but has no active keyset / keys for the currency. + */ +export async function getInitializedCashuWallet({ + cache, + mintUrl, + currency, + bip39seed, +}: { + cache: MintMetadataCache; + mintUrl: string; + currency: Currency; + bip39seed?: Uint8Array; +}): Promise<{ wallet: ExtendedCashuWallet; isOnline: boolean }> { + let metadata: MintMetadata; + try { + metadata = await withMintTimeout(cache.get(mintUrl)); + } catch (error) { + if (error instanceof NetworkError) { + const wallet = getCashuWallet(mintUrl, { + unit: getCashuUnit(currency), + bip39seed, + }); + return { wallet, isOnline: false }; + } + throw error; + } + + const { mintInfo, allMintKeysets, mintActiveKeys } = metadata; + + const unitKeysets = allMintKeysets.keysets.filter( + (ks) => ks.unit === getCashuProtocolUnit(currency), + ); + const activeKeyset = unitKeysets.find((ks) => ks.active); + if (!activeKeyset) { + throw new Error(`No active keyset found for ${currency} on ${mintUrl}`); + } + + const activeKeysForUnit = mintActiveKeys.keysets.find( + (ks) => ks.id === activeKeyset.id, + ); + if (!activeKeysForUnit) { + throw new Error( + `Got active keyset ${activeKeyset.id} from ${mintUrl} but could not find keys for it`, + ); + } + + const wallet = getCashuWallet(mintUrl, { + unit: getCashuUnit(currency), + bip39seed, + }); + const keyChainCache = KeyChain.mintToCacheDTO( + wallet.unit, + mintUrl, + unitKeysets, + [activeKeysForUnit], + ); + wallet.loadMintFromCache(mintInfo.cache, keyChainCache); + + return { wallet, isOnline: true }; +} diff --git a/packages/wallet-sdk/src/internal/db-account.test.ts b/packages/wallet-sdk/src/internal/db-account.test.ts index dcb4fecbb..a293459d4 100644 --- a/packages/wallet-sdk/src/internal/db-account.test.ts +++ b/packages/wallet-sdk/src/internal/db-account.test.ts @@ -137,8 +137,10 @@ describe('DeferredAccountHandleResolver — the live handle is a throwing stub', test('reading a method off the deferred spark wallet throws NotImplementedError', async () => { const { wallet } = await deferred.resolveSpark({ network: 'MAINNET' }); - expect(() => (wallet as { getInfo: () => unknown }).getInfo()).toThrow( - 'Slice 3', - ); + // `BreezSdk.getInfo` requires an argument (the type is real now); cast via `unknown` to + // assert the lazy stub throws on any access regardless of the method's real signature. + expect(() => + (wallet as unknown as { getInfo: () => unknown }).getInfo(), + ).toThrow('Slice 3'); }); }); diff --git a/packages/wallet-sdk/src/internal/encryption.test.ts b/packages/wallet-sdk/src/internal/encryption.test.ts new file mode 100644 index 000000000..b6998a010 --- /dev/null +++ b/packages/wallet-sdk/src/internal/encryption.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; + +// Mock the ECIES primitive so these unit tests exercise the key-caching + (de)serialisation +// logic WITHOUT real crypto: `eciesDecryptBatch` echoes back a UTF-8 encoding of whatever the +// test queued (the base64-`decode` of the input is ignored). The queued plaintexts are the +// JSON strings master's `preprocessData` would have produced. +const decryptedQueue: string[][] = []; +mock.module('./lib-ecies', () => ({ + eciesDecryptBatch: (data: Uint8Array[]) => { + const next = decryptedQueue.shift(); + if (!next) { + throw new Error('no queued decrypt result'); + } + const enc = new TextEncoder(); + return data.map((_, i) => enc.encode(next[i])); + }, +})); + +const { createEncryption, getEncryption } = await import('./encryption'); + +/** A valid base64 ciphertext placeholder — its decoded bytes are ignored by the mock. */ +const CT = btoa('placeholder-ciphertext'); + +afterEach(() => { + decryptedQueue.length = 0; +}); + +const key32 = new Uint8Array(32).fill(7); + +describe('getEncryption.decryptBatch', () => { + test('deserialises a number + a string proof field (master amount/secret)', async () => { + decryptedQueue.push(['42', JSON.stringify('deadbeef')]); + const enc = getEncryption(key32); + + const [amount, secret] = await enc.decryptBatch([CT, CT]); + + expect(amount).toBe(42); + expect(secret).toBe('deadbeef'); + }); + + test('rehydrates type-tagged Date / undefined / non-finite number', async () => { + decryptedQueue.push([ + JSON.stringify({ __type: 'Date', value: '2026-01-02T03:04:05.000Z' }), + JSON.stringify({ __type: 'undefined' }), + JSON.stringify({ __type: 'number', value: 'Infinity' }), + ]); + const enc = getEncryption(key32); + + const [date, undef, inf] = await enc.decryptBatch([CT, CT, CT]); + + expect(date).toBeInstanceOf(Date); + expect((date as Date).toISOString()).toBe('2026-01-02T03:04:05.000Z'); + expect(undef).toBeUndefined(); + expect(inf).toBe(Number.POSITIVE_INFINITY); + }); +}); + +describe('createEncryption', () => { + test('fetches the key lazily + only once across calls', async () => { + let fetches = 0; + const enc = createEncryption(async () => { + fetches++; + return '07'.repeat(32); // hex → 32 bytes of 0x07 + }); + + // No fetch until the first decrypt. + expect(fetches).toBe(0); + + decryptedQueue.push(['1']); + await enc.decryptBatch([CT]); + decryptedQueue.push(['2']); + await enc.decryptBatch([CT]); + + expect(fetches).toBe(1); + }); + + test('concurrent first-callers share a single key fetch', async () => { + let fetches = 0; + const enc = createEncryption(async () => { + fetches++; + return '07'.repeat(32); + }); + + decryptedQueue.push(['1']); + decryptedQueue.push(['2']); + await Promise.all([enc.decryptBatch([CT]), enc.decryptBatch([CT])]); + + expect(fetches).toBe(1); + }); +}); diff --git a/packages/wallet-sdk/src/internal/encryption.ts b/packages/wallet-sdk/src/internal/encryption.ts new file mode 100644 index 000000000..d5030c85b --- /dev/null +++ b/packages/wallet-sdk/src/internal/encryption.ts @@ -0,0 +1,144 @@ +/** + * Internal ECIES encryption — Slice 3 (cashu + spark). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/shared/encryption.ts`. A cashu account's stored proofs are + * encrypted to the user's data-encryption key; reading an account therefore DECRYPTS the + * `amount` + `secret` ciphertext (master `account-repository.decryptCashuProofs` → + * `encryption.decryptBatch`). Master expresses `Encryption` as a React hook + * (`useEncryption`) over two `@tanstack/react-query` suspense queries that fetch the + * derived key from the OpenSecret enclave; here the same key-derivation + ECIES is plain + * async code. + * + * Only the DECRYPT path is built (Slice 3 reads encrypted proofs; the write/encrypt halves + * land with the send/receive services that persist new proofs). The + * `preprocessData`/`deserializeData` type-preservation (Date / undefined / non-finite + * number / `Money`) is ported VERBATIM from master so a decrypted value round-trips + * identically — for a proof, `amount` deserialises to a `number` and `secret` to a `string`. + * + * @module + */ +import { hexToBytes } from '@noble/hashes/utils'; +import { decode } from '@stablelib/base64'; +import { eciesDecryptBatch } from './lib-ecies'; +import { Money } from '../types/money'; + +/** + * Reverse of master's `preprocessData`: rehydrate the type-tagged JSON back into `Date` / + * `undefined` / non-finite `number` / `Money`. A JSON `reviver`, ported verbatim from + * `shared/encryption.ts#deserializeData` so a value encrypted by master decrypts identically. + * + * @param serializedData - the decrypted JSON string. + * @returns the rehydrated value. + */ +function deserializeData(serializedData: string): T { + return JSON.parse(serializedData, (_, value) => { + if (value && typeof value === 'object' && '__type' in value) { + switch (value.__type) { + case 'Date': + return new Date(value.value); + case 'undefined': + return undefined; + case 'number': + return Number(value.value); // handles Infinity, -Infinity, NaN + case 'Money': + return new Money({ + amount: value.amount, + currency: value.currency, + unit: value.unit, + }); + } + } + return value; + }) as T; +} + +/** + * Decrypt a batch of base64-encoded ECIES ciphertexts with the user's private key, order + * preserved. Ported from `shared/encryption.ts#decryptBatchWithPrivateKey`. + * + * @param encryptedDataArray - the base64 ciphertexts (one per input). + * @param privateKeyBytes - the 32-byte data-encryption private key. + * @returns the decrypted values, in input order. + */ +function decryptBatchWithPrivateKey( + encryptedDataArray: readonly [...{ [K in keyof T]: string }], + privateKeyBytes: Uint8Array, +): T { + const decodedData = encryptedDataArray.map((x) => decode(x)); + const decryptedData = eciesDecryptBatch(decodedData, privateKeyBytes); + const decoder = new TextDecoder(); + return decryptedData.map((x) => { + const decodedString = decoder.decode(x); + return deserializeData(decodedString); + }) as unknown as T; +} + +/** + * The decrypt surface the account read path needs — the framework-free half of master's + * `Encryption` type (the encrypt/encryptBatch halves land with the slices that write + * encrypted rows). + */ +export type Encryption = { + /** + * Decrypt an array of values previously encrypted with the user's encryption key. Input + * may be in any order; output order matches input. + * + * @param data - base64-encoded ECIES ciphertexts. + * @returns a promise of the decrypted values, in input order. + */ + decryptBatch: ( + data: readonly [...{ [K in keyof T]: string }], + ) => Promise; +}; + +/** + * Build an {@link Encryption} bound to the user's data-encryption private key. Ported from + * `shared/encryption.ts#getEncryption` (decrypt half). The key is the 32-byte private key + * the SDK derives from the OpenSecret enclave (see {@link createEncryption}). + * + * @param privateKey - the 32-byte data-encryption private key. + * @returns the encryption (decrypt) surface. + */ +export function getEncryption(privateKey: Uint8Array): Encryption { + return { + decryptBatch: async ( + data: readonly [...{ [K in keyof T]: string }], + ) => decryptBatchWithPrivateKey(data, privateKey), + }; +} + +/** + * Fetch the user's hex-encoded data-encryption private key from the enclave. Injected + * (rather than importing `@agicash/opensecret` here) so this module stays a pure mechanism + * the SDK wires to OpenSecret's `getPrivateKeyBytes` — and so it is trivially testable + * without a live enclave. + * + * Master derives this key at `m/10111099'/0'` (`'enc'` in ASCII) via + * `getPrivateKeyBytes({ private_key_derivation_path })` and `hexToBytes`-decodes the result + * (`shared/encryption.ts#encryptionPrivateKeyQueryOptions`). + */ +export type FetchEncryptionPrivateKeyHex = () => Promise; + +/** + * Construct an {@link Encryption} from an enclave key fetcher, lazily fetching + caching the + * derived private key on first use (the key is stable for the session — master caches it + * with `staleTime: Infinity`). Concurrent first-callers share the single in-flight fetch. + * + * @param fetchPrivateKeyHex - obtains the hex-encoded data-encryption private key. + * @returns an {@link Encryption} whose `decryptBatch` resolves the key on first call. + */ +export function createEncryption( + fetchPrivateKeyHex: FetchEncryptionPrivateKeyHex, +): Encryption { + let keyPromise: Promise | null = null; + const getKey = (): Promise => { + keyPromise ??= fetchPrivateKeyHex().then(hexToBytes); + return keyPromise; + }; + return { + decryptBatch: async ( + data: readonly [...{ [K in keyof T]: string }], + ) => getEncryption(await getKey()).decryptBatch(data), + }; +} diff --git a/packages/wallet-sdk/src/internal/lib-account-cryptography.ts b/packages/wallet-sdk/src/internal/lib-account-cryptography.ts new file mode 100644 index 000000000..78c3a8f8f --- /dev/null +++ b/packages/wallet-sdk/src/internal/lib-account-cryptography.ts @@ -0,0 +1,15 @@ +/** + * SDK-internal account-cryptography helper — Slice 3 (cashu + spark). + * + * `getSeedPhraseDerivationPath` builds the BIP-85 derivation path the enclave uses to derive + * a per-account-type child mnemonic (cashu / spark). It is a pure, dependency-free string + * helper in `app/features/accounts/account-cryptography.ts`; per the build plan (`lib/*` / + * the small shared crypto helpers are SDK-internal) we re-export the single live source via a + * relative path — same pattern as `./lib-cashu` / `types/money.ts` — so the derivation paths + * stay in lockstep with the app and there is no duplication. The canonical relocation INTO + * the package is a deferred follow-up. + * + * @module + */ + +export { getSeedPhraseDerivationPath } from '../../../../apps/web-wallet/app/features/accounts/account-cryptography'; diff --git a/packages/wallet-sdk/src/internal/lib-cashu-wallet.ts b/packages/wallet-sdk/src/internal/lib-cashu-wallet.ts new file mode 100644 index 000000000..6346c1abc --- /dev/null +++ b/packages/wallet-sdk/src/internal/lib-cashu-wallet.ts @@ -0,0 +1,32 @@ +/** + * SDK-internal cashu **wallet-construction** primitives — Slice 3 (cashu + spark). + * + * Building a cashu account's LIVE handle (`ExtendedCashuWallet`) needs a few more + * `app/lib/cashu` symbols than the Slice-2 balance/url helpers (`./lib-cashu`): the + * extended wallet class itself, its factory, and the extended mint-info wrapper. They live + * in `app/lib/cashu/utils.ts` + `protocol-extensions.ts` and are framework-free + * (`@cashu/cashu-ts` only — no react / @tanstack; verified), so per the build plan (§12, + * `lib/cashu` is SDK-internal) we re-export the single live source via a relative path — + * exactly as `./lib-cashu` / `./lib-scan` / `types/money.ts` do — so there is ONE + * implementation and no web churn. The canonical relocation of `app/lib/cashu/**` INTO the + * package is a deferred follow-up (out of the build-plan's scope). + * + * Split from `./lib-cashu` (the small pure balance/url helpers) on purpose: the wallet + * factory pulls the heavier `@cashu/cashu-ts` `Wallet`/`Mint` surface, which only the + * Slice-3 wallet-init path needs. + * + * @module + */ + +export { + ExtendedCashuWallet, + getCashuProtocolUnit, + getCashuWallet, +} from '../../../../apps/web-wallet/app/lib/cashu/utils'; +export { ExtendedMintInfo } from '../../../../apps/web-wallet/app/lib/cashu/protocol-extensions'; +/** + * The cashu-ts `Proof` zod schema — its `.shape.dleq` / `.shape.witness` sub-validators + * parse the encrypted proof rows' `dleq` / `witness` JSON back to typed cashu-ts values + * (master `account-repository.decryptCashuProofs`). + */ +export { ProofSchema } from '../../../../apps/web-wallet/app/lib/cashu/types'; diff --git a/packages/wallet-sdk/src/internal/lib-ecies.ts b/packages/wallet-sdk/src/internal/lib-ecies.ts new file mode 100644 index 000000000..7eab34374 --- /dev/null +++ b/packages/wallet-sdk/src/internal/lib-ecies.ts @@ -0,0 +1,21 @@ +/** + * SDK-internal ECIES primitives — Slice 3 (cashu + spark). + * + * A cashu account's stored `proofs` are ENCRYPTED on the DB row (`amount` + `secret` + * ciphertext); decrypting them needs the same ECIES (`secp256k1` + ChaCha20-Poly1305 + + * HKDF) implementation master uses in `app/features/shared/encryption.ts`, which lives in + * `app/lib/ecies`. It is leaf + framework-free (`@noble/*` only — no react / @tanstack / + * `window`; verified), so per the build plan (`lib/*` SDK-internal) we re-export the single + * live source via a relative path — exactly as `./lib-cashu` / `types/money.ts` do — so + * there is ONE implementation and no web churn. The canonical relocation of `app/lib/ecies` + * INTO the package is a deferred follow-up (out of the build-plan's scope). + * + * Only the BATCH decrypt is re-exported: that is the single primitive `./encryption`'s + * `decryptBatch` (the proof-decrypt path) needs. The other ECIES functions + * (encrypt/decrypt single, encrypt batch) are not used by Slice 3 and are pulled in by + * later slices that write encrypted rows. + * + * @module + */ + +export { eciesDecryptBatch } from '../../../../apps/web-wallet/app/lib/ecies/ecies'; diff --git a/packages/wallet-sdk/src/internal/open-secret.ts b/packages/wallet-sdk/src/internal/open-secret.ts index abe5fcad3..4e34ab2d3 100644 --- a/packages/wallet-sdk/src/internal/open-secret.ts +++ b/packages/wallet-sdk/src/internal/open-secret.ts @@ -26,6 +26,8 @@ import { convertGuestToUserAccount as osConvertGuestToUserAccount, fetchUser as osFetchUser, generateThirdPartyToken, + getPrivateKey as osGetPrivateKey, + getPrivateKeyBytes as osGetPrivateKeyBytes, handleGoogleCallback as osHandleGoogleCallback, initiateGoogleAuth as osInitiateGoogleAuth, refreshAccessToken as osRefreshAccessToken, @@ -36,7 +38,9 @@ import { signUp as osSignUp, signUpGuest as osSignUpGuest, } from '@agicash/opensecret'; +import { mnemonicToSeedSync } from '@scure/bip39'; import { jwtDecode } from 'jwt-decode'; +import { getSeedPhraseDerivationPath } from './lib-account-cryptography'; import type { StorageAdapter } from '../types/dependencies'; /** @@ -59,6 +63,16 @@ const ACCESS_TOKEN_KEY = 'access_token'; /** localStorage key the OpenSecret SDK persists its refresh token under. */ const REFRESH_TOKEN_KEY = 'refresh_token'; +/** + * Derivation path for the user's data-encryption key (`'enc'` = `10111099` in ASCII). Matches + * master `shared/encryption.ts#encryptionKeyDerivationPath`; used to decrypt cashu proofs. + */ +const ENCRYPTION_KEY_DERIVATION_PATH = `m/10111099'/0'`; +/** BIP-85 path for the cashu wallet's child seed mnemonic (master `getSeedPhraseDerivationPath('cashu', 12)`). */ +const CASHU_SEED_DERIVATION_PATH = getSeedPhraseDerivationPath('cashu', 12); +/** BIP-85 path for the spark wallet's child seed mnemonic (master `getSeedPhraseDerivationPath('spark', 12)`). */ +const SPARK_SEED_DERIVATION_PATH = getSeedPhraseDerivationPath('spark', 12); + /** Init params for the OpenSecret client (from `SdkConfig.openSecret`). */ export type OpenSecretConfig = { /** enclave/auth backend URL (master `VITE_OPEN_SECRET_API_URL`). */ @@ -238,4 +252,53 @@ export class OpenSecretClient { const { user } = await osFetchUser(); return user; } + + // --- per-user secret derivation (Slice 3 — cashu proof decrypt + wallet seeds) ---- + // + // Each derives a deterministic per-user secret from the enclave. The SDK never sees the + // master seed; it asks the enclave to derive a child key/mnemonic at a fixed path. Re-houses + // master's `shared/encryption.ts` + `shared/cashu.ts` + `shared/spark.ts` query options off + // TanStack onto plain calls (the values are stable for the session; the resolver memoises). + + /** + * The hex-encoded data-encryption private key (`getPrivateKeyBytes` at + * {@link ENCRYPTION_KEY_DERIVATION_PATH}). The `internal/encryption` layer `hexToBytes`- + * decodes it. Re-houses master `encryptionPrivateKeyQueryOptions`. + * + * @returns the hex-encoded private key. + */ + async getEncryptionPrivateKeyHex(): Promise { + const { private_key } = await osGetPrivateKeyBytes({ + private_key_derivation_path: ENCRYPTION_KEY_DERIVATION_PATH, + }); + return private_key; + } + + /** + * The cashu wallet's BIP39 seed bytes: derive the child mnemonic at + * {@link CASHU_SEED_DERIVATION_PATH} then `mnemonicToSeedSync`. Re-houses master + * `shared/cashu.ts#seedQueryOptions`. + * + * @returns the 64-byte BIP39 seed. + */ + async getCashuWalletSeed(): Promise { + const { mnemonic } = await osGetPrivateKey({ + seed_phrase_derivation_path: CASHU_SEED_DERIVATION_PATH, + }); + return mnemonicToSeedSync(mnemonic); + } + + /** + * The spark wallet's seed mnemonic (the child mnemonic at + * {@link SPARK_SEED_DERIVATION_PATH}). Re-houses master + * `shared/spark.ts#sparkMnemonicQueryOptions`. + * + * @returns the BIP39 mnemonic phrase. + */ + async getSparkWalletMnemonic(): Promise { + const { mnemonic } = await osGetPrivateKey({ + seed_phrase_derivation_path: SPARK_SEED_DERIVATION_PATH, + }); + return mnemonic; + } } diff --git a/packages/wallet-sdk/src/internal/spark-wallet.test.ts b/packages/wallet-sdk/src/internal/spark-wallet.test.ts new file mode 100644 index 000000000..8295f7735 --- /dev/null +++ b/packages/wallet-sdk/src/internal/spark-wallet.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'bun:test'; +import { + type BreezRuntime, + SparkWalletCache, + createSparkWalletStub, + getInitializedSparkWallet, +} from './spark-wallet'; + +const MNEMONIC = 'test test test test test test test test test test test junk'; + +/** + * A mock Breez runtime: `connect` returns a fake wallet whose `getInfo` yields a fixed + * balance, and counts connections. The native WASM module is NEVER imported — the resolver + * always injects this in tests. + */ +function makeRuntime( + opts: { balanceSats?: number; failConnect?: boolean } = {}, +): { runtime: BreezRuntime; connects: () => number } { + let connects = 0; + const runtime = { + defaultConfig: (_network: 'mainnet' | 'regtest') => + ({ network: 'regtest' }) as never, + initLogging: async () => { + /* no-op logger */ + }, + connect: async (_req: never) => { + connects++; + if (opts.failConnect) { + throw new Error('connect failed'); + } + return { + getInfo: async (_r: unknown) => ({ + balanceSats: opts.balanceSats ?? 1234, + }), + } as never; + }, + } as unknown as BreezRuntime; + return { runtime, connects: () => connects }; +} + +describe('createSparkWalletStub', () => { + test('throws the given reason on any method call', () => { + const stub = createSparkWalletStub('offline') as unknown as { + getInfo: () => unknown; + }; + expect(() => stub.getInfo()).toThrow('offline'); + }); +}); + +describe('getInitializedSparkWallet', () => { + test('connects, reads balance, reports online', async () => { + const { runtime } = makeRuntime({ balanceSats: 5000 }); + const cache = new SparkWalletCache(); + + const { balance, isOnline } = await getInitializedSparkWallet({ + cache, + mnemonic: MNEMONIC, + network: 'REGTEST', + storageDir: '/tmp/spark-test', + apiKey: 'k', + runtime, + }); + + expect(isOnline).toBe(true); + expect(balance?.toNumber('sat')).toBe(5000); + }); + + test('memoises the connection per (mnemonic, network)', async () => { + const { runtime, connects } = makeRuntime(); + const cache = new SparkWalletCache(); + const params = { + cache, + mnemonic: MNEMONIC, + network: 'REGTEST' as const, + storageDir: '/tmp/spark-test', + apiKey: 'k', + runtime, + }; + + await getInitializedSparkWallet(params); + await getInitializedSparkWallet(params); + + expect(connects()).toBe(1); + }); + + test('on connect failure: returns a throwing stub, offline, null balance', async () => { + const { runtime } = makeRuntime({ failConnect: true }); + const cache = new SparkWalletCache(); + + const { wallet, balance, isOnline } = await getInitializedSparkWallet({ + cache, + mnemonic: MNEMONIC, + network: 'MAINNET', + storageDir: '/tmp/spark-test', + apiKey: 'k', + runtime, + }); + + expect(isOnline).toBe(false); + expect(balance).toBeNull(); + expect(() => + (wallet as unknown as { getInfo: () => unknown }).getInfo(), + ).toThrow(); + }); + + test('a failed connect is not memoised (next call retries)', async () => { + let mode: 'fail' | 'ok' = 'fail'; + let connects = 0; + const runtime = { + defaultConfig: () => ({}) as never, + initLogging: async () => { + /* no-op logger */ + }, + connect: async () => { + connects++; + if (mode === 'fail') { + throw new Error('down'); + } + return { getInfo: async () => ({ balanceSats: 1 }) } as never; + }, + } as unknown as BreezRuntime; + const cache = new SparkWalletCache(); + const params = { + cache, + mnemonic: MNEMONIC, + network: 'REGTEST' as const, + storageDir: '/tmp/spark-test', + apiKey: 'k', + runtime, + }; + + const first = await getInitializedSparkWallet(params); + expect(first.isOnline).toBe(false); + mode = 'ok'; + const second = await getInitializedSparkWallet(params); + expect(second.isOnline).toBe(true); + expect(connects).toBe(2); + }); +}); diff --git a/packages/wallet-sdk/src/internal/spark-wallet.ts b/packages/wallet-sdk/src/internal/spark-wallet.ts new file mode 100644 index 000000000..027cf4a24 --- /dev/null +++ b/packages/wallet-sdk/src/internal/spark-wallet.ts @@ -0,0 +1,245 @@ +/** + * Internal spark (Breez) wallet initialiser — Slice 3 (cashu + spark). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/shared/spark.ts#getInitializedSparkWallet` (+ + * `sparkWalletQueryOptions` / `tryInitLogging`) and the stub from + * `app/lib/spark/utils.ts#createSparkWalletStub`. Builds a spark account's LIVE handle: a + * `BreezSdk` connection (from the account's seed mnemonic + network) and its balance (from + * `getInfo().balanceSats`). If Breez is unreachable / errors it returns a throwing stub with + * `isOnline: false` + `null` balance, exactly as master. + * + * **`@agicash/breez-sdk-spark` is a native/WASM package** (loads WASM + auto-enables Node + * storage at module-eval, `engines.node >= 22`). To keep it OUT of the unit-test path — and + * off any code path that does not actually open a spark wallet — its runtime API + * (`connect` / `defaultConfig` / `initLogging`) is loaded via a **dynamic `import()`** inside + * {@link getInitializedSparkWallet}; the `BreezSdk` / `SdkEvent` TYPES are imported + * type-only (erased, no WASM). Tests mock by passing a `connect` injection — the real module + * is never imported. CI never needs Breez credentials or network: a spark wallet is only + * constructed when an account is actually resolved with a real `breezApiKey`. + * + * Re-housing vs master: + * - The `QueryClient` memo (`sparkWalletQueryOptions`, `staleTime: Infinity`) → a + * framework-free per-(mnemonic,network) memo held by the resolver, so an account's wallet + * is connected once. `measureOperation` telemetry is dropped (§3). + * - `apiKey` (master `import.meta.env.VITE_BREEZ_API_KEY`) comes from `SdkConfig.breezApiKey`. + * - The `getFeatureFlag('DEBUG_LOGGING_SPARK')` debug gate is dropped (no feature-flag + * subsystem in the SDK); logging is initialised quietly, once. + * + * @module + */ +import type { BreezSdk } from '@agicash/breez-sdk-spark'; +import { computeSHA256 } from './crypto'; +import { Money } from '../types/money'; +import type { SparkNetwork } from '../types/dependencies'; + +/** + * The subset of `@agicash/breez-sdk-spark`'s runtime API this module uses, typed from the + * real package (type-only import — erased, no WASM load) so the dynamically-loaded native + * module and any test mock are checked against the genuine `connect` / `defaultConfig` / + * `initLogging` signatures. + */ +export type BreezRuntime = Pick< + typeof import('@agicash/breez-sdk-spark'), + 'connect' | 'defaultConfig' | 'initLogging' +>; + +/** + * Lazily load the native Breez runtime exactly once. Kept behind a dynamic `import()` so the + * WASM module is only pulled when a spark wallet is actually connected (never in unit tests, + * which inject a mock {@link BreezRuntime}). + */ +let breezRuntimePromise: Promise | null = null; +function loadBreezRuntime(): Promise { + breezRuntimePromise ??= import('@agicash/breez-sdk-spark').then((m) => ({ + connect: m.connect, + defaultConfig: m.defaultConfig, + initLogging: m.initLogging, + })); + return breezRuntimePromise; +} + +// Breez's initLogging delegates to Rust's tracing crate (a single global subscriber per +// process — a second call always errors). Track status so we attempt it at most once, +// regardless of outcome. Mirrors master's `loggingStatus` guard. +let loggingStatus: 'initializing' | 'initialized' | 'failed' | undefined; +function tryInitLogging(runtime: BreezRuntime): void { + if (loggingStatus !== undefined || !runtime.initLogging) { + return; + } + loggingStatus = 'initializing'; + runtime + .initLogging({ + log() { + // Discard Breez log lines: the SDK has no feature-flag debug gate (master's was + // `getFeatureFlag('DEBUG_LOGGING_SPARK')`); this no-op logger just satisfies the API. + }, + }) + .then(() => { + loggingStatus = 'initialized'; + }) + .catch((error) => { + loggingStatus = 'failed'; + console.warn('Failed to initialize Breez SDK logging', error); + }); +} + +/** + * A throwing stand-in for a spark wallet that could not be connected (offline / error). + * Every method call throws `reason`. Ported from `lib/spark/utils.ts#createSparkWalletStub`, + * re-implemented inline so the stub carries NO dependency on the native Breez package. + * + * @param reason - the message every stubbed method throws. + * @returns a `BreezSdk`-typed proxy whose every method throws. + */ +export function createSparkWalletStub(reason: string): BreezSdk { + return new Proxy({} as BreezSdk, { + get(_target, prop) { + if (typeof prop === 'string') { + return () => { + console.error(`Cannot call ${prop} on Spark wallet stub`); + throw new Error(reason); + }; + } + return undefined; + }, + }); +} + +type ConnectedWallet = { wallet: BreezSdk; balance: Money }; + +type SparkCacheEntry = { + network: SparkNetwork; + value: Promise; +}; + +/** + * Per-(mnemonic, network) memo of connected spark wallets — the framework-free stand-in for + * master's `sparkWalletQueryOptions` (`staleTime/gcTime: Infinity`). The cache key hashes the + * mnemonic (never stores it in plaintext). A connect that rejects is not cached (the next + * call retries → a transient outage does not pin an offline result). Held by the resolver; + * dropped on `Sdk.destroy()`. + */ +export class SparkWalletCache { + private readonly entries = new Map(); + + /** Connect (or return memoised) the wallet+balance for `(mnemonic, network)`. */ + async getConnected(params: { + runtime: BreezRuntime; + mnemonic: string; + network: SparkNetwork; + storageDir: string; + apiKey: string; + }): Promise { + const key = `${await computeSHA256(params.mnemonic)}:${params.network}:${params.storageDir}`; + const existing = this.entries.get(key); + if (existing) { + return existing.value; + } + const value = this.connect(params); + this.entries.set(key, { network: params.network, value }); + value.catch(() => { + if (this.entries.get(key)?.value === value) { + this.entries.delete(key); + } + }); + return value; + } + + /** Drop all memoised wallets (called when the resolver / SDK is torn down). */ + clear(): void { + this.entries.clear(); + } + + private async connect({ + runtime, + mnemonic, + network, + storageDir, + apiKey, + }: { + runtime: BreezRuntime; + mnemonic: string; + network: SparkNetwork; + storageDir: string; + apiKey: string; + }): Promise { + const breezNetwork = network.toLowerCase() as 'mainnet' | 'regtest'; + tryInitLogging(runtime); + + const wallet = await runtime.connect({ + config: { + ...runtime.defaultConfig(breezNetwork), + apiKey, + // Disable Breez's built-in lightning-address recovery — agicash uses its own + // ln-address system (master sets `lnurlDomain: undefined`). + lnurlDomain: undefined, + privateEnabledDefault: true, + optimizationConfig: { autoEnabled: true, multiplicity: 2 }, + }, + seed: { type: 'mnemonic', mnemonic }, + storageDir, + }); + const info = await wallet.getInfo({}); + // `as Money` (the unparameterised form) matches master `shared/spark.ts`; a `Money<'BTC'>` + // is not assignable to the `Money` the resolver returns. + const balance = new Money({ + amount: info.balanceSats, + currency: 'BTC', + unit: 'sat', + }) as Money; + return { wallet, balance }; + } +} + +/** + * Initialise a spark wallet with offline handling, mirroring master + * `getInitializedSparkWallet`. Connects (memoised) a `BreezSdk` for the account's mnemonic + + * network and reads its balance; on any failure returns a throwing stub with `isOnline: + * false` + `null` balance. + * + * @param params.cache - the per-(mnemonic,network) wallet memo (held by the resolver). + * @param params.mnemonic - the account's seed mnemonic. + * @param params.network - the spark network. + * @param params.storageDir - the Breez storage directory. + * @param params.apiKey - the Breez API key (`SdkConfig.breezApiKey`). + * @param params.runtime - optional Breez-runtime injection (tests pass a mock; defaults to + * the dynamically-imported native module). + * @returns the live wallet (or stub), balance, and whether spark was reachable. + */ +export async function getInitializedSparkWallet({ + cache, + mnemonic, + network, + storageDir, + apiKey, + runtime, +}: { + cache: SparkWalletCache; + mnemonic: string; + network: SparkNetwork; + storageDir: string; + apiKey: string; + runtime?: BreezRuntime; +}): Promise<{ wallet: BreezSdk; balance: Money | null; isOnline: boolean }> { + try { + const resolvedRuntime = runtime ?? (await loadBreezRuntime()); + const { wallet, balance } = await cache.getConnected({ + runtime: resolvedRuntime, + mnemonic, + network, + storageDir, + apiKey, + }); + return { wallet, balance, isOnline: true }; + } catch (error) { + console.error('Failed to initialize spark wallet', { cause: error }); + return { + wallet: createSparkWalletStub( + 'Spark is offline, please try again later.', + ), + balance: null, + isOnline: false, + }; + } +} diff --git a/packages/wallet-sdk/src/sdk.ts b/packages/wallet-sdk/src/sdk.ts index 1b86241f7..06472dce1 100644 --- a/packages/wallet-sdk/src/sdk.ts +++ b/packages/wallet-sdk/src/sdk.ts @@ -35,8 +35,11 @@ import { AccountsDomainImpl } from './domains/accounts'; import { AuthDomainImpl } from './domains/auth'; import { ScanDomainImpl } from './domains/scan'; import { UserDomainImpl } from './domains/user'; -import { DeferredAccountHandleResolver } from './internal/account-handle-resolver'; +import { LiveAccountHandleResolver } from './internal/account-handle-resolver'; import { AccountRepository } from './internal/account-repository'; +import { MintMetadataCache } from './internal/cashu-wallet'; +import { createEncryption } from './internal/encryption'; +import { SparkWalletCache } from './internal/spark-wallet'; import { TypedEventEmitter } from './internal/event-emitter'; import { GuestAccountStorage } from './internal/guest-account-storage'; import { OpenSecretClient } from './internal/open-secret'; @@ -74,6 +77,10 @@ export type SdkConnections = { readonly events: TypedEventEmitter; /** leader-election instance id (provided or auto-generated). */ readonly clientId: string; + /** Per-mint protocol-metadata memo (held so `destroy()` can drop it). */ + readonly mintCache: MintMetadataCache; + /** Per-(mnemonic,network) connected-spark-wallet memo (held so `destroy()` can drop it). */ + readonly sparkCache: SparkWalletCache; }; /** Validate `config` (shape the rest of `create` relies on). Throws on a missing field. */ @@ -227,6 +234,11 @@ export class Sdk { const events = new TypedEventEmitter(); + // Live-handle memos (the cashu per-mint protocol metadata + the spark per-wallet + // connection). Held on the connection bundle so `destroy()` can drop them. + const mintCache = new MintMetadataCache(); + const sparkCache = new SparkWalletCache(); + const connections: SdkConnections = { supabase, openSecret, @@ -234,6 +246,8 @@ export class Sdk { storage: config.storage, events, clientId: config.clientId ?? generateClientId(), + mintCache, + sparkCache, }; // --- Slice 1: auth + user domains ---------------------------------------- @@ -254,10 +268,24 @@ export class Sdk { // --- Slice 2: accounts + scan domains ------------------------------------ // The account repository reads/writes the `wallet.accounts` table and maps rows to the // domain `Account`. An account's LIVE wallet handle + decrypted cashu proofs / spark - // balance are filled in by the handle resolver, which is DEFERRED to Slice 3 (the heavy - // mint/Breez init + `shared/encryption` decryption); here it is the deferral stub that - // yields the DB fields with a lazy live-handle (see `account-handle-resolver.ts`). - const accountHandleResolver = new DeferredAccountHandleResolver(); + // balance are filled in by the handle resolver — the REAL one (Slice 3): it initialises + // the cashu mint wallet (1 h-memo'd keyset/keys fetch + 10 s timeout), decrypts the + // proofs (OpenSecret-derived key + ECIES), and connects the spark Breez wallet (when a + // `breezApiKey` is configured). The per-mint + per-spark memos (built above, on the + // connection bundle) live as long as the SDK and are dropped in `destroy()`. + const encryption = createEncryption(() => + openSecret.getEncryptionPrivateKeyHex(), + ); + const accountHandleResolver = new LiveAccountHandleResolver({ + encryption, + getCashuWalletSeed: () => openSecret.getCashuWalletSeed(), + mintCache, + getSparkWalletMnemonic: () => openSecret.getSparkWalletMnemonic(), + sparkCache, + breezApiKey: config.breezApiKey, + // Master's `account-repository` default; the Breez SDK persists per-wallet state here. + sparkStorageDir: './.spark-data', + }); const accountRepository = new AccountRepository( supabase, accountHandleResolver, @@ -286,9 +314,13 @@ export class Sdk { await this.connections.supabase.removeAllChannels(); // Drop the cached access token. this.connections.sessionToken.clear(); + // Drop the live-handle memos (cashu mint metadata + connected spark wallets). + this.connections.mintCache.clear(); + this.connections.sparkCache.clear(); // Remove every event subscriber. this.connections.events.removeAllListeners(); - // TODO(Slice 3/5): close mint melt/mint-quote WS subs + Breez SDK instances + halt - // the leader-elected processor + clear its timers (wired into this seam when opened). + // TODO(Slice 3/5): close mint melt/mint-quote WS subs + Breez SDK instances (call + // `disconnect()` on connected wallets) + halt the leader-elected processor + clear its + // timers (wired into this seam when those connections are opened). } } diff --git a/packages/wallet-sdk/src/types/dependencies.ts b/packages/wallet-sdk/src/types/dependencies.ts index a35ebd146..2daa68af7 100644 --- a/packages/wallet-sdk/src/types/dependencies.ts +++ b/packages/wallet-sdk/src/types/dependencies.ts @@ -9,27 +9,33 @@ */ // --------------------------------------------------------------------------- -// External package types (import once the deps are added to the package) +// External package types (RESOLVED in Slice 3 — the live wallet handles) // --------------------------------------------------------------------------- +import type { BreezSdk as BreezSdkType } from '@agicash/breez-sdk-spark'; +import type { Proof } from '@cashu/cashu-ts'; +// The live `ExtendedCashuWallet` is the SDK-internal cashu-ts wallet subclass; re-exported +// from `app/lib/cashu/utils` (SDK-internal, §12) — same single-source re-export as +// `internal/lib-cashu-wallet.ts`. Importing the TYPE here keeps `Account.wallet` correctly +// typed without `types/` depending on `internal/`. +import type { ExtendedCashuWallet as ExtendedCashuWalletClass } from '../../../../apps/web-wallet/app/lib/cashu/utils'; + /** - * Live Breez/Spark SDK instance held on a spark `Account`. - * TODO(Slice-0): `import type { BreezSdk } from '@agicash/breez-sdk-spark'` - * (add `@agicash/breez-sdk-spark` to the package deps + root catalog first). + * Live Breez/Spark SDK instance held on a spark `Account`. Resolved (Slice 3) to the real + * `BreezSdk` from `@agicash/breez-sdk-spark` (a native/WASM package — only the TYPE is + * imported here; the runtime is dynamically loaded by `internal/spark-wallet.ts`). */ -export type BreezSdk = unknown; +export type BreezSdk = BreezSdkType; /** - * Live cashu wallet handle (mint info / keysets / keys / seed) held on a cashu - * `Account` — the per-mint protocol-metadata memo (§0 state kind 2). - * TODO(Slice-3): import the real `ExtendedCashuWallet` from the SDK-internal - * `lib/cashu` (extracted from `app/lib/cashu`). + * Live cashu wallet handle (mint info / keysets / keys / seed) held on a cashu `Account` — + * the per-mint protocol-metadata memo (§0 state kind 2). Resolved (Slice 3) to the real + * `ExtendedCashuWallet` (cashu-ts `Wallet` subclass) from the SDK-internal `lib/cashu`. */ -export type ExtendedCashuWallet = unknown; +export type ExtendedCashuWallet = ExtendedCashuWalletClass; /** - * Spark network discriminant. - * TODO(Slice-2): lift `SparkNetwork` verbatim from + * Spark network discriminant. Lifted verbatim from * `app/features/agicash-db/json-models/spark-account-details-db-data.ts`. */ export type SparkNetwork = 'MAINNET' | 'REGTEST'; @@ -37,19 +43,17 @@ export type SparkNetwork = 'MAINNET' | 'REGTEST'; /** * A raw cashu-ts protocol `Proof` (distinct from the domain `CashuProof`). * Carried by `CashuTokenMeltData.tokenProofs` (master: `z.array(ProofSchema)`). - * TODO(Slice-2/3): `import type { Proof } from '@cashu/cashu-ts'` (alias here as - * `CashuProtocolProof`); shape = `app/lib/cashu/types.ts#ProofSchema`. + * Resolved (Slice 3) to cashu-ts `Proof`. */ -export type CashuProtocolProof = unknown; +export type CashuProtocolProof = Proof; /** - * The `dleq` / `witness` sub-fields of a cashu-ts `Proof`, referenced by - * `CashuProof`. - * TODO(Slice-2/3): `import type { Proof } from '@cashu/cashu-ts'` and use - * `Proof['dleq']` / `Proof['witness']` (matches `app/lib/cashu/types.ts#ProofSchema`). + * The `dleq` / `witness` sub-fields of a cashu-ts `Proof`, referenced by `CashuProof`. + * Resolved (Slice 3) to `Proof['dleq']` / `Proof['witness']` (matches + * `app/lib/cashu/types.ts#ProofSchema`). */ -export type ProofDleq = unknown; -export type ProofWitness = unknown; +export type ProofDleq = Proof['dleq']; +export type ProofWitness = Proof['witness']; // --------------------------------------------------------------------------- // Utility types