diff --git a/bun.lock b/bun.lock index 81afeac54..9d9d660de 100644 --- a/bun.lock +++ b/bun.lock @@ -108,6 +108,12 @@ }, "packages/wallet-sdk": { "name": "@agicash/wallet-sdk", + "dependencies": { + "@agicash/opensecret": "catalog:", + "@cashu/cashu-ts": "3.6.1", + "@supabase/supabase-js": "2.95.2", + "jwt-decode": "4.0.0", + }, "devDependencies": { "typescript": "catalog:", }, diff --git a/packages/wallet-sdk/package.json b/packages/wallet-sdk/package.json index d713ef9a3..bf65478f2 100644 --- a/packages/wallet-sdk/package.json +++ b/packages/wallet-sdk/package.json @@ -7,7 +7,14 @@ "./*": "./src/*" }, "scripts": { - "typecheck": "tsc" + "typecheck": "tsc", + "test": "bun test" + }, + "dependencies": { + "@agicash/opensecret": "catalog:", + "@cashu/cashu-ts": "3.6.1", + "@supabase/supabase-js": "2.95.2", + "jwt-decode": "4.0.0" }, "devDependencies": { "typescript": "catalog:" diff --git a/packages/wallet-sdk/src/classify.test.ts b/packages/wallet-sdk/src/classify.test.ts new file mode 100644 index 000000000..862534cf1 --- /dev/null +++ b/packages/wallet-sdk/src/classify.test.ts @@ -0,0 +1,197 @@ +import { + HttpResponseError, + MintOperationError, + NetworkError, +} from '@cashu/cashu-ts'; +import { describe, expect, test } from 'bun:test'; +import { classify } from './classify'; +import { + ConcurrencyError, + DomainError, + NotFoundError, + NotImplementedError, +} from './errors'; +import { CashuErrorCodes } from './internal/cashu-error-codes'; + +describe('classify', () => { + describe('transient', () => { + test('ConcurrencyError (SDK optimistic-lock signal)', () => { + expect(classify(new ConcurrencyError('stale', 'CONCURRENCY_ERROR'))).toBe( + 'transient', + ); + }); + + test('NetworkError (connectivity/transport failure)', () => { + expect(classify(new NetworkError('connection refused'))).toBe( + 'transient', + ); + }); + + test('bare HttpResponseError such as 429 / 5xx', () => { + expect(classify(new HttpResponseError('rate limited', 429))).toBe( + 'transient', + ); + expect(classify(new HttpResponseError('bad gateway', 502))).toBe( + 'transient', + ); + }); + + test('in-flight-elsewhere mint codes (OUTPUTS/PROOFS pending, quote pending)', () => { + expect( + classify( + new MintOperationError( + CashuErrorCodes.OUTPUTS_ARE_PENDING, // 11004 + 'outputs are pending', + ), + ), + ).toBe('transient'); + expect( + classify( + new MintOperationError( + CashuErrorCodes.PROOFS_ARE_PENDING, // 11002 + 'proofs are pending', + ), + ), + ).toBe('transient'); + expect( + classify( + new MintOperationError( + CashuErrorCodes.QUOTE_PENDING, // 20005 + 'quote is pending', + ), + ), + ).toBe('transient'); + }); + }); + + describe('already-resolved', () => { + test('token already spent', () => { + expect( + classify( + new MintOperationError( + CashuErrorCodes.TOKEN_ALREADY_SPENT, // 11001 + 'Token already spent', + ), + ), + ).toBe('already-resolved'); + }); + + test('output already signed', () => { + expect( + classify( + new MintOperationError( + CashuErrorCodes.OUTPUT_ALREADY_SIGNED, // 11003 + 'Output already signed', + ), + ), + ).toBe('already-resolved'); + }); + + test('quote already issued', () => { + expect( + classify( + new MintOperationError( + CashuErrorCodes.QUOTE_ALREADY_ISSUED, // 20002 + 'Tokens already issued for quote', + ), + ), + ).toBe('already-resolved'); + }); + + test('invoice already paid', () => { + expect( + classify( + new MintOperationError( + CashuErrorCodes.INVOICE_ALREADY_PAID, // 20006 + 'Invoice already paid', + ), + ), + ).toBe('already-resolved'); + }); + }); + + describe('permanent', () => { + test('DomainError (definitive, user-facing failure)', () => { + expect(classify(new DomainError('insufficient balance', 'DOMAIN'))).toBe( + 'permanent', + ); + }); + + test('NotFoundError (requested entity missing)', () => { + expect( + classify(new NotFoundError('account not found', 'NOT_FOUND')), + ).toBe('permanent'); + }); + + test('other mint rejections (deterministic, never succeed on retry)', () => { + // transaction not balanced + expect( + classify( + new MintOperationError( + CashuErrorCodes.TRANSACTION_NOT_BALANCED, // 11005 + 'Transaction is not balanced', + ), + ), + ).toBe('permanent'); + // amount out of limits + expect( + classify( + new MintOperationError( + CashuErrorCodes.AMOUNT_OUT_OF_LIMITS, // 11006 + 'Amount outside of limit range', + ), + ), + ).toBe('permanent'); + // keyset inactive + expect( + classify( + new MintOperationError( + CashuErrorCodes.KEYSET_INACTIVE, // 12002 + 'Keyset is inactive', + ), + ), + ).toBe('permanent'); + // quote not paid + expect( + classify( + new MintOperationError( + CashuErrorCodes.QUOTE_NOT_PAID, // 20001 + 'Quote is not paid', + ), + ), + ).toBe('permanent'); + // blind auth required + expect( + classify( + new MintOperationError( + CashuErrorCodes.BLIND_AUTH_REQUIRED, // 31001 + 'Endpoint requires blind auth', + ), + ), + ).toBe('permanent'); + }); + }); + + describe('unhandled', () => { + test('a plain Error is not recognised', () => { + expect(classify(new Error('boom'))).toBe('unhandled'); + }); + + test('a TypeError is not recognised', () => { + expect(classify(new TypeError('x is not a function'))).toBe('unhandled'); + }); + + test('non-Error thrown values (string, null, undefined, object)', () => { + expect(classify('a string')).toBe('unhandled'); + expect(classify(null)).toBe('unhandled'); + expect(classify(undefined)).toBe('unhandled'); + expect(classify({ code: 11004 })).toBe('unhandled'); + }); + + test('NotImplementedError is an SdkError but not Domain/NotFound -> unhandled', () => { + // NotImplementedError extends SdkError but neither DomainError nor + // NotFoundError, so classify falls through to 'unhandled'. + expect(classify(new NotImplementedError('cashu.send'))).toBe('unhandled'); + }); + }); +}); diff --git a/packages/wallet-sdk/src/classify.ts b/packages/wallet-sdk/src/classify.ts new file mode 100644 index 000000000..033592c0e --- /dev/null +++ b/packages/wallet-sdk/src/classify.ts @@ -0,0 +1,119 @@ +/** + * Error classifier — §12 of the contract (the locked 4-bucket seam). + * + * `classify(err)` is a PURE function returning a bare 4-bucket string union + * (gudnuf's no-repair-hints ruling: NO repair hints, services handle the + * specifics). It is consumed by BOTH the SDK `executeQuote` orchestrator AND + * (later) the web hooks. The orchestrator maps the verdict onto its error model: + * + * | verdict | meaning | orchestrator action | + * | ------------------ | ----------------------------------------- | ---------------------------- | + * | `transient` | stale / in-flight / connectivity | retry / recover (→ `ConcurrencyError`) | + * | `permanent` | the mint/peer rejected it; never succeeds | surface (→ `DomainError`) | + * | `already-resolved` | the operation already happened | no-op (reconcile via restore) | + * | `unhandled` | not recognised | propagate | + * + * GROUNDING. The mapping ports master's hook-resident branching, which gates retry + * purely on `error instanceof MintOperationError` (a mint-emitted protocol error → + * no retry, fail the quote) vs everything else (→ retry up to 3×). See + * `app/features/send/cashu-send-quote-hooks.ts` (~L331) + + * `app/features/receive/cashu-receive-quote-hooks.ts` (~L704). This function refines + * that binary into the 4 buckets using the NUT `code` carried on `MintOperationError`: + * - in-flight-elsewhere codes (OUTPUTS/PROOFS pending — #1115 mapped 11004/11002 + * → transient) become `transient` (a parallel op holds them; retry/recover); + * - already-happened codes (already-spent / already-issued / already-paid / + * output-already-signed) become `already-resolved` (recovery = restore, no-op); + * - every other mint rejection stays `permanent`. + * cashu-ts `NetworkError` and bare `HttpResponseError` (e.g. 429 / 5xx — the master + * receive hook treats 429 as retryable) are connectivity/transport → `transient`. + * + * @module + */ +import { + HttpResponseError, + MintOperationError, + NetworkError, +} from '@cashu/cashu-ts'; +import { ConcurrencyError, DomainError, NotFoundError } from './errors'; +import { CashuErrorCodes } from './internal/cashu-error-codes'; + +/** + * The four buckets every error collapses into. Bare strings — no repair hints + * (a verdict, not an instruction). The caller/orchestrator owns the response. + */ +export type ErrorClass = + | 'transient' + | 'permanent' + | 'already-resolved' + | 'unhandled'; + +/** + * Mint NUT codes meaning "a parallel operation currently holds these proofs/outputs". + * The work has NOT completed — retrying (or recovering via `restore`) is correct. + * #1115 mapped 11004 / 11002 → transient; kept consistent here. + */ +const TRANSIENT_MINT_CODES: ReadonlySet = new Set([ + CashuErrorCodes.OUTPUTS_ARE_PENDING, // 11004 + CashuErrorCodes.PROOFS_ARE_PENDING, // 11002 + CashuErrorCodes.QUOTE_PENDING, // 20005 — quote still settling +]); + +/** + * Mint NUT codes meaning "this already happened" — the desired end-state is reached, + * so recovery is a no-op (reconcile local state via `wallet.restore` / idempotent + * re-fetch). These must NOT be retried (the second attempt would error) and must NOT + * be surfaced as a user-facing failure. + */ +const ALREADY_RESOLVED_MINT_CODES: ReadonlySet = new Set([ + CashuErrorCodes.TOKEN_ALREADY_SPENT, // 11001 + CashuErrorCodes.OUTPUT_ALREADY_SIGNED, // 11003 + CashuErrorCodes.QUOTE_ALREADY_ISSUED, // 20002 + CashuErrorCodes.INVOICE_ALREADY_PAID, // 20006 +]); + +/** + * Classify an arbitrary thrown value into one of the four recovery buckets. + * + * PURE: no side effects, no I/O, no logging — safe to call from anywhere (orchestrator, + * web hook, test). Unknown shapes return `'unhandled'` so the caller propagates rather + * than silently swallowing. + * + * @param err - the thrown value (typed `unknown`; this function narrows it). + * @returns the 4-bucket verdict. + */ +export function classify(err: unknown): ErrorClass { + // --- SDK-native errors ----------------------------------------------------- + // ConcurrencyError is the SDK's own "stale, refetch + retry" signal (DB optimistic + // lock) — transient by definition. DomainError / NotFoundError are terminal. + if (err instanceof ConcurrencyError) { + return 'transient'; + } + if (err instanceof DomainError || err instanceof NotFoundError) { + return 'permanent'; + } + + // --- cashu-ts protocol errors --------------------------------------------- + // MintOperationError extends HttpResponseError, so check it FIRST (most specific). + if (err instanceof MintOperationError) { + if (TRANSIENT_MINT_CODES.has(err.code)) { + return 'transient'; + } + if (ALREADY_RESOLVED_MINT_CODES.has(err.code)) { + return 'already-resolved'; + } + // Any other mint rejection (unbalanced tx, unit mismatch, keyset inactive, + // amount-out-of-limits, auth required, …) is a deterministic rejection: retrying + // the same request will fail identically. Surface it. + return 'permanent'; + } + + // A NetworkError or a non-mint HTTP error (timeout, 429, 5xx, connection refused) + // is a transport/connectivity failure — retry. Master's receive hook explicitly + // treats HTTP 429 as retryable. + if (err instanceof NetworkError || err instanceof HttpResponseError) { + return 'transient'; + } + + // --- unknown --------------------------------------------------------------- + return 'unhandled'; +} diff --git a/packages/wallet-sdk/src/errors.ts b/packages/wallet-sdk/src/errors.ts index 65811636e..8df1c8165 100644 --- a/packages/wallet-sdk/src/errors.ts +++ b/packages/wallet-sdk/src/errors.ts @@ -4,7 +4,7 @@ * `SdkError` (base + `readonly code`) is NET-NEW (master's errors have no shared * base / no `code`). `ConcurrencyError` / `DomainError` / `NotFoundError` are * re-parented onto `SdkError` (master forms live in `app/features/shared/error.ts`). - * PR1 ships the class SHAPES only — empty bodies, no logic. + * These are the REAL runtime classes (consumed by `classify()` + the domain stubs). */ /** @@ -34,3 +34,18 @@ export class DomainError extends SdkError {} /** The requested entity does not exist. */ export class NotFoundError extends SdkError {} + +/** + * A method that exists on the contract but whose implementation has not landed yet + * (a later build slice fills it in). Thrown by the domain stubs the `Sdk` shell wires + * in PR2 so calling an unimplemented method fails loudly + identifiably rather than + * returning `undefined`. NOT a runtime error model the orchestrator recovers from. + */ +export class NotImplementedError extends SdkError { + constructor(method: string) { + super( + `${method} is not implemented yet (wired by a later @agicash/wallet-sdk build slice)`, + 'NOT_IMPLEMENTED', + ); + } +} diff --git a/packages/wallet-sdk/src/events.ts b/packages/wallet-sdk/src/events.ts index 533327475..c59c63a8b 100644 --- a/packages/wallet-sdk/src/events.ts +++ b/packages/wallet-sdk/src/events.ts @@ -1,9 +1,12 @@ /** * Event layer — §11 of the contract. FULLY NET-NEW (no master EventEmitter). * - * PR1 ships the `SdkEventMap` keys + the `EventEmitter` INTERFACE (declaration - * only — no implementation). `BackgroundState` is defined here (used by - * `background:state` and `BackgroundDomain.state()`). + * This module owns the `SdkEventMap` keys + the PUBLIC `EventEmitter` interface + * (subscribe-only: `on` / `once`). The runtime backing — `TypedEventEmitter`, which + * also has the internal `emit` / `off` — lands in `./internal/event-emitter` (PR2). The + * `Sdk` exposes the instance typed as this narrow interface so consumers can subscribe + * but not publish. `BackgroundState` is defined here (used by `background:state` and + * `BackgroundDomain.state()`). */ import type { Account } from './types/account'; import type { Contact } from './types/contact'; diff --git a/packages/wallet-sdk/src/index.ts b/packages/wallet-sdk/src/index.ts index 6994930f0..8a35dc8d3 100644 --- a/packages/wallet-sdk/src/index.ts +++ b/packages/wallet-sdk/src/index.ts @@ -1,20 +1,27 @@ /** - * @agicash/wallet-sdk — public contract (PR1: types + interfaces, no impl). + * @agicash/wallet-sdk — public entry barrel. * - * This barrel is the package's single public entry (`exports["."]`). It re-exports - * the public domain TYPES + domain INTERFACES + the `Sdk` class shape + `SdkConfig` - * + the event layer + the error classes. No implementation lands in PR1. + * This is the package's single public entry (`exports["."]`). It re-exports the public + * domain TYPES + domain INTERFACES + the `Sdk` class + `SdkConfig` + the event layer + + * the error classes + the `classify` seam. + * + * PR1 shipped the contract (types + interfaces + `declare class Sdk`). PR2 (core) lands + * the CORE implementation: the real `Money` value export, the runtime error classes + + * the pure `classify()`, the typed event emitter, and the `Sdk.create` shell with + * OpenSecret / Supabase / storage wiring. Domain business logic (auth, accounts, scan, + * cashu, spark, transactions, contacts, transfers, background) is STUBBED until each + * later slice lands — calling a stubbed method throws `NotImplementedError`. */ // --- entry point + config -------------------------------------------------- -export type { Sdk } from './sdk'; +// `Sdk` is now a real VALUE export (PR2 implemented the class) — `Sdk.create(...)`. +export { Sdk } from './sdk'; export type { SdkConfig } from './config'; // --- value types ----------------------------------------------------------- -// `Money` is a TYPE-ONLY export in PR1 (it is a placeholder `declare class` with -// no runtime binding — see ./types/money). Slice 0 replaces it with a real -// re-export of `app/lib/money`'s `Money`, at which point this becomes a value export. -export type { Money } from './types/money'; +// `Money` is now a real VALUE export (Slice 0 resolved PR1's placeholder): it +// re-exports the live `Money` class from `app/lib/money` — see ./types/money. +export { Money } from './types/money'; export type { Currency, CurrencyUnit, BtcUnit, UsdUnit } from './types/money'; // --- domain interfaces ----------------------------------------------------- @@ -45,8 +52,13 @@ export { ConcurrencyError, DomainError, NotFoundError, + NotImplementedError, } from './errors'; +// --- error classifier (§12 — pure 4-bucket seam) --------------------------- +export { classify } from './classify'; +export type { ErrorClass } from './classify'; + // --- accounts (§2) --------------------------------------------------------- export type { Account, diff --git a/packages/wallet-sdk/src/internal/cashu-error-codes.ts b/packages/wallet-sdk/src/internal/cashu-error-codes.ts new file mode 100644 index 000000000..fe71ea720 --- /dev/null +++ b/packages/wallet-sdk/src/internal/cashu-error-codes.ts @@ -0,0 +1,227 @@ +/** + * Cashu NUT error codes — INTERNAL to the SDK (§12: `lib/cashu` is internal-only). + * + * Lifted VERBATIM from `apps/web-wallet/app/lib/cashu/error-codes.ts` (the canonical + * source maintained against https://github.com/cashubtc/nuts/blob/main/error_codes.md). + * Consumed by `src/classify.ts` to map mint-emitted error codes into the 4-bucket + * `classify()` verdict. NOT part of the public barrel. + * + * TODO(follow-up): when `app/lib/cashu/**` is relocated into the package wholesale, + * fold this back into the single internal `lib/cashu` so there is one copy. + */ + +export enum CashuErrorCodes { + /** + * Proof verification failed + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + TOKEN_VERIFICATION_FAILED = 10001, + + /** + * Proofs already spent + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + TOKEN_ALREADY_SPENT = 11001, + + /** + * Proofs are pending (in flight in a parallel operation) + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + PROOFS_ARE_PENDING = 11002, + + /** + * Blinded message of output already signed + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + OUTPUT_ALREADY_SIGNED = 11003, + + /** + * Outputs are pending (in flight in a parallel operation) + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + OUTPUTS_ARE_PENDING = 11004, + + /** + * Transaction is not balanced (inputs != outputs) + * Relevant nuts: @see [NUT-02](https://github.com/cashubtc/nuts/blob/main/02.md), [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + TRANSACTION_NOT_BALANCED = 11005, + + /** + * Amount outside of limit range + * Relevant nuts: @see [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + AMOUNT_OUT_OF_LIMITS = 11006, + + /** + * Duplicate inputs provided + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + DUPLICATE_INPUTS = 11007, + + /** + * Duplicate outputs provided + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + DUPLICATE_OUTPUTS = 11008, + + /** + * Inputs/Outputs of multiple units + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + MULTIPLE_UNITS = 11009, + + /** + * Inputs and outputs not of same unit + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + UNIT_MISMATCH = 11010, + + /** + * Amountless invoice is not supported + * Relevant nuts: @see [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + AMOUNTLESS_INVOICE_UNSUPPORTED = 11011, + + /** + * Amount in request does not equal invoice amount + * Relevant nuts: @see [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + AMOUNT_MISMATCH = 11012, + + /** + * Unit in request is not supported + * Relevant nuts: @see [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + UNIT_NOT_SUPPORTED = 11013, + + /** + * Maximum number of inputs exceeded for a single request + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + MAX_INPUTS_EXCEEDED = 11014, + + /** + * Maximum number of outputs exceeded for a single request + * Relevant nuts: @see [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + MAX_OUTPUTS_EXCEEDED = 11015, + + /** + * Duplicate quote IDs provided in a batched request + */ + DUPLICATE_QUOTE_IDS = 11016, + + /** + * Maximum batch size exceeded for a batched request + */ + MAX_BATCH_SIZE_EXCEEDED = 11017, + + /** + * Keyset is not known + * Relevant nuts: @see [NUT-02](https://github.com/cashubtc/nuts/blob/main/02.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md) + */ + KEYSET_UNKNOWN = 12001, + + /** + * Keyset is inactive, cannot sign messages + * Relevant nuts: @see [NUT-02](https://github.com/cashubtc/nuts/blob/main/02.md), [NUT-03](https://github.com/cashubtc/nuts/blob/main/03.md), [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md) + */ + KEYSET_INACTIVE = 12002, + + /** + * Keyset has expired + * Relevant nuts: @see [NUT-02](https://github.com/cashubtc/nuts/blob/main/02.md) + */ + KEYSET_EXPIRED = 12003, + + /** + * Quote request is not paid + * Relevant nuts: @see [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md) + */ + QUOTE_NOT_PAID = 20001, + + /** + * Tokens have already been issued for quote + * Relevant nuts: @see [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md) + */ + QUOTE_ALREADY_ISSUED = 20002, + + /** + * Minting is disabled + * Relevant nuts: @see [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md) + */ + MINTING_DISABLED = 20003, + + /** + * Lightning payment failed + * Relevant nuts: @see [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + LIGHTNING_PAYMENT_FAILED = 20004, + + /** + * Quote is pending + * Relevant nuts: @see [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + QUOTE_PENDING = 20005, + + /** + * Invoice already paid + * Relevant nuts: @see [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + INVOICE_ALREADY_PAID = 20006, + + /** + * Quote is expired + * Relevant nuts: @see [NUT-04](https://github.com/cashubtc/nuts/blob/main/04.md), [NUT-05](https://github.com/cashubtc/nuts/blob/main/05.md) + */ + QUOTE_EXPIRED = 20007, + + /** + * Signature for mint request invalid + * Relevant nuts: @see [NUT-20](https://github.com/cashubtc/nuts/blob/main/20.md) + */ + INVALID_MINT_SIGNATURE = 20008, + + /** + * Pubkey required for mint quote + * Relevant nuts: @see [NUT-20](https://github.com/cashubtc/nuts/blob/main/20.md) + */ + PUBKEY_REQUIRED = 20009, + + /** + * Endpoint requires clear auth + * Relevant nuts: @see [NUT-21](https://github.com/cashubtc/nuts/blob/main/21.md) + */ + CLEAR_AUTH_REQUIRED = 30001, + + /** + * Clear authentication failed + * Relevant nuts: @see [NUT-21](https://github.com/cashubtc/nuts/blob/main/21.md) + */ + CLEAR_AUTH_FAILED = 30002, + + /** + * Endpoint requires blind auth + * Relevant nuts: @see [NUT-22](https://github.com/cashubtc/nuts/blob/main/22.md) + */ + BLIND_AUTH_REQUIRED = 31001, + + /** + * Blind authentication failed + * Relevant nuts: @see [NUT-22](https://github.com/cashubtc/nuts/blob/main/22.md) + */ + BLIND_AUTH_FAILED = 31002, + + /** + * Maximum BAT mint amount exceeded + * Relevant nuts: @see [NUT-22](https://github.com/cashubtc/nuts/blob/main/22.md) + */ + BAT_MINT_AMOUNT_EXCEEDED = 31003, + + /** + * BAT mint rate limit exceeded + * Relevant nuts: @see [NUT-22](https://github.com/cashubtc/nuts/blob/main/22.md) + */ + BAT_MINT_RATE_LIMIT_EXCEEDED = 31004, +} diff --git a/packages/wallet-sdk/src/internal/event-emitter.test.ts b/packages/wallet-sdk/src/internal/event-emitter.test.ts new file mode 100644 index 000000000..3a3f08a86 --- /dev/null +++ b/packages/wallet-sdk/src/internal/event-emitter.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from 'bun:test'; +import { TypedEventEmitter } from './event-emitter'; + +/** A small event map for exercising the emitter. */ +type TestEventMap = { + ping: { n: number }; + pong: { label: string }; +}; + +describe('TypedEventEmitter', () => { + test('on + emit delivers the payload to the handler', () => { + const ee = new TypedEventEmitter(); + const received: Array<{ n: number }> = []; + + ee.on('ping', (data) => received.push(data)); + ee.emit('ping', { n: 1 }); + + expect(received).toEqual([{ n: 1 }]); + }); + + test('on stays subscribed across multiple emits', () => { + const ee = new TypedEventEmitter(); + const seen: number[] = []; + + ee.on('ping', (data) => seen.push(data.n)); + ee.emit('ping', { n: 1 }); + ee.emit('ping', { n: 2 }); + ee.emit('ping', { n: 3 }); + + expect(seen).toEqual([1, 2, 3]); + }); + + test('once fires exactly once then auto-unsubscribes', () => { + const ee = new TypedEventEmitter(); + const seen: number[] = []; + + ee.once('ping', (data) => seen.push(data.n)); + ee.emit('ping', { n: 1 }); + ee.emit('ping', { n: 2 }); + ee.emit('ping', { n: 3 }); + + expect(seen).toEqual([1]); + }); + + test('the unsubscribe fn returned by on stops further delivery', () => { + const ee = new TypedEventEmitter(); + const seen: number[] = []; + + const off = ee.on('ping', (data) => seen.push(data.n)); + ee.emit('ping', { n: 1 }); + off(); + ee.emit('ping', { n: 2 }); + + expect(seen).toEqual([1]); + }); + + test('calling the unsubscribe fn twice is a no-op (idempotent)', () => { + const ee = new TypedEventEmitter(); + const seen: number[] = []; + + const off = ee.on('ping', (data) => seen.push(data.n)); + off(); + expect(() => off()).not.toThrow(); + ee.emit('ping', { n: 1 }); + + expect(seen).toEqual([]); + }); + + test('off removes a specific handler by reference', () => { + const ee = new TypedEventEmitter(); + const a: number[] = []; + const b: number[] = []; + const handlerA = (data: { n: number }) => a.push(data.n); + const handlerB = (data: { n: number }) => b.push(data.n); + + ee.on('ping', handlerA); + ee.on('ping', handlerB); + ee.off('ping', handlerA); + ee.emit('ping', { n: 1 }); + + expect(a).toEqual([]); + expect(b).toEqual([1]); + }); + + test('off with a never-registered handler is a no-op', () => { + const ee = new TypedEventEmitter(); + const neverRegistered = (data: { n: number }) => { + void data; + }; + expect(() => ee.off('ping', neverRegistered)).not.toThrow(); + }); + + test('multiple listeners on the same event each receive it', () => { + const ee = new TypedEventEmitter(); + const a: number[] = []; + const b: number[] = []; + const c: number[] = []; + + ee.on('ping', (data) => a.push(data.n)); + ee.on('ping', (data) => b.push(data.n)); + ee.on('ping', (data) => c.push(data.n)); + ee.emit('ping', { n: 42 }); + + expect(a).toEqual([42]); + expect(b).toEqual([42]); + expect(c).toEqual([42]); + }); + + test('registering the same handler reference twice subscribes it once (Set dedupe)', () => { + const ee = new TypedEventEmitter(); + const seen: number[] = []; + const handler = (data: { n: number }) => seen.push(data.n); + + ee.on('ping', handler); + ee.on('ping', handler); + ee.emit('ping', { n: 1 }); + + expect(seen).toEqual([1]); + }); + + test('emit with no listeners is a no-op (does not throw)', () => { + const ee = new TypedEventEmitter(); + expect(() => ee.emit('ping', { n: 1 })).not.toThrow(); + }); + + test('handlers are isolated per event name', () => { + const ee = new TypedEventEmitter(); + const pings: number[] = []; + const pongs: string[] = []; + + ee.on('ping', (data) => pings.push(data.n)); + ee.on('pong', (data) => pongs.push(data.label)); + ee.emit('ping', { n: 1 }); + + expect(pings).toEqual([1]); + expect(pongs).toEqual([]); + }); + + test('once returns an unsubscribe fn that cancels before the event fires', () => { + const ee = new TypedEventEmitter(); + const seen: number[] = []; + + const off = ee.once('ping', (data) => seen.push(data.n)); + off(); + ee.emit('ping', { n: 1 }); + + expect(seen).toEqual([]); + }); + + test('removeAllListeners drops every handler across all events', () => { + const ee = new TypedEventEmitter(); + const pings: number[] = []; + const pongs: string[] = []; + + ee.on('ping', (data) => pings.push(data.n)); + ee.on('pong', (data) => pongs.push(data.label)); + ee.removeAllListeners(); + ee.emit('ping', { n: 1 }); + ee.emit('pong', { label: 'x' }); + + expect(pings).toEqual([]); + expect(pongs).toEqual([]); + }); +}); diff --git a/packages/wallet-sdk/src/internal/event-emitter.ts b/packages/wallet-sdk/src/internal/event-emitter.ts new file mode 100644 index 000000000..a7b67842b --- /dev/null +++ b/packages/wallet-sdk/src/internal/event-emitter.ts @@ -0,0 +1,124 @@ +/** + * Typed event emitter — the runtime backing for §11's `EventEmitter` interface. + * FULLY NET-NEW (no master EventEmitter to lift). + * + * The PUBLIC contract (`EventEmitter` in `../events`) exposes only `on` / `once` + * (subscribe). This class implements that interface and ADDS the internal + * `emit` / `off` the SDK needs to publish events (from domains + the realtime + * forwarder). The `Sdk` exposes the instance typed as the narrow public + * `EventEmitter`, so consumers cannot `emit`. + * + * Framework-free: a plain `Map>`, no DOM `EventTarget`, no deps. + * @module + */ +import type { EventEmitter } from '../events'; + +/** A handler for event `K` of map `M`. */ +type Handler = (data: M[K]) => void; + +/** + * Concrete typed emitter. `M` is the event map (e.g. `SdkEventMap`); keys are event + * names and each value is that event's payload type. + */ +export class TypedEventEmitter implements EventEmitter { + /** + * event name -> set of handlers. `Set` gives O(1) add/remove and natural dedupe + * (registering the same handler reference twice subscribes it once). Typed loosely + * here (`keyof M` payloads are heterogeneous); the public methods re-impose the + * per-key type at the call site. + */ + // biome-ignore lint/suspicious/noExplicitAny: heterogeneous payloads across keys; the public on/once/emit signatures restore per-key typing. + private readonly handlers = new Map>>(); + + /** + * Subscribe to `event`. Returns an unsubscribe function (idempotent — calling it + * more than once is a no-op). + * + * @param event - the event name (a key of `M`). + * @param handler - invoked with the event payload on every emit. + * @returns a function that removes this handler. + */ + on(event: K, handler: Handler): () => void { + let set = this.handlers.get(event); + if (!set) { + set = new Set(); + this.handlers.set(event, set); + } + set.add(handler); + return () => this.off(event, handler); + } + + /** + * Subscribe to `event` for a SINGLE emission, then auto-unsubscribe. Returns an + * unsubscribe function so the caller can cancel before the event ever fires. + * + * @param event - the event name (a key of `M`). + * @param handler - invoked once with the next event payload, then removed. + * @returns a function that removes the one-shot handler early. + */ + once(event: K, handler: Handler): () => void { + const wrapper: Handler = (data) => { + // unsubscribe BEFORE invoking so a handler that re-emits the same event does + // not re-enter this one-shot wrapper. + off(); + handler(data); + }; + const off = this.on(event, wrapper); + return off; + } + + /** + * Remove a previously-registered handler. No-op if it was never registered. + * + * @param event - the event name. + * @param handler - the exact handler reference passed to `on`/`once`. + */ + off(event: K, handler: Handler): void { + const set = this.handlers.get(event); + if (!set) { + return; + } + set.delete(handler); + if (set.size === 0) { + this.handlers.delete(event); + } + } + + /** + * Publish `event` to all current subscribers. INTERNAL — not on the public + * `EventEmitter` interface. A snapshot of the handler set is iterated so a + * handler that unsubscribes (or subscribes) during dispatch does not disturb the + * in-flight loop. Handler exceptions are isolated (one throwing handler does not + * prevent the rest from running) and re-surfaced asynchronously so they are not + * swallowed. + * + * @param event - the event name. + * @param data - the payload for `event`. + */ + emit(event: K, data: M[K]): void { + const set = this.handlers.get(event); + if (!set || set.size === 0) { + return; + } + for (const handler of [...set]) { + try { + handler(data); + } catch (error) { + // Isolate: never let one bad subscriber break event delivery to the others. + // Re-throw out-of-band so the failure is still observable (unhandled rejection) + // rather than silently dropped. + queueMicrotask(() => { + throw error; + }); + } + } + } + + /** + * Remove ALL handlers (every event). Used by `Sdk.destroy()` to drop subscriber + * references on teardown. + */ + removeAllListeners(): void { + this.handlers.clear(); + } +} diff --git a/packages/wallet-sdk/src/internal/open-secret.ts b/packages/wallet-sdk/src/internal/open-secret.ts new file mode 100644 index 000000000..c69395a63 --- /dev/null +++ b/packages/wallet-sdk/src/internal/open-secret.ts @@ -0,0 +1,73 @@ +/** + * OpenSecret client wiring — §1 / Slice 0 connection wiring. + * + * The `@agicash/opensecret` package (the enclave/auth backend) is configured via a + * module-global `configure({ apiUrl, clientId })` and then used through standalone + * functions (`signIn`, `generateThirdPartyToken`, …) — there is no per-instance client + * object. This module isolates that wiring behind a tiny `OpenSecretClient` facade so + * the rest of the SDK does not call the global API directly, and so the auth slice has + * one place to attach session handling. + * + * SESSION / STORAGE NOTE. `@agicash/opensecret` persists its own access/refresh tokens + * (today: `localStorage`); `configure()` does NOT accept a storage adapter. So session + * RESUME comes "for free" from the OpenSecret client rehydrating on init — there is no + * storage-injection seam in the installed package. The SDK still HOLDS `config.storage` + * (threaded to {@link OpenSecretClient}) for the auth slice's own state (e.g. the guest + * refresh-token path master reads from `localStorage`) and for the day the OpenSecret + * SDK exposes pluggable storage. See the report / build-plan: the `@agicash/opensecret-sdk` + * pluggable-storage contract referenced in PR1 is NOT the installed package's API. + * + * @module + */ +import { configure, generateThirdPartyToken } from '@agicash/opensecret'; +import type { StorageAdapter } from '../types/dependencies'; + +/** Init params for the OpenSecret client (from `SdkConfig.openSecret`). */ +export type OpenSecretConfig = { + /** enclave/auth backend URL (master `VITE_OPEN_SECRET_API_URL`). */ + url: string; + /** project/tenant client id (master `VITE_OPEN_SECRET_CLIENT_ID`). */ + clientId: string; +}; + +/** + * Thin facade over the module-global `@agicash/opensecret` SDK. One per `Sdk` instance. + * + * PR2 wires CONFIGURATION + the third-party-token fetch (the only OpenSecret surface the + * core connection layer needs — it feeds the Supabase access-token provider). Auth + * methods (`signIn` / `signUp` / OAuth / session-expiry) are wired in the auth slice. + */ +export class OpenSecretClient { + /** + * @param config - the `{ url, clientId }` enclave params. + * @param storage - the pluggable storage adapter (held for the auth slice; see the + * module note on why it is not passed to `configure`). + */ + constructor( + config: OpenSecretConfig, + readonly storage: StorageAdapter, + ) { + if (!config.url) { + throw new Error('SdkConfig.openSecret.url is required'); + } + if (!config.clientId) { + throw new Error('SdkConfig.openSecret.clientId is required'); + } + // Module-global; idempotent for a given process. With a single SDK instance per + // process (the contract's topology) this is the one configuration point. + configure({ apiUrl: config.url, clientId: config.clientId }); + } + + /** + * Fetch an OpenSecret third-party JWT for the given `audience` (e.g. the Supabase + * project). Thin pass-through to `generateThirdPartyToken`; the staleness/caching + * lives in {@link SupabaseSessionTokenProvider}. + * + * @param audience - optional token audience. + * @returns the JWT string. + */ + async generateThirdPartyToken(audience?: string): Promise { + const { token } = await generateThirdPartyToken(audience); + return token; + } +} diff --git a/packages/wallet-sdk/src/internal/stub-domains.ts b/packages/wallet-sdk/src/internal/stub-domains.ts new file mode 100644 index 000000000..00a84b47c --- /dev/null +++ b/packages/wallet-sdk/src/internal/stub-domains.ts @@ -0,0 +1,146 @@ +/** + * Domain stubs — Slice 0. + * + * Each factory returns an object implementing its domain interface (§2-§10) where + * every method throws {@link NotImplementedError}. The `Sdk` shell wires its domain + * accessors to these so the public surface is fully present + type-correct in PR2, + * while the real business logic lands in later slices (auth → S1, accounts/scan → S2, + * cashu/spark → S3, transactions/contacts/transfers → S4, background → S5). Swapping a + * stub for a real impl is the unit of work for each slice — these are the seams. + * + * Implementing the interfaces (rather than casting) keeps the stubs honest: if a + * contract method's signature changes, the stub fails to compile until updated. + * + * @module + */ +import type { + AccountsDomain, + AuthDomain, + BackgroundDomain, + CashuDomain, + ContactsDomain, + ExchangeRateDomain, + ScanDomain, + SparkDomain, + TransactionsDomain, + TransfersDomain, + UserDomain, +} from '../domains'; +import { NotImplementedError } from '../errors'; +import type { BackgroundState } from '../events'; + +/** Helper: a method body that always rejects with a labelled {@link NotImplementedError}. */ +const unimplemented = (method: string): never => { + throw new NotImplementedError(method); +}; + +/** Stub `AuthDomain` (real impl: Slice 1). */ +export const createAuthStub = (): AuthDomain => ({ + signIn: () => unimplemented('auth.signIn'), + signUp: () => unimplemented('auth.signUp'), + signInGuest: () => unimplemented('auth.signInGuest'), + signOut: () => unimplemented('auth.signOut'), + refresh: () => unimplemented('auth.refresh'), + resetPassword: () => unimplemented('auth.resetPassword'), + changePassword: () => unimplemented('auth.changePassword'), + upgradeGuest: () => unimplemented('auth.upgradeGuest'), + beginGoogleSignIn: () => unimplemented('auth.beginGoogleSignIn'), + completeOAuth: () => unimplemented('auth.completeOAuth'), +}); + +/** Stub `UserDomain` (real impl: Slice 1). */ +export const createUserStub = (): UserDomain => ({ + getCurrentUser: () => unimplemented('user.getCurrentUser'), + updateUsername: () => unimplemented('user.updateUsername'), +}); + +/** 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: { + createLightningQuote: () => + unimplemented('cashu.send.createLightningQuote'), + createTokenQuote: () => unimplemented('cashu.send.createTokenQuote'), + executeQuote: () => unimplemented('cashu.send.executeQuote'), + failQuote: () => unimplemented('cashu.send.failQuote'), + reverse: () => unimplemented('cashu.send.reverse'), + get: () => unimplemented('cashu.send.get'), + }, + receive: { + receiveToken: () => unimplemented('cashu.receive.receiveToken'), + createLightningQuote: () => + unimplemented('cashu.receive.createLightningQuote'), + get: () => unimplemented('cashu.receive.get'), + }, +}); + +/** Stub `SparkDomain` (`.send` + `.receive`; real impl: Slice 3). */ +export const createSparkStub = (): SparkDomain => ({ + send: { + createLightningQuote: () => + unimplemented('spark.send.createLightningQuote'), + executeQuote: () => unimplemented('spark.send.executeQuote'), + failQuote: () => unimplemented('spark.send.failQuote'), + get: () => unimplemented('spark.send.get'), + }, + receive: { + createLightningQuote: () => + unimplemented('spark.receive.createLightningQuote'), + get: () => unimplemented('spark.receive.get'), + }, +}); + +/** Stub `TransactionsDomain` (real impl: Slice 4). */ +export const createTransactionsStub = (): TransactionsDomain => ({ + list: () => unimplemented('transactions.list'), + get: () => unimplemented('transactions.get'), + countPendingAck: () => unimplemented('transactions.countPendingAck'), + acknowledge: () => unimplemented('transactions.acknowledge'), +}); + +/** Stub `ContactsDomain` (real impl: Slice 4). */ +export const createContactsStub = (): ContactsDomain => ({ + list: () => unimplemented('contacts.list'), + get: () => unimplemented('contacts.get'), + add: () => unimplemented('contacts.add'), + remove: () => unimplemented('contacts.remove'), + search: () => unimplemented('contacts.search'), +}); + +/** Stub `TransfersDomain` (real impl: Slice 4). */ +export const createTransfersStub = (): TransfersDomain => ({ + createQuote: () => unimplemented('transfers.createQuote'), + executeQuote: () => unimplemented('transfers.executeQuote'), +}); + +/** Stub `ExchangeRateDomain` (real impl: a later slice). */ +export const createExchangeRateStub = (): ExchangeRateDomain => ({ + convert: () => unimplemented('exchangeRate.convert'), +}); + +/** + * Stub `BackgroundDomain` (real impl: Slice 5). `state()` is synchronous and must + * return a {@link BackgroundState}; the stub reports `'stopped'` (the pre-start state) + * rather than throwing, so consumers can poll it harmlessly before Slice 5 lands. + * `start` / `stop` still throw — actually driving the processor is Slice 5's job. + */ +export const createBackgroundStub = (): BackgroundDomain => ({ + start: () => unimplemented('background.start'), + stop: () => unimplemented('background.stop'), + state: (): BackgroundState => 'stopped', +}); diff --git a/packages/wallet-sdk/src/internal/supabase-client.ts b/packages/wallet-sdk/src/internal/supabase-client.ts new file mode 100644 index 000000000..d29ca4272 --- /dev/null +++ b/packages/wallet-sdk/src/internal/supabase-client.ts @@ -0,0 +1,83 @@ +/** + * SDK-owned Supabase client construction — §1 / Slice 0 connection wiring. + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/agicash-db/database.client.ts`. The master form reads + * `import.meta.env.VITE_SUPABASE_*`, rewrites `127.0.0.1` against `window.location`, and + * attaches the realtime client to `window` for debugging. All of that is STRIPPED here: + * the client is built purely from `SdkConfig.supabase` params + the SDK-internal + * access-token provider. The SDK OWNS this client; the consumer never gets a handle + * (decision 1) — it only supplies `{ url, anonKey, serviceRoleKey? }`. + * + * - The schema is PINNED to `'wallet'` (every SDK read/write is in that schema). + * - `accessToken` = the OpenSecret JWT (RLS-scoped) via the injected provider. When + * `serviceRoleKey` is supplied (server-side use), the service-role key is used as the + * key and RLS is bypassed; no per-request `accessToken` is attached in that mode. + * + * The `Database` row/result types (master `agicash-db/database.ts`) are NOT lifted here + * — PR2 ships the re-housed CLIENT, typed loosely; a later slice narrows the generic to + * `SupabaseClient` once those types are lifted into the package. + * + * @module + */ +import { type SupabaseClient, createClient } from '@supabase/supabase-js'; + +/** Connection params for the SDK-owned Supabase client (subset of `SdkConfig.supabase`). */ +export type SupabaseConnectionConfig = { + url: string; + anonKey: string; + /** Present only when the SDK runs server-side; bypasses RLS. */ + serviceRoleKey?: string; +}; + +/** The Supabase DB schema every SDK query is pinned to. */ +export const WALLET_SCHEMA = 'wallet' as const; + +/** + * The SDK-owned Supabase client, with its schema pinned to {@link WALLET_SCHEMA}. The DB + * generic is left as the supabase-js default (untyped) until the lifted `Database` types + * land in a later slice, at which point this becomes `SupabaseClient`. + */ +export type WalletSupabaseClient = SupabaseClient< + // biome-ignore lint/suspicious/noExplicitAny: matches supabase-js's own default DB generic; narrowed to `Database` once those types are lifted. + any, + typeof WALLET_SCHEMA, + typeof WALLET_SCHEMA +>; + +/** + * Build the SDK-owned Supabase client. + * + * @param config - the `{ url, anonKey, serviceRoleKey? }` connection params. + * @param getAccessToken - returns the current OpenSecret JWT for RLS (or `null` when + * signed out). Ignored when `serviceRoleKey` is set (service-role bypasses RLS). + * @returns a configured `SupabaseClient` (schema pinned to `'wallet'`). + * + * TODO(later slice): parameterise as `SupabaseClient` once the lifted DB types + * land; lift the realtime debug logger from `database.client.ts` if needed. + */ +export function createSupabaseClient( + config: SupabaseConnectionConfig, + getAccessToken: () => Promise, +): WalletSupabaseClient { + if (!config.url) { + throw new Error('SdkConfig.supabase.url is required'); + } + if (!config.anonKey) { + throw new Error('SdkConfig.supabase.anonKey is required'); + } + + // Server-side: authenticate with the service-role key (bypasses RLS); no per-request + // user token is attached. + if (config.serviceRoleKey) { + return createClient(config.url, config.serviceRoleKey, { + db: { schema: WALLET_SCHEMA }, + }); + } + + // Client-side (default): anon key + the RLS-scoping OpenSecret JWT per request. + return createClient(config.url, config.anonKey, { + accessToken: getAccessToken, + db: { schema: WALLET_SCHEMA }, + }); +} diff --git a/packages/wallet-sdk/src/internal/supabase-session.ts b/packages/wallet-sdk/src/internal/supabase-session.ts new file mode 100644 index 000000000..188ea7bd3 --- /dev/null +++ b/packages/wallet-sdk/src/internal/supabase-session.ts @@ -0,0 +1,94 @@ +/** + * SDK-internal Supabase access-token provider — §1 / Slice 0 connection wiring. + * + * EXTRACTED (re-housed framework-free) from + * `apps/web-wallet/app/features/agicash-db/supabase-session.ts`. The master form + * pulls the OpenSecret third-party JWT through a module-level TanStack + * `getQueryClient().fetchQuery(...)` whose `staleTime` is computed from the JWT `exp` + * (refresh 5 s before expiry). This re-housing keeps the SAME staleness logic but + * drops TanStack: a tiny in-memory cached-token getter. + * + * The result is the `accessToken: () => Promise` callback the + * SDK-owned Supabase client uses for RLS-scoped reads (the token = the OpenSecret + * JWT, audience = the Supabase project). `null` is returned when no session exists. + * + * @module + */ +import { jwtDecode } from 'jwt-decode'; + +/** Refresh the token this many ms before its `exp` (matches master's 5 s guard). */ +const EXPIRY_GUARD_MS = 5_000; + +/** + * Fetches a fresh OpenSecret third-party token. Injected (rather than importing + * `@agicash/opensecret` directly) so this stays a pure mechanism the auth slice wires + * to `generateThirdPartyToken`, and so it is trivially testable. + * + * Returns `null` when there is no authenticated session (mirrors master's + * `isLoggedIn()` short-circuit). + */ +export type FetchSessionToken = () => Promise; + +/** + * A cached, auto-refreshing access-token getter. + * + * Caches the last token and only re-fetches once it is within {@link EXPIRY_GUARD_MS} + * of its JWT `exp` (or has none). Concurrent callers during a refresh share the single + * in-flight fetch (no thundering herd). All state is instance-local — no globals. + */ +export class SupabaseSessionTokenProvider { + private cached: string | null = null; + private inFlight: Promise | null = null; + + /** + * @param fetchToken - obtains a fresh token (e.g. `generateThirdPartyToken` → `.token`), + * or `null` when signed out. + * @param now - clock injection for tests (defaults to `Date.now`). + */ + constructor( + private readonly fetchToken: FetchSessionToken, + private readonly now: () => number = Date.now, + ) {} + + /** + * The Supabase `accessToken` callback: returns a valid (non-stale) token, fetching + * a new one only when the cached token is missing or about to expire. + * + * @returns the current access token, or `null` if signed out. + */ + getToken = async (): Promise => { + if (this.cached && this.msToExpiry(this.cached) > 0) { + return this.cached; + } + if (this.inFlight) { + return this.inFlight; + } + this.inFlight = this.fetchToken() + .then((token) => { + this.cached = token; + return token; + }) + .finally(() => { + this.inFlight = null; + }); + return this.inFlight; + }; + + /** Drop the cached token (e.g. on sign-out) so the next `getToken` re-fetches. */ + clear(): void { + this.cached = null; + } + + /** + * Milliseconds until `token` should be refreshed: `(exp - guard) - now`, clamped at + * 0. A token with no `exp` is treated as immediately stale (returns 0). Mirrors the + * master `staleTime` computation. + */ + private msToExpiry(token: string): number { + const { exp } = jwtDecode(token); + if (!exp) { + return 0; + } + return Math.max(exp * 1000 - EXPIRY_GUARD_MS - this.now(), 0); + } +} diff --git a/packages/wallet-sdk/src/sdk.ts b/packages/wallet-sdk/src/sdk.ts index bd15d9741..f14ff4da0 100644 --- a/packages/wallet-sdk/src/sdk.ts +++ b/packages/wallet-sdk/src/sdk.ts @@ -1,9 +1,20 @@ /** - * The `Sdk` class SHAPE — §1 of the contract. DECLARATION ONLY (no impl). + * The `Sdk` class — §1 of the contract. The CORE shell + connection wiring (Slice 0). * - * PR1 ships the public shape via `declare class` (no method bodies / no wiring). - * The real `Sdk.create` shell + domain-accessor wiring + connection setup land in - * the core implementation PR (Slice 0 / PR2). + * PR2 turns PR1's `declare class` into a real implementation: `Sdk.create` validates + * the config, instantiates the connections (OpenSecret client, the SDK-owned Supabase + * client wired to an internal access-token provider, the storage adapter), constructs + * the typed event emitter, and wires each domain accessor to a STUB whose methods throw + * (`NotImplementedError`) until its slice lands. `destroy()` tears the instance down. + * + * What is REAL here: config validation; the OpenSecret/Supabase/storage wiring; the + * event emitter; the lifecycle shell. What is STUBBED: all domain business logic (auth, + * accounts, scan, cashu, spark, transactions, contacts, transfers, exchangeRate, + * background) — see `./internal/stub-domains` and each slice in the build plan. + * + * Session RESUME is automatic: the OpenSecret client rehydrates its persisted session on + * init (see `./internal/open-secret`), and the Supabase access-token provider lazily + * fetches a fresh JWT on first DB read. */ import type { SdkConfig } from './config'; import type { @@ -20,6 +31,72 @@ import type { UserDomain, } from './domains'; import type { EventEmitter, SdkEventMap } from './events'; +import { TypedEventEmitter } from './internal/event-emitter'; +import { OpenSecretClient } from './internal/open-secret'; +import { + createAccountsStub, + createAuthStub, + createBackgroundStub, + createCashuStub, + createContactsStub, + createExchangeRateStub, + createScanStub, + createSparkStub, + createTransactionsStub, + createTransfersStub, + createUserStub, +} from './internal/stub-domains'; +import { + type SupabaseConnectionConfig, + type WalletSupabaseClient, + createSupabaseClient, +} from './internal/supabase-client'; +import { SupabaseSessionTokenProvider } from './internal/supabase-session'; +import type { StorageAdapter } from './types/dependencies'; + +/** + * The SDK-internal connection bundle assembled by {@link Sdk.create} and handed to the + * domain implementations (real ones, in later slices) so they share one Supabase client, + * one OpenSecret client, one token provider, and one storage adapter. + * + * Not exported from the package barrel — it is the wiring substrate, not public API. + */ +export type SdkConnections = { + readonly supabase: WalletSupabaseClient; + readonly openSecret: OpenSecretClient; + readonly sessionToken: SupabaseSessionTokenProvider; + readonly storage: StorageAdapter; + readonly events: TypedEventEmitter; + /** leader-election instance id (provided or auto-generated). */ + readonly clientId: string; +}; + +/** Validate `config` (shape the rest of `create` relies on). Throws on a missing field. */ +function validateConfig(config: SdkConfig): void { + if (!config) { + throw new Error('Sdk.create: config is required'); + } + if (!config.openSecret?.url || !config.openSecret?.clientId) { + throw new Error( + 'Sdk.create: config.openSecret.{url,clientId} are required', + ); + } + if (!config.supabase?.url || !config.supabase?.anonKey) { + throw new Error('Sdk.create: config.supabase.{url,anonKey} are required'); + } + if (!config.storage) { + throw new Error('Sdk.create: config.storage (StorageAdapter) is required'); + } +} + +/** + * Generate a leader-election client id when the caller omits one. Uses + * `crypto.randomUUID` (available in browsers + Node ≥ 19 / Bun — the SDK's targets; + * matches master's `crypto.randomUUID()` clientId in `wallet/task-processing.ts`). + */ +function generateClientId(): string { + return crypto.randomUUID(); +} /** * The Agicash wallet SDK — the single entry point a consumer (the web wallet or @@ -31,13 +108,7 @@ import type { EventEmitter, SdkEventMap } from './events'; * Promises, long-running operations return a quote whose discriminated `state` * carries progress, and all change notifications flow through `events`. */ -export declare class Sdk { - /** - * Asynchronously construct and connect an SDK instance: builds the - * Supabase/OpenSecret/Breez clients from `config` and wires the domains. The - * SDK owns those clients; the caller only supplies params via {@link SdkConfig}. - */ - static create(config: SdkConfig): Promise; +export class Sdk { /** Authentication: sign in/up/out, password + guest flows, OAuth. */ readonly auth: AuthDomain; /** The current user and profile mutations (e.g. username). */ @@ -60,11 +131,104 @@ export declare class Sdk { readonly exchangeRate: ExchangeRateDomain; /** Background processing lifecycle (leader-elected orchestrators). */ readonly background: BackgroundDomain; - /** Type-safe event subscription surface (the SDK's only reactivity channel). */ + /** + * Type-safe event subscription surface — the SDK's only reactivity channel. + * Public surface is `on` / `once` (the emitter's `emit` / `off` are internal). + */ readonly events: EventEmitter; + + /** The shared connection bundle (internal; domains read it in later slices). */ + private readonly connections: SdkConnections; + + /** + * Private — construct via {@link Sdk.create}. Takes the assembled connection bundle and + * wires the domain accessors. PR2 wires every accessor to a stub; later slices replace + * the stub factories here with real implementations that receive `connections`. + */ + private constructor(connections: SdkConnections) { + this.connections = connections; + this.events = connections.events; + + // --- domain accessors (STUBS in PR2 — swap per slice) -------------------- + this.auth = createAuthStub(); + this.user = createUserStub(); + this.accounts = createAccountsStub(); + this.scan = createScanStub(); + this.cashu = createCashuStub(); + this.spark = createSparkStub(); + this.transactions = createTransactionsStub(); + this.contacts = createContactsStub(); + this.transfers = createTransfersStub(); + this.exchangeRate = createExchangeRateStub(); + this.background = createBackgroundStub(); + } + + /** + * Create + initialise an SDK instance. + * + * Validates `config`, configures the OpenSecret client (which rehydrates any persisted + * session → session resume), builds the SDK-owned Supabase client wired to the internal + * access-token provider (the OpenSecret JWT, RLS-scoped), threads the storage adapter, + * and constructs the event emitter. Returns a ready `Sdk` whose domains are stubbed + * until their slices land. + * + * @param config - see {@link SdkConfig}. + * @returns the initialised SDK. + * @throws Error if a required config field is missing. + */ + static async create(config: SdkConfig): Promise { + validateConfig(config); + + // OpenSecret: module-global configure + session rehydration; holds the storage adapter. + const openSecret = new OpenSecretClient(config.openSecret, config.storage); + + // Access-token provider: the Supabase `accessToken` callback. Audience = the Supabase + // project URL (so the mint-CAT audience stays separate, per master's two-audience use). + const sessionToken = new SupabaseSessionTokenProvider(() => + openSecret.generateThirdPartyToken(config.supabase.url), + ); + + // Supabase: SDK-owned client (schema 'wallet', RLS via the token provider). + const supabaseConfig: SupabaseConnectionConfig = { + url: config.supabase.url, + anonKey: config.supabase.anonKey, + serviceRoleKey: config.supabase.serviceRoleKey, + }; + const supabase = createSupabaseClient( + supabaseConfig, + sessionToken.getToken, + ); + + const connections: SdkConnections = { + supabase, + openSecret, + sessionToken, + storage: config.storage, + events: new TypedEventEmitter(), + clientId: config.clientId ?? generateClientId(), + }; + + return new Sdk(connections); + } + /** - * Tear down the instance: close WS subscriptions (mints + Supabase realtime + - * Breez), halt orchestrators, and clear timers. Call when the consumer is done. + * Tear down the instance: close WS subscriptions (mints + Supabase realtime + Breez), + * halt the background orchestrators, and clear timers + subscribers. Call when the + * consumer is done with the SDK. + * + * PR2 implements the parts that exist in the core shell: it stops Supabase realtime, + * drops the cached session token, and clears all event subscribers. The mint-WS / + * Breez / orchestrator / leader-election teardown is finalised in Slice 5 when those + * connections are actually opened — this method is the single seam they hook. */ - destroy(): Promise; + async destroy(): Promise { + // Close Supabase realtime channels (no-op if none were opened yet). + await this.connections.supabase.removeAllChannels(); + // Drop the cached access token. + this.connections.sessionToken.clear(); + // Remove every event subscriber. + this.connections.events.removeAllListeners(); + // TODO(Slice 3/5): close mint melt/mint-quote WS subs + Breez SDK instances + halt + // the leader-elected processor + clear its timers (wired into this seam when opened). + } } diff --git a/packages/wallet-sdk/src/types/money.ts b/packages/wallet-sdk/src/types/money.ts index 5a0058a3b..71bd3a0b6 100644 --- a/packages/wallet-sdk/src/types/money.ts +++ b/packages/wallet-sdk/src/types/money.ts @@ -1,41 +1,42 @@ /** - * Money / Currency value types. + * Money / Currency value types — §1 + §12 of the contract. * - * PR1 (contract-as-code) ships these as standalone placeholders so the contract - * typechecks with no runtime dependencies. `Money` is declared as an opaque class - * shell (no method bodies) — the public domain types only ever reference it as a - * type, never construct it here. + * Slice 0 resolves PR1's `Money` placeholder: this module now re-exports the REAL + * `Money` value object (a runtime class, not a `declare class` shell) so the SDK's + * domain types can both reference it as a type AND target it with `z.instanceof(Money)` + * at runtime — and so a consumer can `import { Money } from '@agicash/wallet-sdk'`. * - * TODO(Slice-0): replace this module with a re-export of the real `Money` - * (+ `Currency`, `CurrencyUnit`) lifted from `app/lib/money/{index,money,types}.ts` - * (verbatim; leaf + dependency-free). Web then imports `Money` from this package. - * Source of truth: app/lib/money/types.ts (Currency/CurrencyUnit) + money.ts (Money). + * SOURCE OF TRUTH. The canonical `Money` (+ `Currency` / `CurrencyUnit`) lives in + * `apps/web-wallet/app/lib/money` (`{ index, money, types }.ts`; leaf + dependency-free + * apart from `big.js`). Per the build plan (§0.2) the SDK owns `Money` and the web app + * imports it from this package; the canonical relocation of the source files INTO this + * package + the rewrite of web's ~76 `~/lib/money` import sites is a deliberately-deferred + * follow-up (out of the SDK build-plan scope). Until then this module re-exports the + * single live source via a relative path so there is exactly ONE `Money` implementation + * (no duplication, no web churn). + * + * NOTE on `lib: ["DOM"]`: `money.ts` ships a dev-only `registerDevToolsFormatter()` that + * touches `window`; the SDK is a browser consumer (the web wallet), so the package + * tsconfig includes the `DOM` lib. The formatter is never invoked by SDK code. + * + * TODO(follow-up): move `app/lib/money/**` into `packages/wallet-sdk/src/money/` and + * rewire web's `~/lib/money` imports to `@agicash/wallet-sdk`; then this re-export + * becomes a local `./money` re-export. */ -/** supported currencies — verbatim from app/lib/money/types.ts */ -export type Currency = 'USD' | 'BTC'; +export { Money } from '../../../../apps/web-wallet/app/lib/money'; +export type { + Currency, + CurrencyUnit, +} from '../../../../apps/web-wallet/app/lib/money'; +/** + * Unit sub-types for `CurrencyUnit`. Kept here (the contract's `money.ts` surface) + * because the canonical `app/lib/money/types.ts` does not export them by name and the + * SDK contract (PR1) lists them on the public barrel. They are structurally identical + * to the `UsdUnit` / `BtcUnit` the canonical `CurrencyUnit` is built from. + */ /** Denomination units for USD amounts. */ export type UsdUnit = 'usd' | 'cent'; /** Denomination units for BTC amounts. */ export type BtcUnit = 'btc' | 'sat' | 'msat'; - -/** Unit to denominate the given currency — verbatim from app/lib/money/types.ts */ -export type CurrencyUnit = T extends 'USD' - ? UsdUnit - : T extends 'BTC' - ? BtcUnit - : never; - -/** - * Opaque placeholder for the real `Money` value object. - * - * Declared as a class so domain types can use `Money` as both a type and (later) - * a `z.instanceof(Money)` target without churn. No logic lives here in PR1. - * - * TODO(Slice-0): delete and re-export the real `Money` from `app/lib/money`. - */ -export declare class Money { - /** Brand to keep the placeholder nominal (prevents structural collapse to `{}`). */ - private readonly __moneyBrand: T; -} diff --git a/packages/wallet-sdk/tsconfig.json b/packages/wallet-sdk/tsconfig.json index 5276b610a..56f294042 100644 --- a/packages/wallet-sdk/tsconfig.json +++ b/packages/wallet-sdk/tsconfig.json @@ -3,7 +3,7 @@ "include": ["src/**/*.ts"], "exclude": ["node_modules"], "compilerOptions": { - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "noEmit": true } }