diff --git a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md new file mode 100644 index 000000000..9e020ceff --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -0,0 +1,505 @@ +# Wallet SDK — public contract proposal (step 4) + +- **Date:** 2026-07-02 +- **Status:** Proposal for review +- **Parent spec:** [2026-06-24-wallet-sdk-no-cache-production-design.md](./2026-06-24-wallet-sdk-no-cache-production-design.md) + +Step 4 of the no-cache production design: define the `Sdk` class + `create(config)`, +the per-domain namespaces, the separate `ServerSdk`, and the *shape* of the +background contract. Background implementation lands in step 18; slices 5–16 fill +these namespaces in one domain at a time. + +## Principles (inherited, restated as contract rules) + +1. **No cache.** Reads return `Promise`s that hit the DB. The SDK holds no + resident store of wallet data. (The feature-flag cache is process-local + config, not wallet data — it stays.) +2. **Events out, promises in.** State changes surface only as typed events on + `sdk.events`. The host owns freshness/caching (web: TanStack). +3. **Ports in via `create(config)`, never ambient.** No SDK module reads env + vars or touches `localStorage`/cookies. Everything host-specific enters as a + config port. This is what makes bun/node MCP hosting work. +4. **Instance, not module state.** All runtime capability hangs off an `Sdk` + instance. Pure helpers (codecs, validators) are stateless root exports. +5. **Types at the root, capability on the instance.** `@agicash/wallet-sdk` + exports domain types + pure helpers; repositories, services, and the DB + layer stay internal. `/temporary` keeps bridging until step 19 deletes it. + +## `Sdk.create(config)` + +```ts +type SdkConfig = { + db: { + url: string; // Supabase project URL — host resolves the final URL first + anonKey: string; // Supabase anon key + }; + auth: { + apiUrl: string; // Open Secret backend URL + clientId: string; // Open Secret client id + storage: AuthStorage; // host-backed session persistence + }; + spark: { + breezApiKey: string; + network: SparkNetwork; // default network for account creation (see notes) + storageDir?: string; // node hosts; browser default applies + }; + lightningAddressDomain: string; // lud16 domain for contacts/display + logger?: Logger; // diagnostic sink; MCP stdio hosts route to stderr +}; + +// Illustrative shape — binds to the React-agnostic @agicash/opensecret release's +// storage-provider interface verbatim (method names + nullability), settled when +// the auth slice (step 5) adopts the release, so the SDK ships no adapter over it. +type AuthStorage = { + get(key: string): Promise; + set(key: string, value: string): Promise; + remove(key: string): Promise; +}; + +// Structured diagnostics. Web wires console + Sentry breadcrumbs; a bun/node MCP +// host wires stderr (stdout carries JSON-RPC). The SDK never calls console directly. +type Logger = { + debug(message: string, meta?: unknown): void; + info(message: string, meta?: unknown): void; + warn(message: string, meta?: unknown): void; + error(message: string, meta?: unknown): void; +}; + +class Sdk { + static create(config: SdkConfig): Sdk; // sync; no I/O + init(): Promise; // async second phase; required before Spark use (see notes) + dispose(): Promise; // tears down realtime + background +} +``` + +Notes: + +- **The SDK builds its own Supabase client.** Auth lives inside the SDK + (step 5), so the access-token getter (Open Secret JWT → Supabase session) + wires internally — the host cannot supply it without a circular dependency. + The host supplies only `url` + `anonKey`, and resolves the **final** URL + before `create()` (the dev-only `127.0.0.1 → hostname` rewrite stays in the + host's config assembly). The SDK reads no browser globals. +- **Supported runtimes:** browser, bun, and node ≥ 22 — realtime needs a global + `WebSocket`, which older node lacks; the Breez fork already floors node at 22. +- **All key material stays lazy.** Encryption keys, the Cashu seed, and the + Spark mnemonic derive on demand from the authenticated Open Secret session + (constructors already take `() => Promise<…>` getters today — the internal + wiring keeps that shape). +- **`create` is synchronous; `init()` is the async second phase.** + Constructing an `Sdk` does no I/O. `init()` front-loads the async inits that + can *fail* — session restore and the Breez WASM load — and rejects with the + typed error (e.g. `WebAssemblyUnavailableError`, when WebAssembly is + unavailable as under iOS Lockdown Mode), so the host keeps its boot-time + fallback path. **`init()` resolves when no session exists** — absence of a + session is a normal state (the login pages construct the SDK too), not a + boot failure; it rejects only on actual failures: WASM unavailable, storage + unreadable, refresh errors. **`init()` is required before any Spark + operation** — the SDK does not lazy-load the WASM; Spark calls without a + completed `init()` throw a typed `SdkError`. Everything else keeps master's + split — **what is lazy stays lazy, what is eager stays eager**: eager = + session restore + WASM load (`init()`) and the realtime subscription + (established with the authenticated session — see Events); lazy = + per-account Spark connect, cashu wallet init, and all reads (first use). +- **`dispose()`** awaits in-flight background state transitions to their next + checkpoint, then tears down realtime + background; still-pending namespace + promises reject with a typed `SdkError`. +- **`spark.network`** is the default used when the SDK *creates* an account; the + per-account value persisted in the DB is authoritative for every account after + that (network is per-account state, not global truth). + +## Instance namespaces + +One namespace per migration slice; the slice PR fills the namespace and flips +the web app's imports for that domain from `/temporary` to `sdk.*`. + +```ts +class Sdk { + auth: AuthApi; // step 5 + user: UserApi; // step 5 + accounts: AccountsApi; // step 6 + contacts: ContactsApi; // step 7 + transactions: TransactionsApi; // step 8 + receive: ReceiveApi; // steps 9–12 + send: SendApi; // steps 13–15 + transfer: TransferApi; // step 16 + featureFlags: FeatureFlagsApi; + events: WalletEvents; // shape now; emits from step 18 + background: BackgroundApi; // shape now; implementation step 18 +} +``` + +Flow-first (`receive`/`send`), not rail-first (`cashu`/`spark`): the app and +the slice sequence both think in flows, and cross-rail concerns +(`resolveDestination`, transfer) already live at flow level. Rails appear as +sub-namespaces where the flows genuinely diverge: + +```ts +type ReceiveApi = { + cashu: { + getLightningQuote(params): Promise; + createQuote(params): Promise; + getQuote(id: string): Promise; + }; + spark: { + getLightningQuote(params): Promise; + createQuote(params): Promise; + getQuote(id: string): Promise; + }; + cashuToken: { + getQuote(params): Promise; + claim(params): Promise<…>; + }; +}; + +type SendApi = { + resolveDestination(input: string): Promise; + cashu: { + getLightningQuote(params): Promise; + createQuote(params): Promise<{ transactionId: string }>; + getSwapQuote(params): Promise; // send-to-token + createSwap(params): Promise<…>; + }; + spark: { + getLightningQuote(params): Promise; + createQuote(params): Promise<{ transactionId: string }>; + }; +}; +``` + +Representative shapes for the simpler namespaces (exact param types settle in +each slice PR, per the parent spec): + +```ts +type AccountsApi = { + get(id: string): Promise; + list(): Promise; // active accounts, current user + cashu: { + add(params): Promise; + }; +}; +``` + +Rail-agnostic reads stay at the namespace top (`get`/`list` return the +`Account` union); rail-specific operations nest per rail — the same rule +`receive`/`send` follow. A future addable rail lands as `accounts.spark.add` +with exact input and return types, instead of widening a shared `add` +signature into unions or overloads. + +```ts +type AuthApi = { + signUp(email: string, password: string): Promise; // auto-signs-in + signUpGuest(): Promise; // re-signs-in this device's prior guest account if one exists + signIn(email: string, password: string): Promise; + signOut(): Promise; // stops background, tears down realtime, clears the stored session + verifyEmail(code: string): Promise; + requestNewVerificationCode(): Promise; + convertGuestToFullAccount(email: string, password: string): Promise; + initiateGoogleAuth(): Promise<{ authUrl: string }>; // host redirects to authUrl + completeGoogleAuth(params: { code: string; state: string }): Promise; // OAuth callback leg + getSession(): AuthSession; // sync snapshot for route guards; no I/O +}; + +type AuthSession = + | { isLoggedIn: true; user: AuthUser } // exact AuthUser shape settles in step 5 + | { isLoggedIn: false }; + +type UserApi = { + get(): Promise; + updateUsername(username: string): Promise; + acceptTerms(params): Promise; + setDefaultAccount(params): Promise; // user slice owns default-account + setDefaultCurrency(params): Promise; // …and default-currency writes +}; + +type ContactsApi = { + get(id: string): Promise; + list(): Promise; // today's getAll + create(params): Promise; + delete(id: string): Promise; + findContactCandidates(query: string): Promise; +}; + +type TransferApi = { + getQuote(params): Promise; // stateless preview + initiate(params): Promise<{ transactionId: string }>; +}; + +type TransactionsApi = { + get(id: string): Promise; + list(params: { cursor?: Cursor; pageSize?: number; accountId?: string }): + Promise<{ transactions: Transaction[]; nextCursor: Cursor | null }>; + countPendingAck(): Promise; + acknowledge(transactionId: string): Promise; +}; + +type FeatureFlagsApi = { + get(flag: FeatureFlag): boolean; // sync read from the process-local flag cache + subscribe(listener: () => void): () => void; // cache-change signal (web: useSyncExternalStore) +}; + +type BackgroundApi = { + start(): void; // leader election + processors; throws with no session + stop(): Promise; // see stop() semantics below + readonly state: 'stopped' | 'follower' | 'leader' | 'error'; +}; +``` + +Auth keeps master's verb set verbatim (`signIn`/`signOut`/`signUpGuest`…, from +today's `useAuthActions`); web-only concerns stay host-side (`redirectTo` +after sign-out is routing, not contract). `signOut()` ends the session but +leaves the instance alive in anonymous state — `dispose()` is instance +teardown, not logout. `featureFlags` is the documented process-local cache +exception (Principles, rule 1), hence the sync `get` + `subscribe` pair +instead of promises. + +### Execution model + +**Nothing moves money unless a background loop is running somewhere.** +`createQuote` persists an UNPAID quote; execution (paying, swapping, melting) is +background-only. This is already true today — the task processor is leader-gated +behind a ~6-second DB lock — but the contract must state it, because two +consumers depend on it: + +- **An MCP / request-response host MUST call `background.start()` in-process**, + or its own sends sit UNPAID forever — nothing else runs its loop. +- **The executing instance may differ from the initiating one.** The leader lock + is per-user across devices, so a send initiated on one instance can be executed + by another (e.g. an open browser tab). Failover is bounded by the lease TTL + + poll interval (~6s lease, renewed every 5s), not instant — accepted contract, + not a stall. + +`start()` throws a typed `SdkError` when no authenticated session exists — +background work is per-user by construction (the leader lock and every +processor queue are user-scoped), so an anonymous instance has nothing to run. + +**Error handling** keeps master's two tiers: + +- **Per-task errors are isolated** — a single poisoned quote logs via the + `logger` port and the loop keeps processing the rest; the failed entity + surfaces through its own `*.updated` event on transition to FAILED. +- **Systemic failures** — the leader lock unrenewable after retries and the + like — transition `state → 'error'` and emit `background.state-changed` with + the `error` payload set. A host wires that to recovery: web throws from a + subscribed hook into its global error boundary; an MCP host drives its + exit/restart policy. Change-feed death is **not** a background failure: the + realtime channel belongs to `events`, and its death surfaces as + `connection.changed: 'error'` alone. + +**`stop()` returns a Promise** because callers (logout, process exit, the SDK's +own `dispose()`) must await cleanup: it stops claiming new work immediately, +awaits in-flight iterations to their next checkpoint (bounded by a timeout), +releases the leader lock, and abandons the remaining queue. + +Conventions across all namespaces: + +- **`userId` is implicit.** The instance knows the authenticated user; methods + don't take `userId` params (today's repos do — the namespace layer closes + over the session). +- **`get*` vs `create*`.** `get*` methods are stateless previews — they compute + and return without persisting (`getLightningQuote`, `transfer.getQuote`). + `create*` methods persist and enter the entity into the background lifecycle + (`createQuote`, `createSwap`, `accounts.cashu.add`). A slice never re-decides + which is which. +- **Completion is not the host's job.** Methods like `expire`/`fail`/ + `completeSwap` that today's hooks call from background processors do NOT + appear on the public namespaces — they move behind `sdk.background` + (step 18). The public surface is: initiate, read, observe events. +- **User-initiated writes are public surface**, not only payment initiation: + reads, event observation, *and* plain writes a person triggers — contacts + create/delete, username/terms/default updates, `transfer.getQuote`/`initiate`. + Only background-driven state transitions (bullet above) are hidden. + +### Observing an initiated payment + +Send returns a bare `{ transactionId }` and completion is background-only, so +"pay this and tell me the result" is an **observation**. The contract idiom is +subscribe-then-read (a dedicated `transactions.waitForTerminal` is deferred — +hosts hand-roll correlation for now): + +1. `events.on('.updated', …)` filtered to the id — **subscribe first**. +2. read the starting state (`getQuote(id)` / `transactions.get(id)`) as the baseline. +3. correlate `updated` events until a terminal state lands. + +Order matters: subscribing before the baseline read closes the race where an +update lands between the read and the subscription (TanStack hides this today; +a bare event consumer must get it right by hand). Receive methods return full +quote objects (they must hand back the generated invoice) while send returns +only an id — the asymmetry is intentional, not an oversight. + +## Events + +```ts +type WalletEventMap = { + 'auth.session-expired': Record; // session died without signOut() (expiry / failed refresh) + 'user.updated': { user: User }; + 'account.created' | 'account.updated': { account: Account }; + 'account.balance-changed': { accountId: string; balance: Money }; // both rails; no version + 'contact.created' | 'contact.deleted': { contact: Contact }; + 'transaction.created' | 'transaction.updated': { transaction: Transaction }; + 'cashu-receive-quote.created' | 'cashu-receive-quote.updated': { quote: CashuReceiveQuote }; + 'cashu-receive-swap.created' | 'cashu-receive-swap.updated': { swap: CashuReceiveSwap }; + 'spark-receive-quote.created' | 'spark-receive-quote.updated': { quote: SparkReceiveQuote }; + 'cashu-send-quote.created' | 'cashu-send-quote.updated': { quote: CashuSendQuote }; + 'cashu-send-swap.created' | 'cashu-send-swap.updated': { swap: CashuSendSwap }; + 'spark-send-quote.created' | 'spark-send-quote.updated': { quote: SparkSendQuote }; + 'connection.changed': { state: 'connected' | 'reconnecting' | 'error' }; + 'background.state-changed': { state: BackgroundApi['state']; error?: SdkError }; +}; + +type WalletEvents = { + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void; // returns unsubscribe +}; +``` + +- Payloads are **decrypted domain objects** — from step 18 the SDK owns the + realtime change feed + row decryption (today's `use-track-wallet-changes` + handler set maps 1:1 onto this event map). +- Naming invariant: `.`, entity = the domain type name in + kebab-case. **Verbs are per entity, not universal** — an entity emits the + verbs its data model supports. Most quote/swap/account/transaction entities + emit `created` + `updated`; **`contact` emits `created` + `deleted` only** + (contacts are immutable today — an owner→username link with no update path). + `created` matters cross-device: a quote initiated on one device must reach the + user's other sessions. Terminal transitions (completed/expired/failed) arrive + as `updated` with the new state on the payload, not as separate event names. +- **`account.updated` vs `account.balance-changed` are two kinds of change.** + `account.updated` = a persisted row changed; its payload carries a `version` + and consumers version-gate on it. `account.balance-changed` = a versionless + balance signal **both rails emit**: cashu fires it whenever a row change + moves the balance (alongside the versioned `account.updated` for that same + change), spark fires it from the SDK's internal Breez listeners (replacing + today's raw-handle listeners) — spark's only balance path, since spark + balances are rail-side state, not rows. A balance consumer subscribes to one + event regardless of rail; version-gated row consumers keep `account.updated`. + The contract `Account` carries `balance` and does **not** expose a raw + wallet handle. +- **`connection.changed`** emits on every transition into `connected` — + including the initial connection, not only reconnects — so the + invalidate-all sweep also covers changes that land before the channel came + up (replacing today's `onConnected`). `error` is terminal: the channel is + dead after retries exhaust (today's `SupabaseRealtimeError` → error + boundary), distinct from a long `reconnecting`. +- **`events.on()` never initiates the realtime connection** — it only + registers a handler, and is callable with no session (login pages construct + the SDK; `auth.session-expired` and `background.state-changed` are not + realtime-backed). The per-user channel is established by the session coming + into existence — login, or `init()`'s session restore — and establishment + emits `connection.changed: 'connected'`: the host's invalidate-all signal, + exactly master's `onConnected` role. +- **`auth.session-expired`** is the session-death path the host didn't + initiate: the SDK keeps master's refresh-or-expire machinery internal and + emits this when refresh fails or expiry hits, so the web renders its + "session expired" toast + redirect from one subscription. A host-initiated + `signOut()` never fires it. +- **`background.state-changed`** fires on **every** `state` transition + (`stopped ↔ follower ↔ leader`, and into `'error'`), payload + `{ state, error? }` with `error` set on transitions into `'error'`. One + rule, no exceptions — and per-task errors don't change state, so they still + never fire it (see Execution model). +- Event names are stable contract; adding events is non-breaking, renaming is + breaking. + +## `ServerSdk` + +Lightning-address routes run server-side with different trust: env-provided +secrets, no user session, per-request scope. + +```ts +type ServerSdkConfig = { + db: { url: string; serviceRoleKey: string }; + spark: { breezApiKey: string; network: SparkNetwork; mnemonic: string; storageDir: string }; + quoteEncryptionKey: string; // hex; encrypts LNURL verify payloads +}; + +class ServerSdk { + static create(config: ServerSdkConfig): ServerSdk; // singleton per process + lightningAddress: { + handleLud16Request(params: { username: string; baseUrl: string }): + Promise; + handleLnurlpCallback(params: { + userId: string; amount: Money<'BTC'>; baseUrl: string; bypassAmountValidation?: boolean; + }): Promise; + handleLnurlpVerify(params: { encryptedQuoteData: string }): + Promise; + }; +} +``` + +The `Request` object (a constructor param today) becomes a per-method +`baseUrl` param, so `ServerSdk` constructs once per process instead of per +request. No `auth` port, no `events`, no `background`. + +`db` uses the **service-role key**, not the anon key: these routes do cross-user +reads with no user session, where anon + RLS returns nothing — that different +trust model is the whole reason `ServerSdk` exists. `spark.storageDir` is +required (every route passes it today). `bypassAmountValidation` is a per-request +**method** param, not instance state — it selects the agicash→agicash pay path +(default-currency/FX receive vs BTC-only), and kept as instance state it would +race across concurrent requests on the per-process singleton. `min`/`maxSendable` +stay hardcoded in the service for now. The host still owns wire parsing: a raw +millisat query string is converted to `Money<'BTC'>` before the call, so an +invalid amount can't reach the SDK (the `Money` constructor rejects it). + +## Root exports (module level, stateless) + +Alongside the existing domain types, the package root exports the pure +helpers the web consumes that need no instance state: + +- codecs/inspection: `decodeCashuToken`, `tokenToMoney`, `getTokenHash` +- validation: `validateBolt11`, `validateLightningAddressFormat`, + `cashuMintValidator` +- errors: `SdkError` (abstract base — everything the SDK throws extends it, + giving hosts one `instanceof` check at the boundary) with `DomainError`, + `ConcurrencyError`, `NotFoundError`, `UniqueConstraintError`, + `WebAssemblyUnavailableError` (thrown by `init()` / first Spark use where + WebAssembly is unavailable; web `instanceof`-checks it for the fallback UI). + Subclass semantics are contract: `DomainError.message` is the only + user-displayable message; `ConcurrencyError` always means retry. +- exchange rate: `exchangeRate` — provider fallback chain (mempool → coingecko → + coinbase); holds no instance state or ports, so a rate lookup needs no `Sdk`. + +Anything touching the DB, keys, or accounts lives on the instance. The Zod +row schemas + transaction-details parsers the web imports today are +migration-era leftovers: they become SDK-internal at step 18 (web stops +parsing rows once events deliver domain objects). + +## Migration mapping + +| `/temporary` consumer group today | Contract home | +| --- | --- | +| `*Repository` / `*Service` classes | internal, behind namespaces | +| db row types + `*DbDataSchema` | internal (step 18 removes web's need) | +| `getEncryption`, encrypt/decrypt fns | internal (auth slice) | +| cashu wallet plumbing (`getInitializedCashuWallet`, mint auth…) | internal | +| `lib/spark` key/wallet plumbing (`getSparkMnemonic`, `getSparkIdentityPublicKeyFromMnemonic`, `clearSparkWallets`, `createSparkWalletStub`) | internal (absorbed by the auth/accounts/token-claim slices) | +| `sparkDebugLog` | internal (logger port) | +| `ensureBreezWasm` | replaced by `init()` | +| `WebAssemblyUnavailableError` | root error export (listed above) | +| pure codecs/validators/errors | root exports | +| feature-flag fns | `sdk.featureFlags` | +| exchange-rate | root export (stateless) | +| account/user predicate helpers (`getAccountBalance`, `shouldVerifyEmail`…) | stay root exports (pure fns over domain types) | +| `TaskProcessingLockRepository`, processors | internal, behind `sdk.background` | + +## Decision points (all four signed off in review #1164) + +Kept as the rationale trail; each is now resolved. + +1. **DB port granularity** — `{ url, anonKey }`, SDK builds the client and wires + its own access token (auth is inside). Alternative (host passes a pre-built + Supabase client) rejected: the token getter would point back into the SDK. + **Resolved: adopted** — client port only; `ServerSdk` separately takes a + service-role key. +2. **Flow-first namespaces** (`sdk.receive.cashu`) over rail-first + (`sdk.cashu.receive`) — matches the app mental model and slice sequence. + **Resolved: adopted.** +3. **`userId` implicit from session** — repos keep explicit params internally; + namespaces close over the session. **Resolved: adopted.** +4. **Processor verbs off the public surface** — `complete/expire/fail` are + background-only; hosts observe via events. **Resolved: adopted** — the + Execution model section above states the "what runs the loop" obligation this + creates. diff --git a/packages/wallet-sdk/index.ts b/packages/wallet-sdk/index.ts index cf7ce7b68..f24a1472a 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -1,7 +1,21 @@ -// @agicash/wallet-sdk — public surface: domain types only. -// Repositories, services, and the wallet DB layer are SDK-internal; during the -// migration they're re-exported from '@agicash/wallet-sdk/temporary' instead, -// so that deleting /temporary at the end compiler-enforces the boundary. +// @agicash/wallet-sdk — public surface: the SDK contract (sdk.ts) + domain +// types + typed errors. Repositories, services, and the wallet DB layer are +// SDK-internal; during the migration they're re-exported from +// '@agicash/wallet-sdk/temporary' instead, so that deleting /temporary at the +// end compiler-enforces the boundary. +// The explicit domain-type exports below SHADOW the same-named contract +// projections from './sdk' (an explicit export beats `export *`); each slice +// deletes its names here when it flips the web imports, surfacing the +// projections. +export * from './sdk'; +export { + ConcurrencyError, + DomainError, + NotFoundError, + SdkError, + UniqueConstraintError, +} from './lib/error'; +export { WebAssemblyUnavailableError } from './lib/spark/errors'; export type { DestinationDetails } from './lib/send-destination'; export type { SparkNetwork } from './db/json-models/spark-account-details-db-data'; export type { FeatureFlag, FeatureFlags } from './lib/feature-flag-service'; diff --git a/packages/wallet-sdk/lib/error.ts b/packages/wallet-sdk/lib/error.ts index 52b4bbd4b..628d366b4 100644 --- a/packages/wallet-sdk/lib/error.ts +++ b/packages/wallet-sdk/lib/error.ts @@ -1,20 +1,27 @@ -export class UniqueConstraintError extends Error {} +/** + * Abstract base for everything the SDK throws — hosts get one `instanceof` + * check at the boundary. Subclass semantics are contract: `DomainError.message` + * is the only user-displayable message; `ConcurrencyError` always means retry. + */ +export abstract class SdkError extends Error {} -export class NotFoundError extends Error { +export class UniqueConstraintError extends SdkError {} + +export class NotFoundError extends SdkError { constructor(message: string) { super(message); this.name = 'NotFoundError'; } } -export class DomainError extends Error { +export class DomainError extends SdkError { constructor(message: string) { super(message); this.name = 'DomainError'; } } -export class ConcurrencyError extends Error { +export class ConcurrencyError extends SdkError { constructor( message: string, public details: string | undefined = undefined, diff --git a/packages/wallet-sdk/lib/spark/errors.ts b/packages/wallet-sdk/lib/spark/errors.ts index 7cda429b6..de2584a70 100644 --- a/packages/wallet-sdk/lib/spark/errors.ts +++ b/packages/wallet-sdk/lib/spark/errors.ts @@ -1,3 +1,17 @@ +import { SdkError } from '../error'; + +/** + * Thrown when `WebAssembly` is not available in the current browser session. + * Most commonly caused by iOS Lockdown Mode disabling WASM in WebKit; can also + * occur in restricted in-app WebViews on Android. + */ +export class WebAssemblyUnavailableError extends SdkError { + constructor() { + super('WebAssembly is not available in this browser session'); + this.name = 'WebAssemblyUnavailableError'; + } +} + export const isInsufficentBalanceError = (error: unknown): error is Error => { if (!(error instanceof Error)) { return false; diff --git a/packages/wallet-sdk/lib/spark/wasm.ts b/packages/wallet-sdk/lib/spark/wasm.ts index 998767e89..baaa2f558 100644 --- a/packages/wallet-sdk/lib/spark/wasm.ts +++ b/packages/wallet-sdk/lib/spark/wasm.ts @@ -1,19 +1,8 @@ import initBreezWasm from '@agicash/breez-sdk-spark'; +import { WebAssemblyUnavailableError } from './errors'; let wasmInitPromise: ReturnType | null = null; -/** - * Thrown when `WebAssembly` is not available in the current browser session. - * Most commonly caused by iOS Lockdown Mode disabling WASM in WebKit; can also - * occur in restricted in-app WebViews on Android. - */ -export class WebAssemblyUnavailableError extends Error { - constructor() { - super('WebAssembly is not available in this browser session'); - this.name = 'WebAssemblyUnavailableError'; - } -} - /** * Initializes the Breez SDK WASM module exactly once, even across concurrent * callers. The SDK's own init only short-circuits after completion, so parallel diff --git a/packages/wallet-sdk/sdk.ts b/packages/wallet-sdk/sdk.ts new file mode 100644 index 000000000..faf0742be --- /dev/null +++ b/packages/wallet-sdk/sdk.ts @@ -0,0 +1,405 @@ +// Public contract of @agicash/wallet-sdk. Prose contract: +// docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +import type { + LNURLError, + LNURLPayParams, + LNURLPayResult, + LNURLVerifyResult, +} from '@agicash/lnurl'; +import type { Money } from '@agicash/money'; +import type { SparkNetwork } from './db/json-models/spark-account-details-db-data'; +import type { + CashuAccount as DomainCashuAccount, + SparkAccount as DomainSparkAccount, +} from './domain/accounts/account'; +import type { Contact as DomainContact } from './domain/contacts/contact'; +import type { CashuReceiveQuote as DomainCashuReceiveQuote } from './domain/receive/cashu-receive-quote'; +import type { CashuReceiveLightningQuote } from './domain/receive/cashu-receive-quote-core'; +import type { CashuReceiveSwap as DomainCashuReceiveSwap } from './domain/receive/cashu-receive-swap'; +import type { SparkReceiveQuote as DomainSparkReceiveQuote } from './domain/receive/spark-receive-quote'; +import type { SparkReceiveLightningQuote } from './domain/receive/spark-receive-quote-core'; +import type { CashuSendQuote as DomainCashuSendQuote } from './domain/send/cashu-send-quote'; +import type { CashuLightningQuote } from './domain/send/cashu-send-quote-service'; +import type { CashuSendSwap as DomainCashuSendSwap } from './domain/send/cashu-send-swap'; +import type { CashuSwapQuote } from './domain/send/cashu-send-swap-service'; +import type { SparkSendQuote as DomainSparkSendQuote } from './domain/send/spark-send-quote'; +import type { SparkLightningQuote } from './domain/send/spark-send-quote-service'; +import type { Transaction as DomainTransaction } from './domain/transactions/transaction'; +import type { Cursor } from './domain/transactions/transaction-repository'; +import type { TransferQuote } from './domain/transfer/transfer-service'; +import type { User } from './domain/user/user'; +import type { SdkError } from './lib/error'; +import type { FeatureFlag } from './lib/feature-flag-service'; +import type { DestinationDetails } from './lib/send-destination'; + +export type { Cursor }; + +// Public projections of the domain entities: `userId`/`ownerId` are implicit +// from the session; raw wallet handles and proof material stay internal. + +/** Carries `balance` on every rail, never a raw wallet handle or proof material. */ +export type CashuAccount = Omit< + DomainCashuAccount, + 'keysetCounters' | 'proofs' | 'wallet' +> & { balance: Money | null }; +export type SparkAccount = Omit; +export type Account = CashuAccount | SparkAccount; + +export type Contact = Omit; +export type Transaction = Omit; +export type CashuReceiveQuote = Omit; +export type SparkReceiveQuote = Omit; +export type CashuReceiveSwap = Omit; +export type CashuSendQuote = Omit; +export type CashuSendSwap = Omit< + DomainCashuSendSwap, + 'inputProofs' | 'proofsToSend' | 'userId' +>; +export type SparkSendQuote = Omit; + +/** Host-backed session persistence. */ +export type AuthStorage = { + get(key: string): Promise; + set(key: string, value: string): Promise; + remove(key: string): Promise; +}; + +/** Diagnostic sink; the SDK never writes to the console directly. */ +export type Logger = { + debug(message: string, meta?: unknown): void; + info(message: string, meta?: unknown): void; + warn(message: string, meta?: unknown): void; + error(message: string, meta?: unknown): void; +}; + +export type SdkConfig = { + db: { + url: string; + anonKey: string; + }; + auth: { + apiUrl: string; + clientId: string; + storage: AuthStorage; + }; + spark: { + breezApiKey: string; + /** Default for account creation; the persisted per-account value is authoritative. */ + network: SparkNetwork; + /** Node hosts; browser default applies. */ + storageDir?: string; + }; + /** lud16 domain. */ + lightningAddressDomain: string; + logger?: Logger; +}; + +export type Sdk = { + readonly auth: AuthApi; + readonly user: UserApi; + readonly accounts: AccountsApi; + readonly contacts: ContactsApi; + readonly transactions: TransactionsApi; + readonly receive: ReceiveApi; + readonly send: SendApi; + readonly transfer: TransferApi; + readonly featureFlags: FeatureFlagsApi; + readonly events: WalletEvents; + readonly background: BackgroundApi; + /** + * Front-loads session restore and the Breez WASM load. Resolves when no + * session exists (a state, not a failure); rejects on actual failures, + * e.g. `WebAssemblyUnavailableError`. Required before any Spark operation — + * the SDK does not lazy-load the WASM, so Spark calls without a completed + * `init()` throw a typed `SdkError`. Non-Spark usage lazy-initializes on + * first use. + */ + init(): Promise; + /** + * Awaits in-flight background transitions to their next checkpoint, then + * tears down realtime + background; still-pending namespace promises reject + * with a typed `SdkError`. + */ + dispose(): Promise; +}; + +/** `create` is sync; no I/O. */ +export type SdkConstructor = { + create(config: SdkConfig): Sdk; +}; + +export type AuthUser = unknown; // settles in step 5 (auth & user) + +export type AuthSession = + | { isLoggedIn: true; user: AuthUser } + | { isLoggedIn: false }; + +export type AuthApi = { + /** Creates a full account and signs the user in. */ + signUp(email: string, password: string): Promise; + /** Re-signs-in this device's prior guest account if one exists. */ + signUpGuest(): Promise; + signIn(email: string, password: string): Promise; + /** + * Stops background, tears down realtime, clears the stored session; the + * instance stays usable in anonymous state. + */ + signOut(): Promise; + verifyEmail(code: string): Promise; + requestNewVerificationCode(): Promise; + convertGuestToFullAccount(email: string, password: string): Promise; + /** Returns the URL to redirect to. */ + initiateGoogleAuth(): Promise<{ authUrl: string }>; + /** OAuth callback leg. */ + completeGoogleAuth(params: { code: string; state: string }): Promise; + /** Sync snapshot; no I/O. */ + getSession(): AuthSession; +}; + +export type UserApi = { + get(): Promise; + updateUsername(username: string): Promise; + acceptTerms(params: AcceptTermsParams): Promise; + setDefaultAccount(params: SetDefaultAccountParams): Promise; + setDefaultCurrency(params: SetDefaultCurrencyParams): Promise; +}; + +export type AccountsApi = { + get(id: string): Promise; + /** Active accounts of the current user. */ + list(): Promise; + cashu: { + add(params: AddCashuAccountParams): Promise; + }; +}; + +export type ContactsApi = { + get(id: string): Promise; + list(): Promise; + create(params: CreateContactParams): Promise; + delete(id: string): Promise; + findContactCandidates(query: string): Promise; +}; + +export type TransactionsApi = { + get(id: string): Promise; + list(params: { + /** Opaque pagination token from a previous page's `nextCursor`. */ + cursor?: Cursor; + pageSize?: number; + accountId?: string; + }): Promise<{ transactions: Transaction[]; nextCursor: Cursor | null }>; + countPendingAck(): Promise; + acknowledge(transactionId: string): Promise; +}; + +/** + * `get*` methods are stateless previews; `create*` methods persist and enter + * the entity into the background lifecycle. Completion is observed via + * `events`, never called by the host. + */ +export type ReceiveApi = { + cashu: { + getLightningQuote( + params: GetCashuReceiveLightningQuoteParams, + ): Promise; + createQuote( + params: CreateCashuReceiveQuoteParams, + ): Promise; + getQuote(id: string): Promise; + }; + spark: { + getLightningQuote( + params: GetSparkReceiveLightningQuoteParams, + ): Promise; + createQuote( + params: CreateSparkReceiveQuoteParams, + ): Promise; + getQuote(id: string): Promise; + }; + cashuToken: { + getQuote( + params: GetReceiveCashuTokenQuoteParams, + ): Promise; + claim(params: ClaimCashuTokenParams): Promise; + }; +}; + +export type SendApi = { + resolveDestination(input: string): Promise; + cashu: { + getLightningQuote( + params: GetCashuSendLightningQuoteParams, + ): Promise; + createQuote( + params: CreateCashuSendQuoteParams, + ): Promise<{ transactionId: string }>; + /** Send-to-token. */ + getSwapQuote(params: GetCashuSwapQuoteParams): Promise; + createSwap(params: CreateCashuSwapParams): Promise; + }; + spark: { + getLightningQuote( + params: GetSparkSendLightningQuoteParams, + ): Promise; + createQuote( + params: CreateSparkSendQuoteParams, + ): Promise<{ transactionId: string }>; + }; +}; + +export type TransferApi = { + /** Stateless preview. */ + getQuote(params: GetTransferQuoteParams): Promise; // public projection of TransferQuote settles in step 16 + initiate(params: InitiateTransferParams): Promise<{ transactionId: string }>; +}; + +/** Flags are a process-local cached read — the one no-cache exception. */ +export type FeatureFlagsApi = { + get(flag: FeatureFlag): boolean; + /** Cache-change signal; returns unsubscribe. */ + subscribe(listener: () => void): () => void; +}; + +export type BackgroundState = 'stopped' | 'follower' | 'leader' | 'error'; + +/** + * Execution is background-only: a host must run `start()` somewhere or + * nothing moves money. The executing instance may differ from the initiating + * one (the leader lock is per-user across devices). + */ +export type BackgroundApi = { + /** + * Leader election + processors. + * @throws {SdkError} when no authenticated session exists. + */ + start(): void; + /** + * Stops claiming new work immediately, awaits in-flight iterations to their + * next checkpoint (bounded by a timeout), releases the leader lock, and + * abandons the remaining queue. + */ + stop(): Promise; + readonly state: BackgroundState; +}; + +/** + * Payloads are decrypted domain objects. Naming: `.`, verbs per + * entity; terminal transitions arrive as `updated` with the new state on the + * payload. Adding events is non-breaking; renaming is breaking. + */ +export type WalletEventMap = { + /** The session died without a `signOut()` call (expiry / failed refresh). */ + 'auth.session-expired': Record; + 'user.updated': { user: User }; + 'account.created': { account: Account }; + /** A persisted row changed; the payload carries a `version` consumers gate on. */ + 'account.updated': { account: Account }; + /** Versionless balance signal from both rails; spark's only balance path. */ + 'account.balance-changed': { accountId: string; balance: Money }; + 'contact.created': { contact: Contact }; + 'contact.deleted': { contact: Contact }; + 'transaction.created': { transaction: Transaction }; + 'transaction.updated': { transaction: Transaction }; + 'cashu-receive-quote.created': { quote: CashuReceiveQuote }; + 'cashu-receive-quote.updated': { quote: CashuReceiveQuote }; + 'cashu-receive-swap.created': { swap: CashuReceiveSwap }; + 'cashu-receive-swap.updated': { swap: CashuReceiveSwap }; + 'spark-receive-quote.created': { quote: SparkReceiveQuote }; + 'spark-receive-quote.updated': { quote: SparkReceiveQuote }; + 'cashu-send-quote.created': { quote: CashuSendQuote }; + 'cashu-send-quote.updated': { quote: CashuSendQuote }; + 'cashu-send-swap.created': { swap: CashuSendSwap }; + 'cashu-send-swap.updated': { swap: CashuSendSwap }; + 'spark-send-quote.created': { quote: SparkSendQuote }; + 'spark-send-quote.updated': { quote: SparkSendQuote }; + /** + * Emits on every transition into `connected`, including the initial + * connection — the invalidate-all signal. `error` is terminal: the channel + * is dead after retries exhaust, distinct from a long `reconnecting`. + */ + 'connection.changed': { state: 'connected' | 'reconnecting' | 'error' }; + /** + * Fires on every `state` transition; `error` set on transitions into + * `'error'`. Per-task errors never change state, so they never fire it. + */ + 'background.state-changed': { state: BackgroundState; error?: SdkError }; +}; + +/** + * `on()` only registers a handler and is callable with no session; the + * per-user realtime channel is established when a session comes into + * existence (login, or `init()` session restore). Returns unsubscribe. + */ +export type WalletEvents = { + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void; +}; + +/** + * Server-side trust model: service-role key, no user session, per-request + * scope. No `auth`, no `events`, no `background`. + */ +export type ServerSdkConfig = { + db: { url: string; serviceRoleKey: string }; + spark: { + breezApiKey: string; + network: SparkNetwork; + mnemonic: string; + storageDir: string; + }; + /** Hex; encrypts LNURL verify payloads. */ + quoteEncryptionKey: string; +}; + +export type ServerSdk = { + readonly lightningAddress: { + handleLud16Request(params: { + username: string; + baseUrl: string; + }): Promise; + handleLnurlpCallback(params: { + userId: string; + amount: Money<'BTC'>; + baseUrl: string; + /** Per-request by design — instance state would race on the per-process singleton. */ + bypassAmountValidation?: boolean; + }): Promise; + handleLnurlpVerify(params: { + encryptedQuoteData: string; + }): Promise; + }; +}; + +/** Singleton per process. */ +export type ServerSdkConstructor = { + create(config: ServerSdkConfig): ServerSdk; +}; + +// Settles in step N — pinned by that slice PR to the public projection of +// today's service types. + +export type AcceptTermsParams = unknown; // step 5 (auth & user) +export type SetDefaultAccountParams = unknown; // step 5 (auth & user) +export type SetDefaultCurrencyParams = unknown; // step 5 (auth & user) +export type AddCashuAccountParams = unknown; // step 6 (accounts) +export type CreateContactParams = unknown; // step 7 (contacts) +export type GetCashuReceiveLightningQuoteParams = unknown; // step 9 (cashu receive quote) +export type CreateCashuReceiveQuoteParams = unknown; // step 9 (cashu receive quote) +export type GetSparkReceiveLightningQuoteParams = unknown; // step 11 (spark receive quote) +export type CreateSparkReceiveQuoteParams = unknown; // step 11 (spark receive quote) +export type GetReceiveCashuTokenQuoteParams = unknown; // step 12 (receive cashu token) +export type ReceiveCashuTokenQuote = unknown; // step 12 (receive cashu token) +export type ClaimCashuTokenParams = unknown; // step 12 (receive cashu token) +export type ClaimCashuTokenResult = unknown; // step 12 (receive cashu token) +export type GetCashuSendLightningQuoteParams = unknown; // step 13 (cashu send quote) +export type CreateCashuSendQuoteParams = unknown; // step 13 (cashu send quote) +export type GetCashuSwapQuoteParams = unknown; // step 14 (cashu send swap) +export type CreateCashuSwapParams = unknown; // step 14 (cashu send swap) +export type CreateCashuSwapResult = unknown; // step 14 (cashu send swap) +export type GetSparkSendLightningQuoteParams = unknown; // step 15 (spark send quote) +export type CreateSparkSendQuoteParams = unknown; // step 15 (spark send quote) +export type GetTransferQuoteParams = unknown; // step 16 (transfer) +export type InitiateTransferParams = unknown; // step 16 (transfer)