From 2135bf68d62c3349c636dc4a952f500f80837607 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Wed, 3 Jun 2026 15:05:55 -0700 Subject: [PATCH] wallet-sdk PR4: accounts + scan Implements AccountsDomain + ScanDomain (Slice 2 of the no-cache contract), replacing PR2's NotImplemented stubs and wiring them into Sdk.create. AccountsDomain (list/get/getDefault/add/setDefault/getBalance/suggestFor): - account-repository over the SDK-owned Supabase client (re-housed from account-repository.ts + account-service.ts + account-hooks.ts, framework-free) - db-account row types + dbAccountToAccount mapper (lifted from agicash-db/database.ts + json-models, hand-written rows as in db-user.ts) - setDefault writes the per-currency default-account column on wallet.users - getBalance = the verbatim pure getAccountBalance (cashu proof sum / spark balance) - suggestFor: NET-NEW pure logic generalizing findMatchingOfferOrGiftCardAccount + online filter + default fallback; cheap-first, no cross-protocol cost comparison ScanDomain (parse): re-houses master classifyInput (kind only: bolt11 | ln-address | cashu-token); bolt11/cashu/lnurl decode libs are SDK-internal; unrecognised input throws DomainError (contract types parse as non-null). WALLET HANDLE DEFERRED to Slice 3 per the build plan: an account's live `wallet` handle (ExtendedCashuWallet/BreezSdk) + decrypted cashu proofs are entangled in master's toAccount with the heavy networked mint/Breez init + the shared/encryption subsystem, all assigned to Slice 3. An AccountHandleResolver seam is injected into the repository; the Slice-2 DeferredAccountHandleResolver maps the DB fields and leaves the live handle a lazy throwing stub. PR1 placeholders (ExtendedCashuWallet/BreezSdk/ SparkNetwork) are KEPT. Tests: scan parse cases, suggestFor branches (online/currency/balance/ranking/ fallback/receive), account-repository (get/getAllActive/add error paths) with a fake Supabase client, db-account mapper + guards + deferred-handle stub, account-balance. +74 tests (64 -> 138); full CI gate green (both packages test, tsc, biome lint+format). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wallet-sdk/src/domains/accounts.test.ts | 281 ++++++++++++++++++ packages/wallet-sdk/src/domains/accounts.ts | 199 +++++++++++++ packages/wallet-sdk/src/domains/scan.test.ts | 171 +++++++++++ packages/wallet-sdk/src/domains/scan.ts | 98 ++++++ .../src/internal/account-balance.test.ts | 52 ++++ .../src/internal/account-balance.ts | 41 +++ .../src/internal/account-handle-resolver.ts | 162 ++++++++++ .../src/internal/account-repository.test.ts | 241 +++++++++++++++ .../src/internal/account-repository.ts | 178 +++++++++++ .../src/internal/db-account.test.ts | 144 +++++++++ .../wallet-sdk/src/internal/db-account.ts | 268 +++++++++++++++++ packages/wallet-sdk/src/internal/lib-cashu.ts | 27 ++ packages/wallet-sdk/src/internal/lib-scan.ts | 30 ++ .../wallet-sdk/src/internal/stub-domains.ts | 24 +- .../src/internal/suggest-account.test.ts | 257 ++++++++++++++++ .../src/internal/suggest-account.ts | 209 +++++++++++++ .../src/internal/user-repository.ts | 47 ++- packages/wallet-sdk/src/sdk.ts | 46 ++- 18 files changed, 2441 insertions(+), 34 deletions(-) create mode 100644 packages/wallet-sdk/src/domains/accounts.test.ts create mode 100644 packages/wallet-sdk/src/domains/accounts.ts create mode 100644 packages/wallet-sdk/src/domains/scan.test.ts create mode 100644 packages/wallet-sdk/src/domains/scan.ts create mode 100644 packages/wallet-sdk/src/internal/account-balance.test.ts create mode 100644 packages/wallet-sdk/src/internal/account-balance.ts create mode 100644 packages/wallet-sdk/src/internal/account-handle-resolver.ts create mode 100644 packages/wallet-sdk/src/internal/account-repository.test.ts create mode 100644 packages/wallet-sdk/src/internal/account-repository.ts create mode 100644 packages/wallet-sdk/src/internal/db-account.test.ts create mode 100644 packages/wallet-sdk/src/internal/db-account.ts create mode 100644 packages/wallet-sdk/src/internal/lib-cashu.ts create mode 100644 packages/wallet-sdk/src/internal/lib-scan.ts create mode 100644 packages/wallet-sdk/src/internal/suggest-account.test.ts create mode 100644 packages/wallet-sdk/src/internal/suggest-account.ts diff --git a/packages/wallet-sdk/src/domains/accounts.test.ts b/packages/wallet-sdk/src/domains/accounts.test.ts new file mode 100644 index 000000000..ba6ca0c06 --- /dev/null +++ b/packages/wallet-sdk/src/domains/accounts.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, mock, test } from 'bun:test'; +import type { AccountRepository } from '../internal/account-repository'; +import type { SessionResolver } from '../internal/session'; +import type { UserRepository } from '../internal/user-repository'; +import type { Account, CashuAccount } from '../types/account'; +import { type Currency, Money } from '../types/money'; +import type { PaymentIntent } from '../types/scan'; +import type { User } from '../types/user'; +import { AccountsDomainImpl } from './accounts'; + +// -- Fakes ------------------------------------------------------------------- + +const baseUser: User = { + id: 'user-1', + username: 'alice', + isGuest: false, + email: 'alice@example.com', + emailVerified: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + defaultBtcAccountId: 'btc-default', + defaultUsdAccountId: 'usd-default', + defaultCurrency: 'BTC', + cashuLockingXpub: 'xpub', + encryptionPublicKey: 'enc', + sparkIdentityPublicKey: 'spark', + termsAcceptedAt: null, + giftCardMintTermsAcceptedAt: null, +}; + +function cashuAccount(opts: { + id: string; + sats?: number; + currency?: 'BTC' | 'USD'; + isOnline?: boolean; +}): CashuAccount { + return { + id: opts.id, + name: opts.id, + type: 'cashu', + purpose: 'transactional', + state: 'active', + isOnline: opts.isOnline ?? true, + currency: opts.currency ?? 'BTC', + createdAt: '2026-01-01T00:00:00.000Z', + version: 1, + expiresAt: null, + mintUrl: `https://${opts.id}.example.com`, + isTestMint: false, + keysetCounters: {}, + proofs: + opts.sats && opts.sats > 0 + ? ([{ amount: opts.sats }] as unknown as CashuAccount['proofs']) + : [], + wallet: {} as never, + }; +} + +/** Build the domain over fakes. Each repo/session method is a `mock` so calls are assertable. */ +function buildDomain(overrides?: { + user?: User | null; + accounts?: Account[]; + getById?: (id: string) => Account | null; +}) { + const user = overrides?.user === undefined ? baseUser : overrides.user; + const accounts = overrides?.accounts ?? []; + + const getAllActive = mock(async () => accounts); + const getById = mock(async (id: string) => + overrides?.getById + ? overrides.getById(id) + : (accounts.find((a) => a.id === id) ?? null), + ); + const add = mock(async (_userId: string, _config: unknown) => + cashuAccount({ id: 'new-account' }), + ); + const accountRepo = { + getAllActive, + get: getById, + add, + } as unknown as AccountRepository; + + const setDefaultAccount = mock(async () => baseUser); + const userRepo = { setDefaultAccount } as unknown as UserRepository; + + const session = { + requireCurrentUser: async () => { + if (!user) throw new Error('No authenticated user'); + return user; + }, + getCurrentUser: async () => user, + } as unknown as SessionResolver; + + const domain = new AccountsDomainImpl(accountRepo, userRepo, session); + return { domain, getAllActive, getById, add, setDefaultAccount }; +} + +describe('AccountsDomainImpl.list', () => { + test('returns active accounts sorted oldest-first', async () => { + const a = cashuAccount({ id: 'a' }); + a.createdAt = '2026-03-01T00:00:00.000Z'; + const b = cashuAccount({ id: 'b' }); + b.createdAt = '2026-01-01T00:00:00.000Z'; + const { domain } = buildDomain({ accounts: [a, b] }); + + const result = await domain.list(); + expect(result.map((x) => x.id)).toEqual(['b', 'a']); + }); +}); + +describe('AccountsDomainImpl.get', () => { + test('delegates to the repository', async () => { + const a = cashuAccount({ id: 'a' }); + const { domain, getById } = buildDomain({ accounts: [a] }); + + expect((await domain.get('a'))?.id).toBe('a'); + expect(getById).toHaveBeenCalledWith('a'); + expect(await domain.get('missing')).toBeNull(); + }); +}); + +describe('AccountsDomainImpl.getDefault', () => { + test('reads the BTC default by default currency', async () => { + const def = cashuAccount({ id: 'btc-default' }); + const { domain } = buildDomain({ + accounts: [def], + getById: (id) => (id === 'btc-default' ? def : null), + }); + + const result = await domain.getDefault(); + expect(result?.id).toBe('btc-default'); + }); + + test('reads the USD default when currency=USD', async () => { + const def = cashuAccount({ id: 'usd-default', currency: 'USD' }); + const { domain, getById } = buildDomain({ + getById: (id) => (id === 'usd-default' ? def : null), + }); + + const result = await domain.getDefault({ currency: 'USD' }); + expect(result?.id).toBe('usd-default'); + expect(getById).toHaveBeenCalledWith('usd-default'); + }); + + test('returns null when no default id is set for the currency', async () => { + const userNoUsd: User = { ...baseUser, defaultUsdAccountId: null }; + const { domain } = buildDomain({ user: userNoUsd }); + + expect(await domain.getDefault({ currency: 'USD' })).toBeNull(); + }); +}); + +describe('AccountsDomainImpl.add', () => { + test('resolves the user id and delegates to the repository', async () => { + const { domain, add } = buildDomain(); + + const account = await domain.add({ + type: 'cashu', + mintUrl: 'https://m.example.com', + currency: 'BTC', + }); + expect(account.id).toBe('new-account'); + expect(add).toHaveBeenCalledWith('user-1', { + type: 'cashu', + mintUrl: 'https://m.example.com', + currency: 'BTC', + }); + }); +}); + +describe('AccountsDomainImpl.setDefault', () => { + test('sets the BTC default, leaving the USD default untouched', async () => { + const account = cashuAccount({ id: 'new-btc', currency: 'BTC' }); + const { domain, setDefaultAccount } = buildDomain(); + + await domain.setDefault(account); + expect(setDefaultAccount).toHaveBeenCalledWith('user-1', { + defaultBtcAccountId: 'new-btc', + defaultUsdAccountId: 'usd-default', + defaultCurrency: 'BTC', + }); + }); + + test('sets the USD default, leaving the BTC default untouched', async () => { + const account = cashuAccount({ id: 'new-usd', currency: 'USD' }); + const { domain, setDefaultAccount } = buildDomain(); + + await domain.setDefault(account); + expect(setDefaultAccount).toHaveBeenCalledWith('user-1', { + defaultBtcAccountId: 'btc-default', + defaultUsdAccountId: 'new-usd', + defaultCurrency: 'BTC', + }); + }); + + test('rejects an unsupported currency', async () => { + const account = { + ...cashuAccount({ id: 'x' }), + currency: 'EUR', + } as unknown as Account; + const { domain } = buildDomain(); + + await expect(domain.setDefault(account)).rejects.toThrow( + 'Unsupported currency', + ); + }); +}); + +describe('AccountsDomainImpl.getBalance', () => { + test('returns the cashu proof sum as Money', async () => { + const { domain } = buildDomain(); + const account = cashuAccount({ id: 'a', sats: 4200 }); + + const balance = await domain.getBalance(account); + expect(balance.toNumber('sat')).toBe(4200); + }); + + test('returns Money.zero for a spark account with a null balance', async () => { + const { domain } = buildDomain(); + const spark = { + type: 'spark', + currency: 'BTC', + balance: null, + } as unknown as Account; + + const balance = await domain.getBalance(spark); + expect(balance.toNumber('sat')).toBe(0); + expect(balance.currency).toBe('BTC'); + }); +}); + +describe('AccountsDomainImpl.suggestFor', () => { + test('recommends the funded online account for a token-receive (pure)', async () => { + const a = cashuAccount({ id: 'a', sats: 0 }); + const { domain } = buildDomain(); + const intent: PaymentIntent = { kind: 'token-receive', token: 'cashuAx' }; + + const result = await domain.suggestFor(intent, [a]); + expect(result.recommended.id).toBe('a'); + }); + + test('falls back to the user default account id when none has enough balance', async () => { + const a = cashuAccount({ id: 'a', sats: 10 }); + const def = cashuAccount({ id: 'btc-default', sats: 20 }); + const { domain } = buildDomain(); + const intent: PaymentIntent = { + kind: 'send', + destination: { + kind: 'bolt11', + invoice: { + amountSat: 5000, + amountMsat: 5000000, + createdAtUnixMs: 0, + expiryUnixMs: 0, + network: 'bitcoin', + description: undefined, + payeeNodeKey: '00'.repeat(33), + paymentHash: 'hash', + }, + }, + amount: new Money({ + amount: 5000, + currency: 'BTC', + unit: 'sat', + }), + }; + + const result = await domain.suggestFor(intent, [a, def]); + // 'btc-default' is the user's default account id → chosen as fallback. + expect(result.recommended.id).toBe('btc-default'); + }); + + test('works without a session (default id undefined)', async () => { + const a = cashuAccount({ id: 'a', sats: 1000 }); + const { domain } = buildDomain({ user: null }); + const intent: PaymentIntent = { kind: 'receive' }; + + const result = await domain.suggestFor(intent, [a]); + expect(result.recommended.id).toBe('a'); + }); +}); diff --git a/packages/wallet-sdk/src/domains/accounts.ts b/packages/wallet-sdk/src/domains/accounts.ts new file mode 100644 index 000000000..4c20c73c8 --- /dev/null +++ b/packages/wallet-sdk/src/domains/accounts.ts @@ -0,0 +1,199 @@ +/** + * `AccountsDomain` implementation — §2 of the contract, Slice 2. + * + * EXTRACTED (re-housed framework-free) from `apps/web-wallet/app/features/accounts/*` + * (`account-repository.ts` / `account-service.ts` / `account-hooks.ts`) + + * `app/features/user/user-service.ts` (the default-account write). Master expresses these as + * React hooks over a TanStack `AccountsCache`; the SDK exposes them as plain async methods + * over the SDK-owned Supabase client (no cache — events drive the consumer's read-model). + * + * Two-mode API rule (Josip 6/01): `list`/`get`/`getDefault` are FETCHES; `add` CREATES; + * `setDefault`/`getBalance` take the FULL account object; `suggestFor` is PURE over the + * passed-in accounts. + * + * Slice boundary (build plan): an account's LIVE `wallet` handle + decrypted cashu `proofs` + * are resolved through the injected handle resolver, which is DEFERRED to Slice 3 (the heavy + * mint/Breez init + the `shared/encryption` proof decryption). This slice owns the DB + * read/write, scan, and the pure suggester; reads return real accounts with the DB fields, + * with the live handle filled in by Slice 3. See `internal/account-handle-resolver.ts`. + * + * @module + */ +import { getAccountBalance } from '../internal/account-balance'; +import type { AccountRepository } from '../internal/account-repository'; +import type { SessionResolver } from '../internal/session'; +import { suggestAccountFor } from '../internal/suggest-account'; +import type { UserRepository } from '../internal/user-repository'; +import type { AccountsDomain } from '../domains'; +import type { Account } from '../types/account'; +import type { + AccountSuggestion, + AddAccountConfig, +} from '../types/account-config'; +import { type Currency, Money } from '../types/money'; +import type { PaymentIntent } from '../types/scan'; + +/** + * The accounts domain. Construct with the account repository (DB read/write), the user + * repository (default-account read/write lives on the `wallet.users` row), and the session + * resolver (current user id + default-account ids). + */ +export class AccountsDomainImpl implements AccountsDomain { + /** + * @param accounts - the `wallet.accounts` repository. + * @param users - the `wallet.users` repository (default-account columns). + * @param session - resolves the current user (id + default-account ids). + */ + constructor( + private readonly accounts: AccountRepository, + private readonly users: UserRepository, + private readonly session: SessionResolver, + ) {} + + /** + * All of the user's active accounts (fetch). Re-houses master `useAccounts` / + * `getAllActive`. Sorted oldest-first (matching master's creation-date sort). + * + * @returns the active accounts. + */ + async list(): Promise { + const user = await this.session.requireCurrentUser(); + const accounts = await this.accounts.getAllActive(user.id); + return accounts.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + } + + /** + * The account with this id, or null (fetch). Re-houses master `AccountRepository.get` + * (the lazy DB lookup behind `useAccountOrNull`), so it returns expired accounts too. + * + * @param id - the account id. + * @returns the account, or null. + */ + async get(id: string): Promise { + return this.accounts.get(id); + } + + /** + * The user's default account for a currency (fetch). Re-houses master `useDefaultAccount`: + * reads the default-account id off the current user (`defaultBtcAccountId` / + * `defaultUsdAccountId`, by currency; `currency` defaults to the user's `defaultCurrency`) + * and fetches that account. Returns null when no default is set / the account is missing. + * + * @param params - optional `{ currency }`; defaults to the user's default currency. + * @returns the default account, or null. + */ + async getDefault(params?: { currency?: Currency }): Promise { + const user = await this.session.requireCurrentUser(); + const currency = params?.currency ?? user.defaultCurrency; + + const defaultId = + currency === 'BTC' + ? user.defaultBtcAccountId + : currency === 'USD' + ? user.defaultUsdAccountId + : null; + + if (!defaultId) { + return null; + } + return this.accounts.get(defaultId); + } + + /** + * Create and persist a new account (create). Re-houses master + * `account-service.addCashuAccount` + `account-repository.create`. + * + * @param config - the cashu (mintUrl+currency) or spark (currency) create config. + * @returns the created account. + */ + async add(config: AddAccountConfig): Promise { + const user = await this.session.requireCurrentUser(); + return this.accounts.add(user.id, config); + } + + /** + * Make `account` the default for its currency (FULL object). Re-houses master + * `user-service.setDefaultAccount`: writes the matching default-account column on the + * `wallet.users` row (BTC→`defaultBtcAccountId`, USD→`defaultUsdAccountId`), leaving the + * other currency's default + the user's `defaultCurrency` unchanged. There is one default + * PER currency, not a single global default. + * + * @param account - the account to make default. + * @throws Error if the account's currency is unsupported. + */ + async setDefault(account: Account): Promise { + if (account.currency !== 'BTC' && account.currency !== 'USD') { + throw new Error('Unsupported currency'); + } + const user = await this.session.requireCurrentUser(); + await this.users.setDefaultAccount(user.id, { + defaultBtcAccountId: + account.currency === 'BTC' ? account.id : user.defaultBtcAccountId, + defaultUsdAccountId: + account.currency === 'USD' ? account.id : user.defaultUsdAccountId, + // setDefault changes the default ACCOUNT for the currency, not the user's preferred + // currency (master's `setDefaultCurrency` option defaults to false). + defaultCurrency: user.defaultCurrency, + }); + } + + /** + * The current balance of `account` (FULL object). Pure derivation (no re-read): cashu = + * sum of proofs, spark = the `balance` field. Re-houses master `getAccountBalance`. + * + * @param account - the account. + * @returns the balance as {@link Money} (zero for an empty cashu account; a spark account + * with no known balance resolves to zero in its currency). + */ + async getBalance(account: Account): Promise { + const balance = getAccountBalance(account); + return balance ?? Money.zero(account.currency); + } + + /** + * Recommend which of the passed-in `accounts` to use for `intent` (PURE — no DB read). + * NET-NEW logic; generalizes master's `findMatchingOfferOrGiftCardAccount` + online filter + * + default fallback. The web wallet feeds its cached accounts for an instant result. + * + * Resolves the user's default-account id (for the fallback when nothing has sufficient + * balance) from the current session — the only non-pure touch, and it is read-only and + * optional (the core ranking is pure over the accounts handed in). + * + * @param intent - what the user wants to do. + * @param accounts - the accounts to choose from. + * @returns the {@link AccountSuggestion}. + */ + async suggestFor( + intent: PaymentIntent, + accounts: Account[], + ): Promise { + const user = await this.session.getCurrentUser(); + const currency = inferIntentCurrency(intent, user?.defaultCurrency); + const defaultAccountId = user + ? currency === 'USD' + ? (user.defaultUsdAccountId ?? undefined) + : (user.defaultBtcAccountId ?? undefined) + : undefined; + return suggestAccountFor(intent, accounts, defaultAccountId); + } +} + +/** + * The currency the `suggestFor` fallback should look up a default account for: a BTC + * Lightning send → BTC; otherwise the user's default currency (or BTC when unknown). + */ +function inferIntentCurrency( + intent: PaymentIntent, + defaultCurrency: Currency | undefined, +): Currency { + if (intent.kind === 'send') { + const { destination } = intent; + if (destination.kind === 'bolt11' || destination.kind === 'ln-address') { + return 'BTC'; + } + } + return defaultCurrency ?? 'BTC'; +} diff --git a/packages/wallet-sdk/src/domains/scan.test.ts b/packages/wallet-sdk/src/domains/scan.test.ts new file mode 100644 index 000000000..b47cbcc2b --- /dev/null +++ b/packages/wallet-sdk/src/domains/scan.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from 'bun:test'; +import { type Token, getEncodedToken } from '@cashu/cashu-ts'; +import { DomainError } from '../errors'; +import { ScanDomainImpl } from './scan'; + +// -- Test fixtures (mirrors master scan/classify-input.test.ts) -- + +const CASHU_TOKEN: Token = { + mint: 'https://mint.example.com', + proofs: [ + { + id: '009a1f293253e41e', + amount: 1, + secret: 'test-secret-1', + C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', + }, + ], + unit: 'sat', +}; + +const CASHU_A_TOKEN = getEncodedToken(CASHU_TOKEN, { version: 3 }); +const CASHU_B_TOKEN = getEncodedToken(CASHU_TOKEN, { version: 4 }); + +// Real BOLT11 test vector (250,000 sats, "1 cup coffee") +const BOLT11_INVOICE = + 'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp'; + +describe('ScanDomainImpl.parse', () => { + const scan = new ScanDomainImpl(); + + describe('cashu tokens', () => { + test('cashuA token string → kind cashu-token', async () => { + const result = await scan.parse(CASHU_A_TOKEN); + expect(result.kind).toBe('cashu-token'); + if (result.kind === 'cashu-token') { + expect(result.token.encoded).toBe(CASHU_A_TOKEN); + expect(result.token.metadata).toBeDefined(); + } + }); + + test('cashuB token string → kind cashu-token', async () => { + const result = await scan.parse(CASHU_B_TOKEN); + expect(result.kind).toBe('cashu-token'); + }); + + test('URL containing a cashu token', async () => { + const result = await scan.parse(`https://example.com/#${CASHU_A_TOKEN}`); + expect(result.kind).toBe('cashu-token'); + if (result.kind === 'cashu-token') { + expect(result.token.encoded).toBe(CASHU_A_TOKEN); + } + }); + + test('cashu: URI prefix', async () => { + const result = await scan.parse(`cashu:${CASHU_A_TOKEN}`); + expect(result.kind).toBe('cashu-token'); + }); + + test('leading/trailing whitespace is trimmed', async () => { + const result = await scan.parse(` ${CASHU_A_TOKEN} `); + expect(result.kind).toBe('cashu-token'); + }); + }); + + describe('bolt11 invoices', () => { + test('raw bolt11 invoice → decoded fields', async () => { + const result = await scan.parse(BOLT11_INVOICE); + expect(result.kind).toBe('bolt11'); + if (result.kind === 'bolt11') { + expect(result.invoice.amountSat).toBe(250000); + expect(result.invoice.amountMsat).toBe(250000000); + expect(result.invoice.description).toBe('1 cup coffee'); + expect(result.invoice.network).toBe('bitcoin'); + expect(result.invoice.paymentHash).toBe( + '0001020304050607080900010203040506070809000102030405060708090102', + ); + } + }); + + test('lightning: prefixed invoice', async () => { + const result = await scan.parse(`lightning:${BOLT11_INVOICE}`); + expect(result.kind).toBe('bolt11'); + }); + + test('LIGHTNING: uppercase prefix', async () => { + const result = await scan.parse(`LIGHTNING:${BOLT11_INVOICE}`); + expect(result.kind).toBe('bolt11'); + }); + }); + + describe('lightning addresses', () => { + test('valid lightning address', async () => { + const result = await scan.parse('user@domain.com'); + expect(result).toEqual({ + kind: 'ln-address', + address: 'user@domain.com', + }); + }); + + test('uppercase address is lowercased', async () => { + const result = await scan.parse('USER@Domain.Com'); + expect(result).toEqual({ + kind: 'ln-address', + address: 'user@domain.com', + }); + }); + + test('address with subdomain', async () => { + const result = await scan.parse('alice@pay.example.org'); + expect(result).toEqual({ + kind: 'ln-address', + address: 'alice@pay.example.org', + }); + }); + + test('localhost address rejected by default', async () => { + await expect(scan.parse('user@localhost')).rejects.toBeInstanceOf( + DomainError, + ); + }); + + test('localhost address accepted when allowLocalhost is set', async () => { + const devScan = new ScanDomainImpl({ allowLocalhost: true }); + const result = await devScan.parse('user@localhost:3000'); + expect(result).toEqual({ + kind: 'ln-address', + address: 'user@localhost:3000', + }); + }); + }); + + describe('unrecognised input throws DomainError', () => { + test('empty string', async () => { + await expect(scan.parse('')).rejects.toBeInstanceOf(DomainError); + }); + + test('whitespace only', async () => { + await expect(scan.parse(' ')).rejects.toBeInstanceOf(DomainError); + }); + + test('random gibberish', async () => { + await expect(scan.parse('not a valid anything')).rejects.toBeInstanceOf( + DomainError, + ); + }); + + test('email-like but invalid TLD', async () => { + await expect(scan.parse('user@x')).rejects.toBeInstanceOf(DomainError); + }); + + test('bare URL without token', async () => { + await expect(scan.parse('https://example.com')).rejects.toBeInstanceOf( + DomainError, + ); + }); + + test('the thrown DomainError carries a stable code', async () => { + const promise = scan.parse('garbage'); + await expect(promise).rejects.toMatchObject({ + code: 'UNRECOGNISED_DESTINATION', + }); + }); + }); + + describe('classification priority', () => { + test('cashu token takes priority over an @-address', async () => { + const result = await scan.parse(`user@domain.com ${CASHU_A_TOKEN}`); + expect(result.kind).toBe('cashu-token'); + }); + }); +}); diff --git a/packages/wallet-sdk/src/domains/scan.ts b/packages/wallet-sdk/src/domains/scan.ts new file mode 100644 index 000000000..d31578ff4 --- /dev/null +++ b/packages/wallet-sdk/src/domains/scan.ts @@ -0,0 +1,98 @@ +/** + * `ScanDomain` implementation — §3 of the contract, Slice 2. + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/scan/classify-input.ts#classifyInput`. `parse(input)` + * classifies a raw scanned/pasted string into a {@link ParsedDestination} — the KIND only + * (`bolt11` | `ln-address` | `cashu-token`), with NO merchant/contact resolution (decision + * 3: gift-card/contact matching is the separate `suggestFor` step, not part of parse). + * + * Re-housing notes: + * - The three decode libs (`parseBolt11Invoice`, `extractCashuToken`, + * `buildLightningAddressFormatValidator`) are SDK-internal (§12); imported via + * `internal/lib-scan`. + * - Master gates the ln-address `allowLocalhost` flag on `import.meta.env.MODE === + * 'development'`. The SDK is framework-free, so that becomes a constructor option + * (`allowLocalhost`, default `false` = production behaviour). + * - ln-address → invoice resolution (LNURL-pay) is NOT done here — it folds into + * `createLightningQuote` (Slice 3/PR5) where the amount is known (§3). So `parse` only + * surfaces the address. + * - `parse` REJECTS (throws) on an unclassifiable input. Master's `classifyInput` returns + * `null`; the contract types `parse` as `Promise` (no null), so an + * unrecognised string is a {@link DomainError} the caller surfaces. + * + * @module + */ +import { DomainError } from '../errors'; +import { + buildLightningAddressFormatValidator, + extractCashuToken, + parseBolt11Invoice, +} from '../internal/lib-scan'; +import type { ScanDomain } from '../domains'; +import type { ParsedToken } from '../types/dependencies'; +import type { ParsedDestination } from '../types/scan'; + +/** + * The scan domain. Construct with `{ allowLocalhost }` controlling whether a + * `user@localhost` Lightning address is accepted (development convenience; default `false`, + * matching production). Re-houses master `classifyInput`. + */ +export class ScanDomainImpl implements ScanDomain { + /** The ln-address FORMAT validator, built once with the localhost policy. */ + private readonly validateLnAddressFormat: ( + value: string | null | undefined, + ) => string | boolean; + + /** + * @param options - `{ allowLocalhost }` — accept `user@localhost(:port)` addresses + * (development only); defaults to `false`. + */ + constructor(options?: { allowLocalhost?: boolean }) { + this.validateLnAddressFormat = buildLightningAddressFormatValidator({ + message: 'invalid', + allowLocalhost: options?.allowLocalhost ?? false, + }); + } + + /** + * Classify a raw string into a {@link ParsedDestination}. Priority (verbatim from master): + * cashu token → BOLT11 invoice → Lightning address. + * + * @param input - the scanned/pasted string (may carry a `cashu:` / `lightning:` prefix, a + * wrapping URL, or surrounding whitespace). + * @returns the parsed destination (kind only). + * @throws DomainError if the input is not a recognised destination. + */ + async parse(input: string): Promise { + const trimmed = input.trim(); + + // 1. Cashu token (works on URLs, raw tokens, `cashu:`-prefixed, etc.). + const cashuResult = extractCashuToken(trimmed); + if (cashuResult) { + const token: ParsedToken = { + encoded: cashuResult.encoded, + metadata: cashuResult.metadata, + }; + return { kind: 'cashu-token', token }; + } + + // 2. BOLT11 invoice (accepts an optional, case-insensitive `lightning:` prefix). + const bolt11Result = parseBolt11Invoice(trimmed); + if (bolt11Result.valid) { + return { kind: 'bolt11', invoice: bolt11Result.decoded }; + } + + // 3. Lightning address — lowercase before validation (the format validator's local-part + // regex only accepts lowercase). + const lowered = trimmed.toLowerCase(); + if (this.validateLnAddressFormat(lowered) === true) { + return { kind: 'ln-address', address: lowered }; + } + + throw new DomainError( + 'Unrecognised input: not a Lightning invoice, Lightning address, or cashu token', + 'UNRECOGNISED_DESTINATION', + ); + } +} diff --git a/packages/wallet-sdk/src/internal/account-balance.test.ts b/packages/wallet-sdk/src/internal/account-balance.test.ts new file mode 100644 index 000000000..6e89e2271 --- /dev/null +++ b/packages/wallet-sdk/src/internal/account-balance.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test'; +import type { CashuAccount, SparkAccount } from '../types/account'; +import { Money } from '../types/money'; +import { getAccountBalance } from './account-balance'; + +function cashu(proofAmounts: number[], currency: 'BTC' | 'USD' = 'BTC') { + return { + type: 'cashu', + currency, + proofs: proofAmounts.map((amount) => ({ amount })), + } as unknown as CashuAccount; +} + +describe('getAccountBalance', () => { + test('cashu: sums proof amounts into Money (BTC → sat)', () => { + const balance = getAccountBalance(cashu([100, 250, 1])); + expect(balance).not.toBeNull(); + expect(balance?.toNumber('sat')).toBe(351); + expect(balance?.currency).toBe('BTC'); + }); + + test('cashu: USD proofs sum into cents', () => { + const balance = getAccountBalance(cashu([100, 50], 'USD')); + // cashu unit for USD is 'cent'. + expect(balance?.toNumber('cent')).toBe(150); + expect(balance?.currency).toBe('USD'); + }); + + test('cashu: empty account → zero balance (not null)', () => { + const balance = getAccountBalance(cashu([])); + expect(balance).not.toBeNull(); + expect(balance?.toNumber('sat')).toBe(0); + }); + + test('spark: returns the balance field', () => { + const account = { + type: 'spark', + currency: 'BTC', + balance: new Money({ amount: 5000, currency: 'BTC', unit: 'sat' }), + } as unknown as SparkAccount; + expect(getAccountBalance(account)?.toNumber('sat')).toBe(5000); + }); + + test('spark: null balance passes through as null', () => { + const account = { + type: 'spark', + currency: 'BTC', + balance: null, + } as unknown as SparkAccount; + expect(getAccountBalance(account)).toBeNull(); + }); +}); diff --git a/packages/wallet-sdk/src/internal/account-balance.ts b/packages/wallet-sdk/src/internal/account-balance.ts new file mode 100644 index 000000000..664a94c16 --- /dev/null +++ b/packages/wallet-sdk/src/internal/account-balance.ts @@ -0,0 +1,41 @@ +/** + * `getAccountBalance` — pure account-balance derivation (Slice 2). + * + * Lifted verbatim from `apps/web-wallet/app/features/accounts/account.ts#getAccountBalance`: + * a cashu account's balance is the sum of its proof amounts (in the cashu unit for the + * currency); a spark account's balance is its `balance` field (sourced from the Breez SDK, + * may be `null` when offline / not yet known). Pure — no DB read, no live-wallet call — so + * it backs both `accounts.getBalance` (full object) and the `suggestFor` ranking. + * + * NOTE on the Slice-2 deferral: a cashu account's `proofs` are populated by the account + * handle resolver, which is deferred to Slice 3 (the Slice-2 stub yields `proofs: []`). So + * for a cashu account built in this slice this returns a zero balance until Slice 3 wires + * proof decryption; a spark account's `balance` is likewise `null` until Slice 3. This is + * the documented PR4 boundary — the function itself is the verbatim, final logic. + * + * @module + */ +import { getCashuUnit, sumProofs } from './lib-cashu'; +import { Money } from '../types/money'; +import type { Account } from '../types/account'; + +/** + * The balance of an account. + * + * - cashu: `sum(proofs.amount)` as {@link Money} in the currency's cashu unit. + * - spark: the account's `balance` (possibly `null`). + * + * @param account - the account. + * @returns the balance as {@link Money}, or `null` for a spark account with no known balance. + */ +export function getAccountBalance(account: Account): Money | null { + if (account.type === 'cashu') { + const value = sumProofs(account.proofs); + return new Money({ + amount: value, + currency: account.currency, + unit: getCashuUnit(account.currency), + }); + } + return account.balance; +} diff --git a/packages/wallet-sdk/src/internal/account-handle-resolver.ts b/packages/wallet-sdk/src/internal/account-handle-resolver.ts new file mode 100644 index 000000000..47f3bdd7f --- /dev/null +++ b/packages/wallet-sdk/src/internal/account-handle-resolver.ts @@ -0,0 +1,162 @@ +/** + * Account live-handle resolver — the Slice-2 / Slice-3 seam. + * + * An `Account`'s `wallet` field is a LIVE protocol handle (a cashu `ExtendedCashuWallet` + * or a `BreezSdk` instance), and a cashu account's `proofs` are DECRYPTED from the DB rows. + * Master builds BOTH in one place — `account-repository.toAccount` — by calling + * `getInitializedCashuWallet` / `getInitializedSparkWallet` (a heavy, networked mint/Breez + * init that fetches keysets/keys with a 10s timeout) and `Encryption.decryptBatch` (the + * OpenSecret-derived encryption key + ECIES). + * + * **Per the build plan, that heavy construction is Slice 3, not Slice 2.** The plan puts + * the per-mint protocol-metadata memo (`ExtendedCashuWallet`), the mint-WS managers, the + * `lib/cashu/**` absorption, and `@agicash/breez-sdk-spark` all in Slice 3 ("THE HEAVY + * one"); Slice 2 owns the account DB read/write + scan + `suggestFor`. Proof DECRYPTION is + * equally entangled (same `toAccount` call, same `shared/encryption` subsystem) and rides + * with it. + * + * 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. + * + * @module + */ +import { NotImplementedError } from '../errors'; +import type { CashuProof } from '../types/account'; +import type { + BreezSdk, + ExtendedCashuWallet, + SparkNetwork, +} from '../types/dependencies'; +import type { Currency } from '../types/money'; + +/** Inputs needed to (eventually) initialise a cashu account's live wallet + proofs. */ +export type CashuHandleRequest = { + mintUrl: string; + currency: Currency; + /** Encrypted `(amount, secret)` proof rows from the DB, awaiting decryption (Slice 3). */ + encryptedProofs: EncryptedCashuProofRow[]; +}; + +/** The encrypted-on-the-row fields of a cashu proof the resolver decrypts in Slice 3. */ +export type EncryptedCashuProofRow = { + id: string; + accountId: string; + userId: string; + keysetId: string; + /** Encrypted ciphertext (decrypted to a `number` in Slice 3). */ + amount: string; + /** Encrypted ciphertext (decrypted to a `string` in Slice 3). */ + secret: string; + unblindedSignature: string; + publicKeyY: string; + dleq: CashuProof['dleq']; + witness: CashuProof['witness']; + state: CashuProof['state']; + version: number; + createdAt: string; + reservedAt?: string | null; + spentAt?: string | null; +}; + +/** What the resolver returns for a cashu account: the live wallet, online flag, + proofs. */ +export type ResolvedCashuHandle = { + wallet: ExtendedCashuWallet; + isOnline: boolean; + proofs: CashuProof[]; +}; + +/** What the resolver returns for a spark account: the live wallet, online flag, + balance. */ +export type ResolvedSparkHandle = { + wallet: BreezSdk; + isOnline: boolean; + balance: ResolvedSparkBalance; +}; + +/** Spark balance the resolver derives from the live wallet (Slice 3); deferred to `null` here. */ +export type ResolvedSparkBalance = import('../types/money').Money | null; + +/** + * Fills in an account's deferred, connection-bound fields (the live `wallet` handle, online + * status, decrypted cashu proofs / spark balance). Slice 2 supplies a deferral stub; Slice 3 + * supplies the real mint/Breez init + proof decryption. + */ +export interface AccountHandleResolver { + /** Resolve a cashu account's live wallet, online flag, and decrypted proofs. */ + resolveCashu(request: CashuHandleRequest): Promise; + /** Resolve a spark account's live wallet, online flag, and balance. */ + resolveSpark(request: { + network: SparkNetwork; + }): Promise; +} + +/** + * Build a lazy stub that stands in for a not-yet-constructed live wallet handle. Reading + * any property (e.g. calling `wallet.getMintInfo()`) throws {@link NotImplementedError} + * naming the slice that fills it in, so a deferred handle fails loudly + identifiably + * rather than surfacing as `undefined`. The account's plain DB fields remain fully usable. + * + * @param label - identifies the handle in the thrown message (e.g. `'ExtendedCashuWallet'`). + * @returns a `Proxy` typed as `T` whose every access throws. + */ +function deferredHandle(label: string): T { + return new Proxy( + {}, + { + get(_target, prop) { + // Let well-known introspection symbols resolve to undefined so the stub can be + // safely held / logged / spread without tripping the throw (only real *use* throws). + if (typeof prop === 'symbol') { + return undefined; + } + throw new NotImplementedError( + `${label}.${String(prop)} (live wallet handle is constructed in @agicash/wallet-sdk Slice 3)`, + ); + }, + }, + ) as T; +} + +/** + * Slice-2 {@link AccountHandleResolver}: constructs accounts from the DB fields this slice + * owns and DEFERS the live wallet handle + proof decryption to Slice 3. + * + * - cashu: `wallet` is a deferred {@link deferredHandle} stub, `isOnline` is reported + * `false` (no mint was contacted), and `proofs` is `[]` (decryption is Slice 3 — the + * encrypted ciphertext is intentionally NOT surfaced as a proof). Consumers that only + * read DB fields (`mintUrl`, `keysetCounters`, `currency`, defaults, `suggestFor` online- + * filtering) work; anything that needs the live mint or real proofs gets a labelled throw. + * - spark: `wallet` is a deferred stub, `isOnline` is `false`, `balance` is `null`. + */ +export class DeferredAccountHandleResolver implements AccountHandleResolver { + /** Deferred cashu handle: stub wallet, offline, no decrypted proofs (Slice 3 fills these). */ + async resolveCashu( + _request: CashuHandleRequest, + ): Promise { + return { + wallet: deferredHandle('ExtendedCashuWallet'), + isOnline: false, + proofs: [], + }; + } + + /** Deferred spark handle: stub wallet, offline, null balance (Slice 3 fills these). */ + async resolveSpark(_request: { + network: SparkNetwork; + }): Promise { + return { + wallet: deferredHandle('BreezSdk'), + isOnline: false, + balance: null, + }; + } +} diff --git a/packages/wallet-sdk/src/internal/account-repository.test.ts b/packages/wallet-sdk/src/internal/account-repository.test.ts new file mode 100644 index 000000000..8c3a4d0db --- /dev/null +++ b/packages/wallet-sdk/src/internal/account-repository.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from 'bun:test'; +import { DeferredAccountHandleResolver } from './account-handle-resolver'; +import { AccountRepository } from './account-repository'; +import type { AgicashDbAccountWithProofs } from './db-account'; +import type { WalletSupabaseClient } from './supabase-client'; + +// -- Fakes ------------------------------------------------------------------- + +/** Terminal result shape every builder call resolves to. */ +type Result = { data: unknown; error: unknown; status?: number }; + +/** + * A fake Supabase client whose fluent `from(...).select/insert/eq/returns/...` chain is a + * no-op (records the last `insert(...)` payload) and whose terminal `single()` / + * `maybeSingle()` — and the awaited builder itself (`getAllActive` awaits the query, not a + * terminal) — resolve to a pre-set result. Only the methods the repository touches are + * implemented; cast to the client type. + */ +function fakeDb(result: Result) { + let lastInsert: unknown; + const chain: Record & PromiseLike = { + select: () => chain, + insert: (payload: unknown) => { + lastInsert = payload; + return chain; + }, + eq: () => chain, + returns: () => chain, + single: async () => result, + maybeSingle: async () => result, + // Make the builder awaitable (the `getAllActive` list query is awaited directly) — a + // PostgrestFilterBuilder IS a real thenable, so the mock must be one too. + // biome-ignore lint/suspicious/noThenProperty: intentionally mocking the thenable Supabase query builder. + then: (onFulfilled, onRejected) => + Promise.resolve(result).then(onFulfilled, onRejected), + }; + const db = { + from: () => chain, + } as unknown as WalletSupabaseClient; + return { db, getLastInsert: () => lastInsert }; +} + +const resolver = new DeferredAccountHandleResolver(); + +const cashuRow: AgicashDbAccountWithProofs = { + id: 'acct-cashu', + name: 'My Mint', + type: 'cashu', + purpose: 'transactional', + state: 'active', + currency: 'BTC', + details: { + mint_url: 'https://mint.example.com', + is_test_mint: false, + keyset_counters: { abc: 3 }, + }, + created_at: '2026-01-01T00:00:00.000Z', + expires_at: null, + user_id: 'user-1', + version: 2, + cashu_proofs: [], +}; + +const sparkRow: AgicashDbAccountWithProofs = { + id: 'acct-spark', + name: 'Spark', + type: 'spark', + purpose: 'transactional', + state: 'active', + currency: 'BTC', + details: { network: 'MAINNET' }, + created_at: '2026-01-02T00:00:00.000Z', + expires_at: null, + user_id: 'user-1', + version: 1, + cashu_proofs: [], +}; + +describe('AccountRepository.get', () => { + test('maps a cashu account row to the domain account (handle deferred)', async () => { + const { db } = fakeDb({ data: cashuRow, error: null }); + const repo = new AccountRepository(db, resolver); + + const account = await repo.get('acct-cashu'); + + expect(account).not.toBeNull(); + expect(account?.type).toBe('cashu'); + if (account?.type === 'cashu') { + expect(account.mintUrl).toBe('https://mint.example.com'); + expect(account.isTestMint).toBe(false); + expect(account.keysetCounters).toEqual({ abc: 3 }); + // Deferred to Slice 3: proofs empty, account reported offline. + expect(account.proofs).toEqual([]); + expect(account.isOnline).toBe(false); + } + }); + + test('maps a spark account row (balance deferred to null)', async () => { + const { db } = fakeDb({ data: sparkRow, error: null }); + const repo = new AccountRepository(db, resolver); + + const account = await repo.get('acct-spark'); + + expect(account?.type).toBe('spark'); + if (account?.type === 'spark') { + expect(account.network).toBe('MAINNET'); + expect(account.balance).toBeNull(); + expect(account.isOnline).toBe(false); + } + }); + + test('returns null when no row exists', async () => { + const { db } = fakeDb({ data: null, error: null }); + const repo = new AccountRepository(db, resolver); + + expect(await repo.get('missing')).toBeNull(); + }); + + test('throws on a read error', async () => { + const { db } = fakeDb({ data: null, error: { message: 'boom' } }); + const repo = new AccountRepository(db, resolver); + + await expect(repo.get('acct-cashu')).rejects.toThrow( + 'Failed to get account', + ); + }); +}); + +describe('AccountRepository.getAllActive', () => { + test('maps every returned row', async () => { + const { db } = fakeDb({ data: [cashuRow, sparkRow], error: null }); + const repo = new AccountRepository(db, resolver); + + const accounts = await repo.getAllActive('user-1'); + + expect(accounts).toHaveLength(2); + expect(accounts.map((a) => a.id)).toEqual(['acct-cashu', 'acct-spark']); + }); + + test('throws on a read error', async () => { + const { db } = fakeDb({ data: null, error: { message: 'boom' } }); + const repo = new AccountRepository(db, resolver); + + await expect(repo.getAllActive('user-1')).rejects.toThrow( + 'Failed to get accounts', + ); + }); +}); + +describe('AccountRepository.add', () => { + test('builds the cashu insert row (normalised mint url + empty counters)', async () => { + const { db, getLastInsert } = fakeDb({ data: cashuRow, error: null }); + const repo = new AccountRepository(db, resolver); + + const account = await repo.add('user-1', { + type: 'cashu', + mintUrl: 'https://mint.example.com/', // trailing slash → normalised + currency: 'BTC', + }); + + const inserted = getLastInsert() as { + type: string; + currency: string; + user_id: string; + purpose: string; + expires_at: string | null; + details: { mint_url: string; is_test_mint: boolean }; + }; + expect(inserted.type).toBe('cashu'); + expect(inserted.currency).toBe('BTC'); + expect(inserted.user_id).toBe('user-1'); + expect(inserted.purpose).toBe('transactional'); + expect(inserted.expires_at).toBeNull(); + expect(inserted.details.mint_url).toBe('https://mint.example.com'); + expect(account.type).toBe('cashu'); + }); + + test('builds the spark insert row (network MAINNET, default name)', async () => { + const { db, getLastInsert } = fakeDb({ data: sparkRow, error: null }); + const repo = new AccountRepository(db, resolver); + + await repo.add('user-1', { type: 'spark', currency: 'BTC' }); + + const inserted = getLastInsert() as { + type: string; + name: string; + details: { network: string }; + }; + expect(inserted.type).toBe('spark'); + expect(inserted.name).toBe('Spark'); + expect(inserted.details.network).toBe('MAINNET'); + }); + + test('a 409 on a cashu insert → DomainError (account already exists)', async () => { + const { db } = fakeDb({ + data: null, + error: { message: 'conflict' }, + status: 409, + }); + const repo = new AccountRepository(db, resolver); + + const promise = repo.add('user-1', { + type: 'cashu', + mintUrl: 'https://mint.example.com', + currency: 'BTC', + }); + await expect(promise).rejects.toMatchObject({ + code: 'ACCOUNT_ALREADY_EXISTS', + }); + }); + + test('a LIMIT_REACHED hint → DomainError', async () => { + const { db } = fakeDb({ + data: null, + error: { message: 'too many', hint: 'LIMIT_REACHED', details: 'max 1' }, + }); + const repo = new AccountRepository(db, resolver); + + const promise = repo.add('user-1', { + type: 'cashu', + mintUrl: 'https://mint.example.com', + currency: 'BTC', + }); + await expect(promise).rejects.toMatchObject({ + code: 'ACCOUNT_LIMIT_REACHED', + }); + }); + + test('a non-conflict insert error → generic Error', async () => { + const { db } = fakeDb({ + data: null, + error: { message: 'boom' }, + status: 500, + }); + const repo = new AccountRepository(db, resolver); + + await expect( + repo.add('user-1', { type: 'spark', currency: 'BTC' }), + ).rejects.toThrow('Failed to create account'); + }); +}); diff --git a/packages/wallet-sdk/src/internal/account-repository.ts b/packages/wallet-sdk/src/internal/account-repository.ts new file mode 100644 index 000000000..214474443 --- /dev/null +++ b/packages/wallet-sdk/src/internal/account-repository.ts @@ -0,0 +1,178 @@ +/** + * Internal `wallet.accounts` repository — Slice 2 (accounts + scan). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/accounts/account-repository.ts` (`get` / `getAllActive` / + * `create`) + `account-service.ts` (`addCashuAccount`). Master expresses these over a + * React-hook-constructed repository wired to a TanStack `QueryClient`; here they are plain + * async methods over the SDK-owned Supabase client (passed in), reading/writing the + * `wallet.accounts` table (joined with `cashu_proofs`) and mapping rows via + * {@link dbAccountToAccount}. + * + * The live wallet handle + proof decryption are resolved through the injected + * {@link AccountHandleResolver} (deferred to Slice 3; see `account-handle-resolver.ts`). The + * `expires_at`-from-keyset path master runs for `offer` accounts in `addCashuAccount` needs + * the mint keysets (a networked read = Slice 3); offers are out of v1 (§13), so `add` + * persists `expires_at: null` and a Slice-3 follow-up wires the keyset-expiry path when + * offer creation lands. + * + * @module + */ +import { DomainError } from '../errors'; +import type { AccountHandleResolver } from './account-handle-resolver'; +import { + type AgicashDbAccountWithProofs, + CashuAccountDetailsDbDataSchema, + SparkAccountDetailsDbDataSchema, + dbAccountToAccount, +} from './db-account'; +import { checkIsTestMint, normalizeMintUrl } from './lib-cashu'; +import type { WalletSupabaseClient } from './supabase-client'; +import type { Account } from '../types/account'; +import type { AddAccountConfig } from '../types/account-config'; + +/** The `select` projection master uses: the account row joined with its UNSPENT proofs. */ +const ACCOUNT_WITH_PROOFS_SELECT = '*, cashu_proofs(*)'; + +/** + * Reads + writes for the `wallet.accounts` table, scoped (via RLS) to the signed-in user. + * + * Holds the SDK-owned Supabase client + the {@link AccountHandleResolver}. Methods take the + * `userId` the account domain resolves from the current session. + */ +export class AccountRepository { + /** + * @param db - the SDK-owned Supabase client (schema pinned to `wallet`). + * @param resolver - fills in each account's deferred live-handle fields (Slice 2 stub / + * Slice 3 real). + */ + constructor( + private readonly db: WalletSupabaseClient, + private readonly resolver: AccountHandleResolver, + ) {} + + /** + * Get the account with the given id (or null if not found). + * + * Verbatim logic from master `AccountRepository.get`: select the account joined with its + * UNSPENT proofs, then map via {@link dbAccountToAccount}. The proof limit master notes + * (≤6000) is a server concern and unchanged here. + * + * @param id - the account id. + * @returns the account, or null. + * @throws Error if the read fails. + */ + async get(id: string): Promise { + const { data, error } = await this.db + .from('accounts') + .select(ACCOUNT_WITH_PROOFS_SELECT) + .eq('id', id) + .eq('cashu_proofs.state', 'UNSPENT') + .maybeSingle(); + + if (error) { + throw new Error('Failed to get account', { cause: error }); + } + + return data ? dbAccountToAccount(data, this.resolver) : null; + } + + /** + * Get all ACTIVE accounts for the user (with their UNSPENT proofs). + * + * Verbatim logic from master `AccountRepository.getAllActive`: filter to `state='active'` + * and join UNSPENT proofs, then map each row. This is the read backing `accounts.list`. + * + * @param userId - the owning user id. + * @returns the active accounts. + * @throws Error if the read fails. + */ + async getAllActive(userId: string): Promise { + const { data, error } = await this.db + .from('accounts') + .select(ACCOUNT_WITH_PROOFS_SELECT) + .eq('user_id', userId) + .eq('state', 'active') + .eq('cashu_proofs.state', 'UNSPENT') + .returns(); + + if (error) { + throw new Error('Failed to get accounts', { cause: error }); + } + + return Promise.all( + data.map((row) => dbAccountToAccount(row, this.resolver)), + ); + } + + /** + * Create an account from an {@link AddAccountConfig} and return the mapped domain account. + * + * Re-houses master `account-service.addCashuAccount` + `account-repository.create`: builds + * the per-type `details` JSON (cashu: normalised mint URL + test-mint detection + empty + * keyset counters; spark: network), inserts the row, and maps the result. `name` defaults + * to the mint URL (cashu) / `'Spark'` (spark) when omitted. `expires_at` is `null` + * (the offer keyset-expiry path is Slice 3; offers are out of v1). + * + * @param userId - the owning user id. + * @param config - the account create config. + * @returns the created account. + * @throws DomainError if the mint's account limit is reached, or (cashu) the mint+currency + * account already exists. + * @throws Error if the insert otherwise fails. + */ + async add(userId: string, config: AddAccountConfig): Promise { + const network = config.type === 'spark' ? 'MAINNET' : undefined; + + const details = + config.type === 'cashu' + ? CashuAccountDetailsDbDataSchema.parse({ + mint_url: normalizeMintUrl(config.mintUrl), + is_test_mint: checkIsTestMint(config.mintUrl), + keyset_counters: {}, + }) + : SparkAccountDetailsDbDataSchema.parse({ network }); + + const name = + config.name ?? + (config.type === 'cashu' ? normalizeMintUrl(config.mintUrl) : 'Spark'); + + const row = { + name, + type: config.type, + currency: config.currency, + details, + user_id: userId, + purpose: 'transactional', + expires_at: null, + }; + + const { data, error, status } = await this.db + .from('accounts') + // biome-ignore lint/suspicious/noExplicitAny: the Supabase client is untyped until the generated Database types are lifted (a later slice); the insert row shape is enforced above. + .insert(row as any) + .select(ACCOUNT_WITH_PROOFS_SELECT) + .eq('cashu_proofs.state', 'UNSPENT') + .single(); + + if (error) { + // Master surfaces the mint's per-account LIMIT_REACHED hint as a DomainError. + if (error.hint === 'LIMIT_REACHED') { + throw new DomainError( + `${error.message} ${error.details ?? ''}`.trim(), + 'ACCOUNT_LIMIT_REACHED', + ); + } + // A 409 on a cashu insert = the mint+currency account already exists. + if (status === 409 && config.type === 'cashu') { + throw new DomainError( + 'Account for this mint and currency already exists', + 'ACCOUNT_ALREADY_EXISTS', + ); + } + throw new Error('Failed to create account', { cause: error }); + } + + return dbAccountToAccount(data, this.resolver); + } +} diff --git a/packages/wallet-sdk/src/internal/db-account.test.ts b/packages/wallet-sdk/src/internal/db-account.test.ts new file mode 100644 index 000000000..dcb4fecbb --- /dev/null +++ b/packages/wallet-sdk/src/internal/db-account.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test'; +import { + type AccountHandleResolver, + DeferredAccountHandleResolver, +} from './account-handle-resolver'; +import { + type AgicashDbAccount, + type AgicashDbAccountWithProofs, + dbAccountToAccount, + isCashuAccount, + isSparkAccount, +} from './db-account'; + +const deferred = new DeferredAccountHandleResolver(); + +const cashuBase: AgicashDbAccount = { + id: 'a1', + name: 'Mint', + type: 'cashu', + purpose: 'transactional', + state: 'active', + currency: 'BTC', + details: { + mint_url: 'https://mint.example.com', + is_test_mint: true, + keyset_counters: {}, + }, + created_at: '2026-01-01T00:00:00.000Z', + expires_at: null, + user_id: 'u1', + version: 1, +}; + +describe('type guards', () => { + test('isCashuAccount true for a valid cashu row', () => { + expect(isCashuAccount(cashuBase)).toBe(true); + }); + + test('isCashuAccount false for a spark row', () => { + const spark: AgicashDbAccount = { + ...cashuBase, + type: 'spark', + details: { network: 'REGTEST' }, + }; + expect(isCashuAccount(spark)).toBe(false); + expect(isSparkAccount(spark)).toBe(true); + }); + + test('isCashuAccount throws when type=cashu but details are invalid', () => { + const bad: AgicashDbAccount = { ...cashuBase, details: { mint_url: 123 } }; + expect(() => isCashuAccount(bad)).toThrow(); + }); +}); + +describe('dbAccountToAccount (deferred resolver)', () => { + test('maps a cashu row, deferring wallet/proofs/isOnline', async () => { + const row: AgicashDbAccountWithProofs = { ...cashuBase, cashu_proofs: [] }; + const account = await dbAccountToAccount(row, deferred); + + expect(account.id).toBe('a1'); + expect(account.type).toBe('cashu'); + expect(account.currency).toBe('BTC'); + expect(account.version).toBe(1); + if (account.type === 'cashu') { + expect(account.mintUrl).toBe('https://mint.example.com'); + expect(account.isTestMint).toBe(true); + expect(account.proofs).toEqual([]); + expect(account.isOnline).toBe(false); + } + }); + + test('maps a spark row, deferring wallet/balance/isOnline', async () => { + const row: AgicashDbAccountWithProofs = { + ...cashuBase, + type: 'spark', + details: { network: 'MAINNET' }, + cashu_proofs: [], + }; + const account = await dbAccountToAccount(row, deferred); + + expect(account.type).toBe('spark'); + if (account.type === 'spark') { + expect(account.network).toBe('MAINNET'); + expect(account.balance).toBeNull(); + expect(account.isOnline).toBe(false); + } + }); + + test('throws on an unknown account type', async () => { + const row = { + ...cashuBase, + type: 'unknown', + cashu_proofs: [], + } as unknown as AgicashDbAccountWithProofs; + await expect(dbAccountToAccount(row, deferred)).rejects.toThrow( + 'Invalid account type', + ); + }); + + test('uses the injected resolver output (forward-compat with the Slice-3 resolver)', async () => { + // A fake "Slice 3" resolver that returns a real online cashu wallet handle + proofs. + const fakeWallet = { getMintInfo: () => ({}) }; + const realResolver: AccountHandleResolver = { + resolveCashu: async () => ({ + wallet: fakeWallet as never, + isOnline: true, + proofs: [], + }), + resolveSpark: async () => ({ + wallet: {} as never, + isOnline: true, + balance: null, + }), + }; + const row: AgicashDbAccountWithProofs = { ...cashuBase, cashu_proofs: [] }; + const account = await dbAccountToAccount(row, realResolver); + + expect(account.isOnline).toBe(true); + if (account.type === 'cashu') { + expect(account.wallet).toBe(fakeWallet as never); + } + }); +}); + +describe('DeferredAccountHandleResolver — the live handle is a throwing stub', () => { + test('reading a method off the deferred cashu wallet throws NotImplementedError', async () => { + const { wallet } = await deferred.resolveCashu({ + mintUrl: 'm', + currency: 'BTC', + encryptedProofs: [], + }); + // The wallet exists as an object, but *using* it (any property access) throws. + expect(() => + (wallet as { getMintInfo: () => unknown }).getMintInfo(), + ).toThrow('Slice 3'); + }); + + 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', + ); + }); +}); diff --git a/packages/wallet-sdk/src/internal/db-account.ts b/packages/wallet-sdk/src/internal/db-account.ts new file mode 100644 index 000000000..be4287c87 --- /dev/null +++ b/packages/wallet-sdk/src/internal/db-account.ts @@ -0,0 +1,268 @@ +/** + * Internal DB ⇄ domain `Account` mapping — Slice 2 (accounts + scan). + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/accounts/account-repository.ts` (`toAccount`) + + * `apps/web-wallet/app/features/agicash-db/database.ts` (the row types + `isCashuAccount`/ + * `isSparkAccount` guards) + the `json-models/*-account-details-db-data.ts` schemas. Master + * maps a `wallet.accounts` row (joined with `cashu_proofs`) to the domain {@link Account}; + * here that mapping is framework-free and the LIVE wallet handle / decrypted proofs are + * resolved through the injected {@link AccountHandleResolver} (deferred to Slice 3 — see + * `account-handle-resolver.ts`). + * + * This module owns: + * - {@link AgicashDbAccount} / {@link AgicashDbCashuProof} / {@link AgicashDbAccountWithProofs} + * — the row shapes (master: generated `supabase/database.types.ts`). Hand-written here as + * in `db-user.ts`, so the SDK can type the otherwise-untyped Supabase reads without + * pulling the full generated `Database` types (lifted in a later slice). + * - {@link CashuAccountDetailsDbDataSchema} / {@link SparkAccountDetailsDbDataSchema} — the + * `details` JSON schemas (lifted verbatim from `json-models/*-account-details-db-data.ts`). + * - {@link isCashuAccount} / {@link isSparkAccount} — the type guards (verbatim). + * - {@link dbAccountToAccount} — the row→domain mapper (verbatim logic from + * `account-repository.toAccount`, minus the live-handle/decrypt construction which the + * resolver owns). + * + * @module + */ +import { z } from 'zod/mini'; +import type { + AccountHandleResolver, + EncryptedCashuProofRow, +} from './account-handle-resolver'; +import type { + Account, + AccountPurpose, + AccountState, + AccountType, +} from '../types/account'; +import type { SparkNetwork } from '../types/dependencies'; +import type { Currency } from '../types/money'; + +// --- json-model `details` schemas (verbatim from json-models/*-account-details-db-data) -- + +/** The `accounts.details` JSON for a cashu account (verbatim from master). */ +export const CashuAccountDetailsDbDataSchema = z.object({ + /** URL of the mint. */ + mint_url: z.string(), + /** Whether the mint is a test mint. */ + is_test_mint: z.boolean(), + /** Counter value per mint keyset (keysetId → counter). */ + keyset_counters: z.record(z.string(), z.number()), +}); +/** `z.infer` of {@link CashuAccountDetailsDbDataSchema}. */ +export type CashuAccountDetailsDbData = z.infer< + typeof CashuAccountDetailsDbDataSchema +>; + +/** The `accounts.details` JSON for a spark account (verbatim from master). */ +export const SparkAccountDetailsDbDataSchema = z.object({ + /** Network of the Spark account (stored uppercase). */ + network: z.enum(['MAINNET', 'REGTEST']), +}); +/** `z.infer` of {@link SparkAccountDetailsDbDataSchema}. */ +export type SparkAccountDetailsDbData = z.infer< + typeof SparkAccountDetailsDbDataSchema +>; + +// --- row shapes (hand-written; master = generated supabase/database.types.ts) ------------ + +/** + * A row of the `wallet.accounts` table. + * + * Lifted from master `agicash-db/database.ts#AgicashDbAccount` + * (`Database['wallet']['Tables']['accounts']['Row']`, generated in + * `supabase/database.types.ts`). Hand-written here (as in {@link AgicashDbUser}) so the SDK + * can narrow the currently-untyped Supabase reads; replaced by the generated types when + * those are lifted into the package. `details` is the per-type JSON parsed by the guards. + */ +export type AgicashDbAccount = { + /** UUID primary key. */ + id: string; + /** Display name. */ + name: string; + /** `'cashu' | 'spark'`. */ + type: AccountType; + /** What the account is for. */ + purpose: AccountPurpose; + /** `'active' | 'expired'`. */ + state: AccountState; + /** The account currency. */ + currency: Currency; + /** Per-type JSON blob (cashu mint/keyset info, or spark network). */ + details: unknown; + /** Row creation time, ISO 8601. */ + created_at: string; + /** Account expiry, ISO 8601, or null for non-expiring. */ + expires_at: string | null; + /** Owning user id. */ + user_id: string; + /** Row version (optimistic lock). */ + version: number; +}; + +/** + * A row of the `wallet.cashu_proofs` table. + * + * Lifted from master `agicash-db/database.ts#AgicashDbCashuProof`. NOTE: `amount` + `secret` + * are ENCRYPTED ciphertext on the row (typed `string`); decryption to a `number` / plaintext + * `string` happens in the {@link AccountHandleResolver} (Slice 3). Only the encrypted + * columns the mapper forwards are typed; the spend-tracking foreign-key columns are omitted + * (not needed by the read mapper). + */ +export type AgicashDbCashuProof = { + id: string; + account_id: string; + user_id: string; + keyset_id: string; + /** Encrypted ciphertext. */ + amount: string; + /** Encrypted ciphertext. */ + secret: string; + unblinded_signature: string; + public_key_y: string; + dleq: unknown; + witness: unknown; + state: 'UNSPENT' | 'RESERVED' | 'SPENT'; + version: number; + created_at: string; + reserved_at: string | null; + spent_at?: string | null; +}; + +/** + * An account row joined with its `cashu_proofs`. For a spark account `cashu_proofs` is an + * empty array (verbatim from master `AgicashDbAccountWithProofs`). + */ +export type AgicashDbAccountWithProofs = AgicashDbAccount & { + cashu_proofs: AgicashDbCashuProof[]; +}; + +// --- type guards (verbatim from agicash-db/database.ts) ---------------------------------- + +/** + * Whether the row is a cashu account (verbatim from master `isCashuAccount`). + * @throws if `type === 'cashu'` but `details` fails the cashu schema. + */ +export function isCashuAccount( + data: AgicashDbAccount, +): data is AgicashDbAccount & { + type: 'cashu'; + details: CashuAccountDetailsDbData; +} { + if (data.type !== 'cashu') { + return false; + } + CashuAccountDetailsDbDataSchema.parse(data.details); + return true; +} + +/** + * Whether the row is a spark account (verbatim from master `isSparkAccount`). + * @throws if `type === 'spark'` but `details` fails the spark schema. + */ +export function isSparkAccount( + data: AgicashDbAccount, +): data is AgicashDbAccount & { + type: 'spark'; + details: SparkAccountDetailsDbData; +} { + if (data.type !== 'spark') { + return false; + } + SparkAccountDetailsDbDataSchema.parse(data.details); + return true; +} + +/** Map the encrypted cashu-proof rows to the resolver's {@link EncryptedCashuProofRow} input. */ +function toEncryptedProofRows( + proofs: AgicashDbCashuProof[], +): EncryptedCashuProofRow[] { + return proofs.map((p) => ({ + id: p.id, + accountId: p.account_id, + userId: p.user_id, + keysetId: p.keyset_id, + amount: p.amount, + secret: p.secret, + unblindedSignature: p.unblinded_signature, + publicKeyY: p.public_key_y, + // dleq/witness are cashu-ts `Proof` sub-fields; the domain `CashuProof` carries them + // as-is (Slice 3's resolver re-parses via cashu-ts `ProofSchema`). Cast to the domain + // sub-field type (a PR1 placeholder until cashu-ts types are imported). + dleq: p.dleq as EncryptedCashuProofRow['dleq'], + witness: p.witness as EncryptedCashuProofRow['witness'], + state: p.state, + version: p.version, + createdAt: p.created_at, + reservedAt: p.reserved_at, + spentAt: p.spent_at, + })); +} + +/** + * Map a `wallet.accounts` row (joined with `cashu_proofs`) to the domain {@link Account}. + * + * Verbatim logic from master `account-repository.toAccount`: the common base fields are + * copied straight off the row; the per-type fields are read from the parsed `details`; and + * the connection-bound fields — the live `wallet` handle, `isOnline`, decrypted cashu + * `proofs` / spark `balance` — come from the injected {@link AccountHandleResolver} (the + * Slice-2 deferral stub or the Slice-3 real resolver). + * + * @param data - the joined account row. + * @param resolver - fills in the deferred live-handle fields. + * @returns the domain account. + * @throws Error if the row's `type` is neither cashu nor spark. + */ +export async function dbAccountToAccount( + data: AgicashDbAccountWithProofs, + resolver: AccountHandleResolver, +): Promise { + const commonData = { + id: data.id, + name: data.name, + currency: data.currency, + purpose: data.purpose, + state: data.state, + createdAt: data.created_at, + version: data.version, + expiresAt: data.expires_at, + }; + + if (isCashuAccount(data)) { + const details = data.details; + const { wallet, isOnline, proofs } = await resolver.resolveCashu({ + mintUrl: details.mint_url, + currency: data.currency, + encryptedProofs: toEncryptedProofRows(data.cashu_proofs), + }); + + return { + ...commonData, + isOnline, + type: 'cashu', + mintUrl: details.mint_url, + isTestMint: details.is_test_mint, + keysetCounters: details.keyset_counters, + proofs, + wallet, + } as T; + } + + if (isSparkAccount(data)) { + const network: SparkNetwork = data.details.network; + const { wallet, isOnline, balance } = await resolver.resolveSpark({ + network, + }); + + return { + ...commonData, + type: 'spark', + balance, + network, + isOnline, + wallet, + } as T; + } + + throw new Error('Invalid account type'); +} diff --git a/packages/wallet-sdk/src/internal/lib-cashu.ts b/packages/wallet-sdk/src/internal/lib-cashu.ts new file mode 100644 index 000000000..1e93ca319 --- /dev/null +++ b/packages/wallet-sdk/src/internal/lib-cashu.ts @@ -0,0 +1,27 @@ +/** + * SDK-internal cashu helpers — Slice 2 (accounts + scan). + * + * The account domain needs a few small, framework-free cashu utilities: + * - `sumProofs` / `getCashuUnit` — to compute a cashu account's balance from its proofs + * (`getBalance`, mirroring master `getAccountBalance` in `accounts/account.ts`); + * - `normalizeMintUrl` / `checkIsTestMint` — for `add` (canonicalise the mint URL + + * detect a test mint, mirroring master `account-service.addCashuAccount` + + * `account-repository.create`). + * + * The build plan makes `lib/cashu` **SDK-internal** (§12). Re-housing approach (matches + * `types/money.ts` + `lib-scan.ts`): re-export the single live source from the specific + * `app/lib/cashu/*` modules via a relative path so there is exactly ONE implementation. We + * import from the specific modules (`utils` for the URL/unit helpers, `proof` for + * `sumProofs`) NOT the `lib/cashu` barrel, to avoid pulling the heavier subscription-manager + * / payment-request surface the barrel re-exports — those are Slice 3. None of the imported + * functions transitively pulls react / @tanstack (verified). + * + * @module + */ + +export { + checkIsTestMint, + getCashuUnit, + normalizeMintUrl, +} from '../../../../apps/web-wallet/app/lib/cashu/utils'; +export { sumProofs } from '../../../../apps/web-wallet/app/lib/cashu/proof'; diff --git a/packages/wallet-sdk/src/internal/lib-scan.ts b/packages/wallet-sdk/src/internal/lib-scan.ts new file mode 100644 index 000000000..ad7392d27 --- /dev/null +++ b/packages/wallet-sdk/src/internal/lib-scan.ts @@ -0,0 +1,30 @@ +/** + * SDK-internal scan/decode primitives — Slice 2 (accounts + scan). + * + * `scan.parse` (= master `classifyInput`) decodes a raw string via three small, + * framework-free `app/lib/*` modules: `parseBolt11Invoice` (BOLT11), `extractCashuToken` + * (cashu), and `buildLightningAddressFormatValidator` (Lightning-address format). The + * build plan makes `lib/bolt11` / `lib/cashu` / `lib/lnurl` **SDK-internal** (§12 — they + * are not part of the public surface; only `scan.parse`'s typed result is). + * + * Re-housing approach (matches `types/money.ts`): re-export the single live source from + * `apps/web-wallet/app/lib/*` via a relative path so there is exactly ONE implementation + * (no duplication, no web churn). The canonical relocation of these files INTO the package + * is a deliberately-deferred follow-up (out of the SDK build-plan's scope); until then this + * module is the SDK-internal seam every scan consumer imports from. None of these three + * functions transitively pulls react / @tanstack (verified) — the framework-free constraint + * holds. + * + * The functions imported here are the *format/decode* primitives only. The NETWORK side of + * LNURL (`getInvoiceFromLud16`, ln-address → invoice) is NOT a scan concern — it folds into + * `createLightningQuote` (Slice 3/PR5), where the amount is known (§3). + * + * @module + */ + +export { + type DecodedBolt11, + parseBolt11Invoice, +} from '../../../../apps/web-wallet/app/lib/bolt11'; +export { extractCashuToken } from '../../../../apps/web-wallet/app/lib/cashu/token'; +export { buildLightningAddressFormatValidator } from '../../../../apps/web-wallet/app/lib/lnurl'; diff --git a/packages/wallet-sdk/src/internal/stub-domains.ts b/packages/wallet-sdk/src/internal/stub-domains.ts index ccdd3b12f..dfb9bf084 100644 --- a/packages/wallet-sdk/src/internal/stub-domains.ts +++ b/packages/wallet-sdk/src/internal/stub-domains.ts @@ -14,12 +14,10 @@ * @module */ import type { - AccountsDomain, BackgroundDomain, CashuDomain, ContactsDomain, ExchangeRateDomain, - ScanDomain, SparkDomain, TransactionsDomain, TransfersDomain, @@ -33,27 +31,11 @@ const unimplemented = (method: string): never => { }; /** - * Stub factories for the domains not yet implemented. `auth` + `user` (Slice 1) are no - * longer stubbed — they are real (`../domains/auth`, `../domains/user`), wired directly in - * `Sdk.create`. + * Stub factories for the domains not yet implemented. `auth` + `user` (Slice 1) and + * `accounts` + `scan` (Slice 2) are no longer stubbed — they are real (`../domains/*`), + * wired directly in `Sdk.create`. */ -/** Stub `AccountsDomain` (real impl: Slice 2). */ -export const createAccountsStub = (): AccountsDomain => ({ - list: () => unimplemented('accounts.list'), - get: () => unimplemented('accounts.get'), - getDefault: () => unimplemented('accounts.getDefault'), - add: () => unimplemented('accounts.add'), - setDefault: () => unimplemented('accounts.setDefault'), - getBalance: () => unimplemented('accounts.getBalance'), - suggestFor: () => unimplemented('accounts.suggestFor'), -}); - -/** Stub `ScanDomain` (real impl: Slice 2). */ -export const createScanStub = (): ScanDomain => ({ - parse: () => unimplemented('scan.parse'), -}); - /** Stub `CashuDomain` (`.send` + `.receive`; real impl: Slice 3). */ export const createCashuStub = (): CashuDomain => ({ send: { diff --git a/packages/wallet-sdk/src/internal/suggest-account.test.ts b/packages/wallet-sdk/src/internal/suggest-account.test.ts new file mode 100644 index 000000000..ef5f9300d --- /dev/null +++ b/packages/wallet-sdk/src/internal/suggest-account.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, test } from 'bun:test'; +import type { Account, CashuAccount, SparkAccount } from '../types/account'; +import { type Currency, Money } from '../types/money'; +import type { ParsedDestination, PaymentIntent } from '../types/scan'; +import { suggestAccountFor } from './suggest-account'; + +// -- Test builders ----------------------------------------------------------- + +/** + * Build a cashu test account. `sats` sets the balance via a single synthetic proof (the + * suggester reads balance through `getAccountBalance` → `sumProofs`, so one proof of `sats` + * is enough). `wallet`/`proofs` internals are cast — the pure suggester never touches them. + */ +function cashuAccount(opts: { + id: string; + sats: number; + isOnline?: boolean; + currency?: 'BTC' | 'USD'; + purpose?: Account['purpose']; + createdAt?: string; +}): CashuAccount { + return { + id: opts.id, + name: opts.id, + type: 'cashu', + purpose: opts.purpose ?? 'transactional', + state: 'active', + isOnline: opts.isOnline ?? true, + currency: opts.currency ?? 'BTC', + createdAt: opts.createdAt ?? '2026-01-01T00:00:00.000Z', + version: 1, + expiresAt: null, + mintUrl: `https://mint-${opts.id}.example.com`, + isTestMint: false, + keysetCounters: {}, + proofs: + opts.sats > 0 + ? ([{ amount: opts.sats }] as unknown as CashuAccount['proofs']) + : [], + wallet: {} as never, + }; +} + +/** Build a spark test account whose balance is `sats` (or null when omitted). */ +function sparkAccount(opts: { + id: string; + sats: number | null; + isOnline?: boolean; + currency?: 'BTC' | 'USD'; + createdAt?: string; +}): SparkAccount { + const currency = opts.currency ?? 'BTC'; + return { + id: opts.id, + name: opts.id, + type: 'spark', + purpose: 'transactional', + state: 'active', + isOnline: opts.isOnline ?? true, + currency, + createdAt: opts.createdAt ?? '2026-01-01T00:00:00.000Z', + version: 1, + expiresAt: null, + balance: + opts.sats === null + ? null + : new Money({ amount: opts.sats, currency, unit: 'sat' }), + network: 'MAINNET', + wallet: {} as never, + }; +} + +const bolt11Destination: ParsedDestination = { + kind: 'bolt11', + invoice: { + amountMsat: undefined, + amountSat: undefined, + createdAtUnixMs: 0, + expiryUnixMs: 0, + network: 'bitcoin', + description: undefined, + payeeNodeKey: '00'.repeat(33), + paymentHash: 'deadbeef', + }, +}; + +const sendSats = (sats: number): PaymentIntent => ({ + kind: 'send', + destination: bolt11Destination, + amount: new Money({ amount: sats, currency: 'BTC', unit: 'sat' }), +}); + +describe('suggestAccountFor', () => { + test('throws when there are no accounts', () => { + expect(() => suggestAccountFor(sendSats(100), [])).toThrow( + 'No accounts to choose from', + ); + }); + + describe('online filter', () => { + test('offline accounts are excluded from recommended/alternatives', () => { + const online = cashuAccount({ id: 'on', sats: 1000 }); + const offline = cashuAccount({ id: 'off', sats: 1000, isOnline: false }); + const result = suggestAccountFor(sendSats(100), [offline, online]); + expect(result.recommended.id).toBe('on'); + expect(result.alternatives).toHaveLength(0); + // The offline account is not surfaced as insufficient either (it was filtered out). + expect(result.insufficient).toHaveLength(0); + }); + + test('throws when the only account is offline (no candidate)', () => { + const offline = cashuAccount({ id: 'off', sats: 1000, isOnline: false }); + expect(() => suggestAccountFor(sendSats(100), [offline])).toThrow( + 'No account matches the payment intent', + ); + }); + }); + + describe('currency filter (BTC for a Lightning send)', () => { + test('USD accounts are excluded for a bolt11 send', () => { + const usd = cashuAccount({ id: 'usd', sats: 5000, currency: 'USD' }); + const btc = cashuAccount({ id: 'btc', sats: 5000, currency: 'BTC' }); + const result = suggestAccountFor(sendSats(100), [usd, btc]); + expect(result.recommended.id).toBe('btc'); + expect(result.recommended.currency).toBe('BTC'); + }); + }); + + describe('balance split', () => { + test('partitions accounts into sufficient vs insufficient', () => { + const rich = cashuAccount({ id: 'rich', sats: 5000 }); + const poor = cashuAccount({ id: 'poor', sats: 50 }); + const result = suggestAccountFor(sendSats(1000), [rich, poor]); + expect(result.recommended.id).toBe('rich'); + expect(result.insufficient.map((a) => a.id)).toEqual(['poor']); + }); + + test('amountless send needs only a positive balance', () => { + const positive = cashuAccount({ id: 'pos', sats: 1 }); + const empty = cashuAccount({ id: 'empty', sats: 0 }); + const intent: PaymentIntent = { + kind: 'send', + destination: bolt11Destination, + }; + const result = suggestAccountFor(intent, [empty, positive]); + expect(result.recommended.id).toBe('pos'); + expect(result.insufficient.map((a) => a.id)).toEqual(['empty']); + }); + }); + + describe('ranking (cheap-first, no cross-protocol cost comparison)', () => { + test('prefers a higher-priority purpose (offer > gift-card > transactional)', () => { + const txn = cashuAccount({ id: 'txn', sats: 5000 }); + const card = cashuAccount({ + id: 'card', + sats: 5000, + purpose: 'gift-card', + }); + const offer = cashuAccount({ id: 'offer', sats: 5000, purpose: 'offer' }); + const result = suggestAccountFor(sendSats(100), [txn, card, offer]); + expect(result.recommended.id).toBe('offer'); + expect(result.reason).toBe('offer-account match'); + // Remaining sufficient accounts ranked after, by the same comparator. + expect(result.alternatives.map((a) => a.id)).toEqual(['card', 'txn']); + }); + + test('within the same purpose, prefers the higher balance', () => { + const small = cashuAccount({ id: 'small', sats: 2000 }); + const big = cashuAccount({ id: 'big', sats: 9000 }); + const result = suggestAccountFor(sendSats(100), [small, big]); + expect(result.recommended.id).toBe('big'); + expect(result.alternatives.map((a) => a.id)).toEqual(['small']); + }); + + test('ties broken by creation date (older first)', () => { + const newer = cashuAccount({ + id: 'newer', + sats: 5000, + createdAt: '2026-02-01T00:00:00.000Z', + }); + const older = cashuAccount({ + id: 'older', + sats: 5000, + createdAt: '2026-01-01T00:00:00.000Z', + }); + const result = suggestAccountFor(sendSats(100), [newer, older]); + expect(result.recommended.id).toBe('older'); + }); + + test('mixes cashu + spark by balance (no protocol preference)', () => { + const cashu = cashuAccount({ id: 'cashu', sats: 3000 }); + const spark = sparkAccount({ id: 'spark', sats: 8000 }); + const result = suggestAccountFor(sendSats(100), [cashu, spark]); + expect(result.recommended.id).toBe('spark'); + }); + }); + + describe('default fallback (nothing has sufficient balance)', () => { + test('falls back to the user default account id when provided', () => { + const a = cashuAccount({ id: 'a', sats: 10 }); + const b = cashuAccount({ id: 'b', sats: 20 }); + const result = suggestAccountFor(sendSats(5000), [a, b], 'b'); + expect(result.recommended.id).toBe('b'); + expect(result.alternatives).toHaveLength(0); + expect(result.insufficient.map((x) => x.id)).toEqual(['a']); + expect(result.reason).toBe('insufficient balance; default account'); + }); + + test('falls back to the first insufficient account when no default id given', () => { + const a = cashuAccount({ id: 'a', sats: 10 }); + const b = cashuAccount({ id: 'b', sats: 20 }); + const result = suggestAccountFor(sendSats(5000), [a, b]); + expect(result.recommended.id).toBe('a'); + expect(result.insufficient.map((x) => x.id)).toEqual(['b']); + }); + }); + + describe('receive intents', () => { + test('receive does not require balance (any online account qualifies)', () => { + const empty = cashuAccount({ id: 'empty', sats: 0 }); + const intent: PaymentIntent = { + kind: 'receive', + amount: new Money({ + amount: 1000, + currency: 'BTC', + unit: 'sat', + }), + }; + const result = suggestAccountFor(intent, [empty]); + expect(result.recommended.id).toBe('empty'); + expect(result.insufficient).toHaveLength(0); + expect(result.reason).toContain('receive'); + }); + + test('token-receive does not constrain currency or balance', () => { + const usdEmpty = cashuAccount({ + id: 'usd', + sats: 0, + currency: 'USD', + }); + const intent: PaymentIntent = { + kind: 'token-receive', + token: 'cashuAfoo', + }; + const result = suggestAccountFor(intent, [usdEmpty]); + expect(result.recommended.id).toBe('usd'); + }); + + test('receive still applies the online filter', () => { + const offline = cashuAccount({ id: 'off', sats: 0, isOnline: false }); + const online = cashuAccount({ id: 'on', sats: 0 }); + const intent: PaymentIntent = { kind: 'receive' }; + const result = suggestAccountFor(intent, [offline, online]); + expect(result.recommended.id).toBe('on'); + }); + }); +}); diff --git a/packages/wallet-sdk/src/internal/suggest-account.ts b/packages/wallet-sdk/src/internal/suggest-account.ts new file mode 100644 index 000000000..fc685dddd --- /dev/null +++ b/packages/wallet-sdk/src/internal/suggest-account.ts @@ -0,0 +1,209 @@ +/** + * `suggestFor` — the NET-NEW, PURE account-suggestion logic (§2, Slice 2). + * + * There is no single backing function in master. This GENERALIZES master's + * `apps/web-wallet/app/features/send/find-matching-offer-or-gift-card-account.ts` (a pure + * offer/gift-card matcher over `accounts`) into a protocol-agnostic suggester, and folds in + * the two heuristics the UI applies around it: an **online filter** (`isOnline`, which gates + * `canSendToLightning`/`canReceiveFromLightning`) and a **default fallback** (the user's + * default account for the currency). It is **pure over the passed-in accounts** — no DB read, + * no live-wallet call — so the web wallet feeds its cached accounts for an instant result. + * + * Cheap-first, NO cross-protocol cost comparison (contract): the recommendation is the + * highest-priority account with sufficient balance (gift-card/offer match first, then a + * cheap-first ordering), with the remaining matches as `alternatives` and the + * matching-but-underfunded accounts as `insufficient`. The result carries a human-readable + * `reason`. + * + * Gift-card / offer accounts are out of SDK v1 (§13) and the gift-card *config* (the mint + * destination allow-list `findMatchingOfferOrGiftCardAccount` consults) is not part of the + * intent, so v1 cannot do destination-allow-list matching. What v1 generalizes is the + * *priority + filter + fallback shape*: `purpose: 'offer'` > `'gift-card'` > `'transactional'` + * is preserved as a priority ordering (so when offer/gift-card accounts DO appear they are + * preferred, matching master), then online + sufficient-balance filtering, then the default + * fallback. When gift-card config arrives in a later slice it slots in as a pre-filter. + * + * @module + */ +import { getAccountBalance } from './account-balance'; +import type { Account } from '../types/account'; +import type { Money } from '../types/money'; +import type { ParsedDestination, PaymentIntent } from '../types/scan'; +import type { AccountSuggestion } from '../types/account-config'; + +/** Priority by account purpose (higher = preferred), mirroring master's offer > gift-card order. */ +const PURPOSE_PRIORITY: Record = { + offer: 2, + 'gift-card': 1, + transactional: 0, +}; + +/** + * The currency a send intent must be paid from, when it can be inferred from the parsed + * destination. A BOLT11 / ln-address Lightning payment settles in BTC, so only BTC accounts + * can fund it (mirrors master `findMatchingOfferOrGiftCardAccount`, which skips USD accounts + * for BOLT11 melts). A cashu-token send/receive is mint-denominated and not currency-pinned + * here. Returns `undefined` when no currency constraint applies. + */ +function requiredCurrency( + destination: ParsedDestination | undefined, +): Account['currency'] | undefined { + if (!destination) { + return undefined; + } + if (destination.kind === 'bolt11' || destination.kind === 'ln-address') { + return 'BTC'; + } + return undefined; +} + +/** The amount an intent needs an account to cover, or `undefined` when amountless. */ +function intentAmount(intent: PaymentIntent): Money | undefined { + if (intent.kind === 'send') { + return intent.amount; + } + if (intent.kind === 'receive') { + return intent.amount; + } + // token-receive: amount is encoded in the token, not constrained over accounts here. + return undefined; +} + +/** + * Whether `account` can cover `amount`. With no amount (amountless invoice / open receive), + * a send needs only a positive balance; a receive has no balance requirement. + */ +function hasSufficientBalance( + account: Account, + amount: Money | undefined, + isReceive: boolean, +): boolean { + if (isReceive) { + // Receiving doesn't spend the account's balance. + return true; + } + const balance = getAccountBalance(account); + if (!balance) { + return false; + } + return amount ? balance.greaterThanOrEqual(amount) : balance.isPositive(); +} + +/** + * Compare two candidate accounts for recommendation priority (cheap-first, no cross-protocol + * cost comparison): higher `purpose` priority first, then higher balance, then older account + * (stable by `createdAt`). Returns a negative number when `a` should rank before `b`. + */ +function compareCandidates(a: Account, b: Account): number { + const byPurpose = PURPOSE_PRIORITY[b.purpose] - PURPOSE_PRIORITY[a.purpose]; + if (byPurpose !== 0) { + return byPurpose; + } + + const balanceA = getAccountBalance(a); + const balanceB = getAccountBalance(b); + if (balanceA && balanceB && !balanceA.equals(balanceB)) { + return balanceB.greaterThan(balanceA) ? 1 : -1; + } + + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); +} + +/** + * Recommend which of `accounts` to use for `intent`. PURE — no DB read, no live-wallet call. + * + * Pipeline: + * 1. **Currency filter** — for a Lightning send, keep only BTC accounts (master parity). + * 2. **Online filter** — keep only `isOnline` accounts (the offline ones can't transact; + * they are not surfaced as recommendations/alternatives). + * 3. **Balance split** — partition the matching accounts into sufficient vs insufficient. + * 4. **Rank** — order the sufficient accounts cheap-first (purpose priority → balance → age) + * and pick the top one as `recommended`, the rest as `alternatives`. + * 5. **Default fallback** — if nothing has sufficient balance, fall back to the user's + * default account for the (filtered) currency when one is present in `accounts`, so the + * UI can still pre-select an account to top up; otherwise throw (no candidate at all). + * + * @param intent - what the user wants to do. + * @param accounts - the accounts to choose from (the caller's cached set). + * @param defaultAccountId - the user's default account id for the intent's currency, if known + * (used only for the fallback when no account has sufficient balance). + * @returns the {@link AccountSuggestion}. + * @throws Error if `accounts` is empty or no account matches the intent at all. + */ +export function suggestAccountFor( + intent: PaymentIntent, + accounts: Account[], + defaultAccountId?: string, +): AccountSuggestion { + if (accounts.length === 0) { + throw new Error('No accounts to choose from'); + } + + const isReceive = + intent.kind === 'receive' || intent.kind === 'token-receive'; + const destination = intent.kind === 'send' ? intent.destination : undefined; + const currency = requiredCurrency(destination); + const amount = intentAmount(intent); + + // 1 + 2: currency + online filter. + const eligible = accounts.filter((account) => { + if (currency && account.currency !== currency) { + return false; + } + return account.isOnline; + }); + + // 3: split by sufficient balance. + const sufficient: Account[] = []; + const insufficient: Account[] = []; + for (const account of eligible) { + if (hasSufficientBalance(account, amount, isReceive)) { + sufficient.push(account); + } else { + insufficient.push(account); + } + } + + // 4: rank the sufficient candidates cheap-first. + if (sufficient.length > 0) { + const ranked = [...sufficient].sort(compareCandidates); + const [recommended, ...alternatives] = ranked; + return { + recommended, + alternatives, + insufficient, + reason: reasonFor(recommended, isReceive), + }; + } + + // 5: default fallback — nothing has sufficient balance. Prefer the user's default account + // (for the eligible currency) so the UI can still pre-select; else the first insufficient. + const fallback = + (defaultAccountId && eligible.find((a) => a.id === defaultAccountId)) || + insufficient[0]; + + if (!fallback) { + throw new Error('No account matches the payment intent'); + } + + return { + recommended: fallback, + alternatives: [], + insufficient: insufficient.filter((a) => a.id !== fallback.id), + reason: isReceive + ? 'default account' + : 'insufficient balance; default account', + }; +} + +/** Human-readable basis for a recommendation. */ +function reasonFor(account: Account, isReceive: boolean): string { + if (account.purpose === 'offer') { + return 'offer-account match'; + } + if (account.purpose === 'gift-card') { + return 'gift-card-mint match'; + } + const verb = isReceive ? 'receive to' : 'send from'; + return `${verb} ${account.type} account`; +} diff --git a/packages/wallet-sdk/src/internal/user-repository.ts b/packages/wallet-sdk/src/internal/user-repository.ts index dc7eed645..8f8074bc8 100644 --- a/packages/wallet-sdk/src/internal/user-repository.ts +++ b/packages/wallet-sdk/src/internal/user-repository.ts @@ -8,15 +8,16 @@ * are plain async methods over the SDK-owned client (passed in), reading/writing the * `wallet.users` table and mapping rows via {@link dbUserToUser}. * - * Only the two reads/writes the auth + user domains need are ported: fetch-by-id and - * username update. The rest of master's user repository (upsert-with-accounts, default - * account resolution) belongs to later slices. + * The reads/writes the auth + user + accounts domains need are ported: fetch-by-id, + * username update, and the default-account update (Slice 2's `accounts.setDefault`). The + * rest of master's user repository (upsert-with-accounts) belongs to later slices. * * @module */ import { DomainError, NotFoundError } from '../errors'; import type { WalletSupabaseClient } from './supabase-client'; import { type AgicashDbUser, dbUserToUser } from './db-user'; +import type { Currency } from '../types/money'; import type { User } from '../types/user'; /** @@ -101,4 +102,44 @@ export class UserRepository { return dbUserToUser(data); } + + /** + * Update the user's default-account columns and return the updated domain {@link User}. + * + * Re-houses master `WriteUserRepository.update` (the default-account path used by + * `user-service.setDefaultAccount`): writes `default_btc_account_id` / + * `default_usd_account_id` / `default_currency`. The account domain computes which + * column changes from the account's currency; this is the thin row-write. + * + * @param userId - the user id. + * @param defaults - the new default-account ids + currency to persist. + * @returns the updated domain user. + * @throws Error if the update fails (e.g. the DB constraint that a default currency must + * have a default account set). + */ + async setDefaultAccount( + userId: string, + defaults: { + defaultBtcAccountId: string | null; + defaultUsdAccountId: string | null; + defaultCurrency: Currency; + }, + ): Promise { + const { data, error } = await this.db + .from('users') + .update({ + default_btc_account_id: defaults.defaultBtcAccountId, + default_usd_account_id: defaults.defaultUsdAccountId, + default_currency: defaults.defaultCurrency, + }) + .eq('id', userId) + .select() + .single(); + + if (error) { + throw new Error('Failed to update user', { cause: error }); + } + + return dbUserToUser(data); + } } diff --git a/packages/wallet-sdk/src/sdk.ts b/packages/wallet-sdk/src/sdk.ts index 7aa6ac670..1b86241f7 100644 --- a/packages/wallet-sdk/src/sdk.ts +++ b/packages/wallet-sdk/src/sdk.ts @@ -31,19 +31,21 @@ import type { UserDomain, } from './domains'; import type { EventEmitter, SdkEventMap } from './events'; +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 { AccountRepository } from './internal/account-repository'; import { TypedEventEmitter } from './internal/event-emitter'; import { GuestAccountStorage } from './internal/guest-account-storage'; import { OpenSecretClient } from './internal/open-secret'; import { SessionResolver } from './internal/session'; import { - createAccountsStub, createBackgroundStub, createCashuStub, createContactsStub, createExchangeRateStub, - createScanStub, createSparkStub, createTransactionsStub, createTransfersStub, @@ -145,27 +147,34 @@ export class Sdk { /** * Private — construct via {@link Sdk.create}. Takes the assembled connection bundle plus - * the already-built real domains (`auth` + `user` as of Slice 1) and wires the domain - * accessors. Domains not yet implemented are wired to a stub; later slices replace each - * stub here with its real implementation. + * the already-built real domains (`auth` + `user` as of Slice 1; `accounts` + `scan` as of + * Slice 2) and wires the domain accessors. Domains not yet implemented are wired to a stub; + * later slices replace each stub here with its real implementation. * * @param connections - the shared connection bundle. * @param domains - the real domain implementations built in {@link Sdk.create}. */ private constructor( connections: SdkConnections, - domains: { auth: AuthDomain; user: UserDomain }, + domains: { + auth: AuthDomain; + user: UserDomain; + accounts: AccountsDomain; + scan: ScanDomain; + }, ) { this.connections = connections; this.events = connections.events; - // --- real domains (Slice 1: auth + user) --------------------------------- + // --- real domains -------------------------------------------------------- + // Slice 1: auth + user. this.auth = domains.auth; this.user = domains.user; + // Slice 2: accounts + scan. + this.accounts = domains.accounts; + this.scan = domains.scan; // --- domain accessors still STUBBED (swap per slice) --------------------- - this.accounts = createAccountsStub(); - this.scan = createScanStub(); this.cashu = createCashuStub(); this.spark = createSparkStub(); this.transactions = createTransactionsStub(); @@ -242,7 +251,24 @@ export class Sdk { const auth = new AuthDomainImpl(openSecret, session, guestStorage); const user = new UserDomainImpl(session, users); - return new Sdk(connections, { auth, user }); + // --- 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(); + const accountRepository = new AccountRepository( + supabase, + accountHandleResolver, + ); + const accounts = new AccountsDomainImpl(accountRepository, users, session); + // Scan is connection-free (pure decode). `allowLocalhost` defaults to false (production); + // master gates it on `import.meta.env.MODE === 'development'`, which the framework-free + // SDK does not read. + const scan = new ScanDomainImpl(); + + return new Sdk(connections, { auth, user, accounts, scan }); } /**