From 9a8314a5f1ef89765a452667b4acfba8bbe9e608 Mon Sep 17 00:00:00 2001 From: orveth Date: Thu, 2 Jul 2026 09:21:16 -0700 Subject: [PATCH 1/8] docs(wallet-sdk): propose the step-4 public contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sdk.create(config) ports, per-domain namespaces, ServerSdk, background contract shape, and the event map — grounded in the extracted domain layer's constructor graph and the web app's /temporary consumer map. Co-Authored-By: Claude Fable 5 --- ...2026-07-02-wallet-sdk-contract-proposal.md | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md 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..9fff37988 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -0,0 +1,275 @@ +# 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 + 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; + storageDir?: string; // node hosts; browser default applies + }; + lightningAddressDomain: string; // lud16 domain for contacts/display +}; + +type AuthStorage = { + get(key: string): Promise; + set(key: string, value: string): Promise; + remove(key: string): Promise; +}; + +class Sdk { + static create(config: SdkConfig): Sdk; // sync; connects lazily + 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`. +- **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: constructing an `Sdk` does no I/O. First use of a + namespace (or `auth.getSession()`) drives connection. This keeps hosts free + to construct at module scope. + +## 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; + exchangeRate: ExchangeRateApi; + 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 + addCashuAccount(params): Promise; +}; + +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 BackgroundApi = { + start(): void; // leader election + change feed + processors + stop(): Promise; + readonly state: 'stopped' | 'follower' | 'leader'; +}; +``` + +Two 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). +- **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. + +## Events + +```ts +type WalletEventMap = { + 'user.updated': { user: User }; + 'account.created' | 'account.updated': { account: Account }; + 'contact.created' | 'contact.deleted': { contact: Contact }; + 'transaction.created' | 'transaction.updated': { transaction: Transaction }; + 'receive.cashu-quote.updated': { quote: CashuReceiveQuote }; + 'receive.cashu-swap.updated': { swap: CashuReceiveSwap }; + 'receive.spark-quote.updated': { quote: SparkReceiveQuote }; + 'send.cashu-quote.updated': { quote: CashuSendQuote }; + 'send.cashu-swap.updated': { swap: CashuSendSwap }; + 'send.spark-quote.updated': { quote: SparkSendQuote }; + 'connection.changed': { state: 'connected' | 'reconnecting' }; +}; + +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). +- `connection.changed → 'connected'` is the web's invalidate-all signal on + reconnect, replacing today's `onConnected` cache sweep. +- 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; anonKey: string }; + spark: { breezApiKey: string; network: SparkNetwork; mnemonic: 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 }): + 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`. + +## 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: `DomainError`, `ConcurrencyError`, `NotFoundError`, + `UniqueConstraintError` + +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 | +| pure codecs/validators/errors | root exports | +| feature-flag fns | `sdk.featureFlags` | +| exchange-rate | `sdk.exchangeRate` | +| account/user predicate helpers (`getAccountBalance`, `shouldVerifyEmail`…) | stay root exports (pure fns over domain types) | +| `TaskProcessingLockRepository`, processors | internal, behind `sdk.background` | + +## Decision points (need a call before slice PRs start) + +1. **DB port granularity** — proposal: `{ url, anonKey }`, SDK builds the + client and wires its own access token (auth is inside). Alternative: host + passes a pre-built Supabase client; rejected here because the token getter + would point back into the SDK. +2. **Flow-first namespaces** (`sdk.receive.cashu`) over rail-first + (`sdk.cashu.receive`) — proposal: flow-first, matches app mental model and + slice sequence. +3. **`userId` implicit from session** — proposal: yes; repos keep explicit + params internally, namespaces close over the session. +4. **Processor verbs off the public surface** — proposal: `complete/expire/ + fail` are background-only; hosts observe via events. This is the strongest + simplification vs. today and worth explicit sign-off. From 38ad003032d88169be6ec5a8a7c6c46864e7d8f4 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 3 Jul 2026 04:27:42 -0700 Subject: [PATCH 2/8] docs(wallet-sdk): fold in PR-1164 review outcomes accounts.add discriminated input, entity-named events with created variants, SdkError base class. Co-Authored-By: Claude Fable 5 --- ...2026-07-02-wallet-sdk-contract-proposal.md | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) 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 index 9fff37988..8c3f13ff7 100644 --- a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -139,7 +139,7 @@ each slice PR, per the parent spec): type AccountsApi = { get(id: string): Promise; list(): Promise; // active accounts, current user - addCashuAccount(params): Promise; + add(params: AddAccountParams): Promise; // input discriminated on type; only 'cashu' addable today }; type TransactionsApi = { @@ -175,12 +175,12 @@ type WalletEventMap = { 'account.created' | 'account.updated': { account: Account }; 'contact.created' | 'contact.deleted': { contact: Contact }; 'transaction.created' | 'transaction.updated': { transaction: Transaction }; - 'receive.cashu-quote.updated': { quote: CashuReceiveQuote }; - 'receive.cashu-swap.updated': { swap: CashuReceiveSwap }; - 'receive.spark-quote.updated': { quote: SparkReceiveQuote }; - 'send.cashu-quote.updated': { quote: CashuSendQuote }; - 'send.cashu-swap.updated': { swap: CashuSendSwap }; - 'send.spark-quote.updated': { quote: SparkSendQuote }; + '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' }; }; @@ -195,6 +195,11 @@ type WalletEvents = { - 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. Every persisted entity emits `created` and `updated` — `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. - `connection.changed → 'connected'` is the web's invalidate-all signal on reconnect, replacing today's `onConnected` cache sweep. - Event names are stable contract; adding events is non-breaking, renaming is @@ -237,8 +242,11 @@ helpers the web consumes that need no instance state: - codecs/inspection: `decodeCashuToken`, `tokenToMoney`, `getTokenHash` - validation: `validateBolt11`, `validateLightningAddressFormat`, `cashuMintValidator` -- errors: `DomainError`, `ConcurrencyError`, `NotFoundError`, - `UniqueConstraintError` +- errors: `SdkError` (abstract base — everything the SDK throws extends it, + giving hosts one `instanceof` check at the boundary) with `DomainError`, + `ConcurrencyError`, `NotFoundError`, `UniqueConstraintError`. Subclass + semantics are contract: `DomainError.message` is the only user-displayable + message; `ConcurrencyError` always means retry. Anything touching the DB, keys, or accounts lives on the instance. The Zod row schemas + transaction-details parsers the web imports today are From 7b23f9eddf94b2dea6a304951d1a568a48a1bad7 Mon Sep 17 00:00:00 2001 From: orveth Date: Fri, 3 Jul 2026 04:33:07 -0700 Subject: [PATCH 3/8] docs(wallet-sdk): nest account creation per rail (accounts.cashu.add) Co-Authored-By: Claude Fable 5 --- .../specs/2026-07-02-wallet-sdk-contract-proposal.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index 8c3f13ff7..136156e2e 100644 --- a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -139,9 +139,19 @@ each slice PR, per the parent spec): type AccountsApi = { get(id: string): Promise; list(): Promise; // active accounts, current user - add(params: AddAccountParams): Promise; // input discriminated on type; only 'cashu' addable today + 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 TransactionsApi = { get(id: string): Promise; list(params: { cursor?: Cursor; pageSize?: number; accountId?: string }): From d784ddcfa4adcd79786016c43258ecde81b18042 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 7 Jul 2026 07:16:28 -0700 Subject: [PATCH 4/8] docs(wallet-sdk): fold in PR-1164 review round 2 (ditto + petar/jbojcic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract additions from the independent review discussion: - lifecycle: add init() async second phase (session restore + WASM probe, rejects WebAssemblyUnavailableError); document lazy/eager split = master; dispose() awaits in-flight then rejects pending - config: logger port (MCP → stderr); spark.network = default-at-creation with the per-account DB value authoritative; AuthStorage binds to the @agicash/opensecret release's storage shape; supported-runtime floor - background: Execution model (money moves only under a running loop; MCP host MUST start() in-process; failover ~ lease TTL + poll); error tiers (per-task isolate via logger, systemic -> state 'error' + state-changed); stop() semantics - events: account.balance-changed (spark, versionless) vs versioned account.updated; connection 'error' terminal + emit-on-initial-connect; per-entity verbs (contact = created/deleted only); background.state-changed - namespaces: fill User/Contacts/Transfer method lists; setDefault* -> user slice; get*/create* rule; "Observing an initiated payment" idiom; public writes - exchangeRate off the instance -> stateless root export - ServerSdk: serviceRoleKey (not anon) + trust-model note; spark.storageDir; bypassAmountValidation as a method param; WebAssemblyUnavailableError root export extends SdkError - decision points 1-4 marked signed-off Broader lib/spark migration-table rows deferred per petar (revisit when needed). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-07-02-wallet-sdk-contract-proposal.md | 223 +++++++++++++++--- 1 file changed, 185 insertions(+), 38 deletions(-) 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 index 136156e2e..12da05e3e 100644 --- a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -30,7 +30,7 @@ these namespaces in one domain at a time. ```ts type SdkConfig = { db: { - url: string; // Supabase project URL + url: string; // Supabase project URL — host resolves the final URL first anonKey: string; // Supabase anon key }; auth: { @@ -40,20 +40,34 @@ type SdkConfig = { }; spark: { breezApiKey: string; - network: SparkNetwork; + 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; connects lazily + static create(config: SdkConfig): Sdk; // sync; no I/O + init(): Promise; // optional async second phase (see notes) dispose(): Promise; // tears down realtime + background } ``` @@ -63,14 +77,31 @@ 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`. + 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: constructing an `Sdk` does no I/O. First use of a - namespace (or `auth.getSession()`) drives connection. This keeps hosts free - to construct at module scope. +- **`create` is synchronous; `init()` is the optional 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 probe — 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. If the host never calls `init()`, first use lazy-initializes + exactly as today. This preserves master's split verbatim — **what is lazy + stays lazy, what is eager stays eager**: eager = session restore + WASM probe + (`init()`) and the realtime subscription (driven by `events.on()`); 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 @@ -88,7 +119,6 @@ class Sdk { send: SendApi; // steps 13–15 transfer: TransferApi; // step 16 featureFlags: FeatureFlagsApi; - exchangeRate: ExchangeRateApi; events: WalletEvents; // shape now; emits from step 18 background: BackgroundApi; // shape now; implementation step 18 } @@ -152,6 +182,27 @@ with exact input and return types, instead of widening a shared `add` signature into unions or overloads. ```ts +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 }): @@ -162,20 +213,77 @@ type TransactionsApi = { type BackgroundApi = { start(): void; // leader election + change feed + processors - stop(): Promise; - readonly state: 'stopped' | 'follower' | 'leader'; + stop(): Promise; // see stop() semantics below + readonly state: 'stopped' | 'follower' | 'leader' | 'error'; }; ``` -Two conventions across all namespaces: +### 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. + +**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** — change feed dead after retries, leader lock + unrenewable — transition `state → 'error'` and emit `background.state-changed`. + 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. + +**`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 @@ -183,6 +291,7 @@ Two conventions across all namespaces: type WalletEventMap = { 'user.updated': { user: User }; 'account.created' | 'account.updated': { account: Account }; + 'account.balance-changed': { accountId: string; balance: Money }; // spark only; no version 'contact.created' | 'contact.deleted': { contact: Contact }; 'transaction.created' | 'transaction.updated': { transaction: Transaction }; 'cashu-receive-quote.created' | 'cashu-receive-quote.updated': { quote: CashuReceiveQuote }; @@ -191,7 +300,8 @@ type WalletEventMap = { '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' }; + 'connection.changed': { state: 'connected' | 'reconnecting' | 'error' }; + 'background.state-changed': { state: BackgroundApi['state']; error?: SdkError }; }; type WalletEvents = { @@ -206,12 +316,29 @@ type WalletEvents = { 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. Every persisted entity emits `created` and `updated` — `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. -- `connection.changed → 'connected'` is the web's invalidate-all signal on - reconnect, replacing today's `onConnected` cache sweep. + 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 live rail-side + balance signal with no version semantics. Cashu accounts only ever emit + `account.updated` (balance is row-derived); **spark accounts emit + `account.balance-changed`** from the SDK's internal Breez listeners (replacing + today's raw-handle listeners). The contract `Account` carries `balance` and + does **not** expose a raw wallet handle. +- **`connection.changed`** emits on every transition into `connected` — + including the first subscribe, not only reconnects — so the invalidate-all + sweep also covers changes that land between first render and subscription + (replacing today's `onConnected`). `error` is terminal: the channel is dead + after retries exhaust (today's `SupabaseRealtimeError` → error boundary), + distinct from a long `reconnecting`. +- **`background.state-changed`** fires only on systemic background failures + (see Execution model); per-task errors never fire it. - Event names are stable contract; adding events is non-breaking, renaming is breaking. @@ -222,8 +349,8 @@ secrets, no user session, per-request scope. ```ts type ServerSdkConfig = { - db: { url: string; anonKey: string }; - spark: { breezApiKey: string; network: SparkNetwork; mnemonic: string }; + db: { url: string; serviceRoleKey: string }; + spark: { breezApiKey: string; network: SparkNetwork; mnemonic: string; storageDir: string }; quoteEncryptionKey: string; // hex; encrypts LNURL verify payloads }; @@ -232,8 +359,9 @@ class ServerSdk { lightningAddress: { handleLud16Request(params: { username: string; baseUrl: string }): Promise; - handleLnurlpCallback(params: { userId: string; amount: Money<'BTC'>; baseUrl: string }): - Promise; + handleLnurlpCallback(params: { + userId: string; amount: Money<'BTC'>; baseUrl: string; bypassAmountValidation?: boolean; + }): Promise; handleLnurlpVerify(params: { encryptedQuoteData: string }): Promise; }; @@ -244,6 +372,17 @@ 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 @@ -254,9 +393,13 @@ helpers the web consumes that need no instance state: `cashuMintValidator` - errors: `SdkError` (abstract base — everything the SDK throws extends it, giving hosts one `instanceof` check at the boundary) with `DomainError`, - `ConcurrencyError`, `NotFoundError`, `UniqueConstraintError`. Subclass - semantics are contract: `DomainError.message` is the only user-displayable - message; `ConcurrencyError` always means retry. + `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 @@ -273,21 +416,25 @@ parsing rows once events deliver domain objects). | cashu wallet plumbing (`getInitializedCashuWallet`, mint auth…) | internal | | pure codecs/validators/errors | root exports | | feature-flag fns | `sdk.featureFlags` | -| exchange-rate | `sdk.exchangeRate` | +| 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 (need a call before slice PRs start) +## Decision points (all four signed off in review #1164) + +Kept as the rationale trail; each is now resolved. -1. **DB port granularity** — proposal: `{ url, anonKey }`, SDK builds the - client and wires its own access token (auth is inside). Alternative: host - passes a pre-built Supabase client; rejected here because the token getter - would point back into the SDK. +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`) — proposal: flow-first, matches app mental model and - slice sequence. -3. **`userId` implicit from session** — proposal: yes; repos keep explicit - params internally, namespaces close over the session. -4. **Processor verbs off the public surface** — proposal: `complete/expire/ - fail` are background-only; hosts observe via events. This is the strongest - simplification vs. today and worth explicit sign-off. + (`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. From 93fc0e257b150c3339f153932eb642d1776d16b8 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 8 Jul 2026 05:10:05 -0700 Subject: [PATCH 5/8] docs(wallet-sdk): fold in PR-1164 review round 3 (ditto + petar/jbojcic) Contract changes from the round-3 threads: - events: account.balance-changed emitted by BOTH rails (cashu alongside versioned account.updated, spark from internal Breez listeners); versioned vs versionless split restated per-consumer - lifecycle: init() resolves when no session exists (logged-out is a state, not a failure); rejects only on WASM/storage/refresh errors - anon context: events.on() never initiates the realtime connection and is callable with no session; the per-user channel rides the session (login / restore), establishment emits connection.changed 'connected'; background.start() throws with no session - background: start() = leader election + processors (change feed belongs to events; its death = connection.changed 'error', not a background failure); background.state-changed fires on every state transition, error? set on transitions into 'error' - namespaces: AuthApi method list (master's useAuthActions verbs verbatim + getSession() sync guard read + auth.session-expired event) and FeatureFlagsApi (get + subscribe, process-local cache exception) - migration mapping: lib/spark rows (key/wallet plumbing, sparkDebugLog, ensureBreezWasm, WebAssemblyUnavailableError) Co-Authored-By: Claude Fable 5 --- ...2026-07-02-wallet-sdk-contract-proposal.md | 112 ++++++++++++++---- 1 file changed, 88 insertions(+), 24 deletions(-) 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 index 12da05e3e..e8ea8a725 100644 --- a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -91,11 +91,15 @@ Notes: can *fail* — session restore and the Breez WASM probe — 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. If the host never calls `init()`, first use lazy-initializes - exactly as today. This preserves master's split verbatim — **what is lazy - stays lazy, what is eager stays eager**: eager = session restore + WASM probe - (`init()`) and the realtime subscription (driven by `events.on()`); lazy = - per-account Spark connect, cashu wallet init, and all reads (first use). + 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. If the host never calls `init()`, first use + lazy-initializes exactly as today. This preserves master's split verbatim — + **what is lazy stays lazy, what is eager stays eager**: eager = session + restore + WASM probe (`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`. @@ -182,6 +186,23 @@ 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; @@ -211,13 +232,26 @@ type TransactionsApi = { 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 + change feed + processors + 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.** @@ -234,15 +268,22 @@ consumers depend on it: 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** — change feed dead after retries, leader lock - unrenewable — transition `state → 'error'` and emit `background.state-changed`. - 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. +- **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, @@ -289,9 +330,10 @@ only an id — the asymmetry is intentional, not an oversight. ```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 }; // spark only; no version + '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 }; @@ -325,20 +367,38 @@ type WalletEvents = { 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 live rail-side - balance signal with no version semantics. Cashu accounts only ever emit - `account.updated` (balance is row-derived); **spark accounts emit - `account.balance-changed`** from the SDK's internal Breez listeners (replacing - today's raw-handle listeners). The contract `Account` carries `balance` and - does **not** expose a raw wallet handle. + 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 first subscribe, not only reconnects — so the invalidate-all - sweep also covers changes that land between first render and subscription - (replacing today's `onConnected`). `error` is terminal: the channel is dead - after retries exhaust (today's `SupabaseRealtimeError` → error boundary), - distinct from a long `reconnecting`. -- **`background.state-changed`** fires only on systemic background failures - (see Execution model); per-task errors never fire it. + 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. @@ -414,6 +474,10 @@ parsing rows once events deliver domain objects). | 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) | From a8cee5d1930117e045cc9ff691d53daf8536c86d Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 8 Jul 2026 06:53:48 -0700 Subject: [PATCH 6/8] feat(wallet-sdk): add the contract types (step 4 code part) sdk.ts defines the public contract from the proposal doc as compilable types: SdkConfig ports (db/auth/spark/logger), Sdk + SdkConstructor, per-domain namespace APIs (auth incl. AuthSession sync guard read, user, accounts, contacts, transactions, receive.{cashu,spark,cashuToken}, send.{cashu,spark}, transfer, featureFlags, background), the typed WalletEventMap/WalletEvents, and ServerSdk(Config). Return types wire the existing domain types; params the doc defers are named placeholder aliases JSDoc'd with the slice that pins them. lib/error.ts adds the abstract SdkError base (one instanceof check at the host boundary) and re-parents DomainError/ConcurrencyError/ NotFoundError/UniqueConstraintError under it. WebAssemblyUnavailableError moves here verbatim so the root export does not pull @agicash/breez-sdk-spark into every consumer; wasm.ts re-exports it, so the /temporary surface is unchanged. index.ts exports the contract types + error classes from the root. Co-Authored-By: Claude Fable 5 --- packages/wallet-sdk/index.ts | 63 +++- packages/wallet-sdk/lib/error.ts | 27 +- packages/wallet-sdk/lib/spark/wasm.ts | 15 +- packages/wallet-sdk/sdk.ts | 429 ++++++++++++++++++++++++++ 4 files changed, 514 insertions(+), 20 deletions(-) create mode 100644 packages/wallet-sdk/sdk.ts diff --git a/packages/wallet-sdk/index.ts b/packages/wallet-sdk/index.ts index cf7ce7b68..c152791b5 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -1,7 +1,62 @@ -// @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. +export type { + AcceptTermsParams, + AccountsApi, + AddCashuAccountParams, + AuthApi, + AuthSession, + AuthStorage, + AuthUser, + BackgroundApi, + BackgroundState, + ClaimCashuTokenParams, + ClaimCashuTokenResult, + ContactsApi, + CreateCashuReceiveQuoteParams, + CreateCashuSendQuoteParams, + CreateCashuSwapParams, + CreateCashuSwapResult, + CreateContactParams, + CreateSparkReceiveQuoteParams, + CreateSparkSendQuoteParams, + Cursor, + FeatureFlagsApi, + GetCashuReceiveLightningQuoteParams, + GetCashuSendLightningQuoteParams, + GetCashuSwapQuoteParams, + GetReceiveCashuTokenQuoteParams, + GetSparkReceiveLightningQuoteParams, + GetSparkSendLightningQuoteParams, + GetTransferQuoteParams, + InitiateTransferParams, + Logger, + ReceiveApi, + ReceiveCashuTokenQuote, + Sdk, + SdkConfig, + SdkConstructor, + SendApi, + ServerSdk, + ServerSdkConfig, + ServerSdkConstructor, + TransactionsApi, + TransferApi, + UserApi, + WalletEventMap, + WalletEvents, +} from './sdk'; +export { + ConcurrencyError, + DomainError, + NotFoundError, + SdkError, + UniqueConstraintError, + WebAssemblyUnavailableError, +} from './lib/error'; 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..64e5e7839 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, @@ -23,3 +30,15 @@ export class ConcurrencyError extends Error { this.name = 'ConcurrencyError'; } } + +/** + * 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'; + } +} diff --git a/packages/wallet-sdk/lib/spark/wasm.ts b/packages/wallet-sdk/lib/spark/wasm.ts index 998767e89..cb036b543 100644 --- a/packages/wallet-sdk/lib/spark/wasm.ts +++ b/packages/wallet-sdk/lib/spark/wasm.ts @@ -1,18 +1,9 @@ import initBreezWasm from '@agicash/breez-sdk-spark'; +import { WebAssemblyUnavailableError } from '../error'; -let wasmInitPromise: ReturnType | null = null; +export { WebAssemblyUnavailableError }; -/** - * 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'; - } -} +let wasmInitPromise: ReturnType | null = null; /** * Initializes the Breez SDK WASM module exactly once, even across concurrent diff --git a/packages/wallet-sdk/sdk.ts b/packages/wallet-sdk/sdk.ts new file mode 100644 index 000000000..f2c2b0bbd --- /dev/null +++ b/packages/wallet-sdk/sdk.ts @@ -0,0 +1,429 @@ +/** + * @agicash/wallet-sdk public contract — the types the migration slices + * implement against. Prose contract (semantics, invariants, rationale): + * docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md + * + * Shapes marked "settles in step N" are placeholders a slice PR pins to the + * public projection of today's service types (internals like `userId` and raw + * wallet handles never appear on the public surface). + */ +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 { Account, CashuAccount } from './domain/accounts/account'; +import type { Contact } from './domain/contacts/contact'; +import type { CashuReceiveQuote } from './domain/receive/cashu-receive-quote'; +import type { CashuReceiveLightningQuote } from './domain/receive/cashu-receive-quote-core'; +import type { CashuReceiveSwap } from './domain/receive/cashu-receive-swap'; +import type { SparkReceiveQuote } from './domain/receive/spark-receive-quote'; +import type { SparkReceiveLightningQuote } from './domain/receive/spark-receive-quote-core'; +import type { CashuSendQuote } from './domain/send/cashu-send-quote'; +import type { CashuLightningQuote } from './domain/send/cashu-send-quote-service'; +import type { CashuSendSwap } from './domain/send/cashu-send-swap'; +import type { CashuSwapQuote } from './domain/send/cashu-send-swap-service'; +import type { SparkSendQuote } from './domain/send/spark-send-quote'; +import type { SparkLightningQuote } from './domain/send/spark-send-quote-service'; +import type { Transaction } 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 }; + +/** + * Host-backed session persistence. 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. + */ +export 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. + */ +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: { + /** Supabase project URL — the host resolves the final URL before `create()`. */ + url: string; + anonKey: string; + }; + auth: { + /** Open Secret backend URL. */ + apiUrl: string; + /** Open Secret client id. */ + clientId: string; + storage: AuthStorage; + }; + spark: { + breezApiKey: string; + /** + * Default used when the SDK creates an account; the per-account value + * persisted in the DB is authoritative for every account after that. + */ + network: SparkNetwork; + /** Node hosts; browser default applies. */ + storageDir?: string; + }; + /** lud16 domain for contacts/display. */ + 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; + /** + * Optional async second phase: front-loads the inits that can fail — + * session restore and the Breez WASM probe. Resolves when no session exists + * (absence of a session is a state, not a failure); rejects only on actual + * failures, e.g. `WebAssemblyUnavailableError`. Never called → first use + * lazy-initializes. + */ + 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; +}; + +/** The implementing class satisfies this statically: `create` is sync, no I/O. */ +export type SdkConstructor = { + create(config: SdkConfig): Sdk; +}; + +export type AuthUser = unknown; // settles in step 5: binds to the @agicash/opensecret release's user shape + +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 alive in anonymous state — `dispose()` is instance + * teardown, not logout. + */ + signOut(): Promise; + verifyEmail(code: string): Promise; + requestNewVerificationCode(): Promise; + convertGuestToFullAccount(email: string, password: string): Promise; + /** Host redirects to `authUrl`. */ + initiateGoogleAuth(): Promise<{ authUrl: string }>; + /** OAuth callback leg. */ + completeGoogleAuth(params: { code: string; state: string }): Promise; + /** Sync snapshot for route guards; 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 — they compute and return without + * persisting. `create*` methods persist and enter the entity into the + * background lifecycle. Completion is not the host's job: execution is + * background-only, observed via `events` (subscribe first, then read the + * baseline — see "Observing an initiated payment" in the contract doc). + */ +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; + initiate(params: InitiateTransferParams): Promise<{ transactionId: string }>; +}; + +/** + * Feature flags are the documented process-local cache exception (contract + * principles, rule 1) — hence the sync `get` + `subscribe` pair instead of + * promises. + */ +export type FeatureFlagsApi = { + get(flag: FeatureFlag): boolean; + /** Cache-change signal (web: `useSyncExternalStore`); returns unsubscribe. */ + subscribe(listener: () => void): () => void; +}; + +export type BackgroundState = 'stopped' | 'follower' | 'leader' | 'error'; + +/** + * Nothing moves money unless a background loop is running somewhere: an MCP / + * request-response host MUST call `start()` in-process, or its own sends sit + * UNPAID forever. 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 — background work + * is per-user by construction. + */ + 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 invariant: `.`, + * verbs per entity (an entity emits the verbs its data model supports); + * terminal transitions arrive as `updated` with the new state on the payload. + * Event names are stable contract; adding events is non-breaking, renaming is + * breaking. + */ +export type WalletEventMap = { + /** + * The session died without a `signOut()` call (expiry / failed refresh) — + * the host renders its "session expired" path from this one subscription. + */ + '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, both rails: cashu alongside the versioned + * `account.updated` for the same change, spark from the SDK's internal + * Breez listeners (spark's only balance path — spark balances are rail-side + * state, not rows). + */ + '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 — as the host's 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 don't change state, so they never fire it. + */ + 'background.state-changed': { state: BackgroundState; error?: SdkError }; +}; + +/** + * `on()` never initiates the realtime connection — it only registers a + * handler, and is callable with no session. The per-user channel is + * established by the session coming into existence (login, or `init()`'s + * session restore). + */ +export type WalletEvents = { + on( + event: K, + handler: (payload: WalletEventMap[K]) => void, + ): () => void; +}; + +/** + * Lightning-address routes run server-side with different trust: env-provided + * secrets, no user session, per-request scope. `db` uses the service-role key + * (cross-user reads with no user session, where anon + RLS returns nothing). + * No `auth` port, 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; + /** + * Selects the agicash→agicash pay path; per-request by design — as + * instance state it would race across concurrent requests on the + * per-process singleton. + */ + bypassAmountValidation?: boolean; + }): Promise; + handleLnurlpVerify(params: { + encryptedQuoteData: string; + }): Promise; + }; +}; + +/** Singleton per process; the `Request` of the route becomes per-method `baseUrl`. */ +export type ServerSdkConstructor = { + create(config: ServerSdkConfig): ServerSdk; +}; + +// --- Slice-settled shapes ------------------------------------------------- +// Each alias is pinned by its slice PR to the public projection of today's +// service types. They exist now so the namespace structure, method names, and +// return types are compiler-checked contract from step 4 on. + +export type AcceptTermsParams = unknown; // settles in step 5 (auth & user) +export type SetDefaultAccountParams = unknown; // settles in step 5 (auth & user) +export type SetDefaultCurrencyParams = unknown; // settles in step 5 (auth & user) +export type AddCashuAccountParams = unknown; // settles in step 6 (accounts) +export type CreateContactParams = unknown; // settles in step 7 (contacts) +export type GetCashuReceiveLightningQuoteParams = unknown; // settles in step 9 (cashu receive quote) +export type CreateCashuReceiveQuoteParams = unknown; // settles in step 9 (cashu receive quote) +export type GetSparkReceiveLightningQuoteParams = unknown; // settles in step 11 (spark receive quote) +export type CreateSparkReceiveQuoteParams = unknown; // settles in step 11 (spark receive quote) +export type GetReceiveCashuTokenQuoteParams = unknown; // settles in step 12 (receive cashu token) +export type ReceiveCashuTokenQuote = unknown; // settles in step 12 (today: CrossAccountReceiveQuotesResult) +export type ClaimCashuTokenParams = unknown; // settles in step 12 (receive cashu token) +export type ClaimCashuTokenResult = unknown; // settles in step 12 (receive cashu token) +export type GetCashuSendLightningQuoteParams = unknown; // settles in step 13 (cashu send quote) +export type CreateCashuSendQuoteParams = unknown; // settles in step 13 (cashu send quote) +export type GetCashuSwapQuoteParams = unknown; // settles in step 14 (cashu send swap) +export type CreateCashuSwapParams = unknown; // settles in step 14 (cashu send swap) +export type CreateCashuSwapResult = unknown; // settles in step 14 (cashu send swap) +export type GetSparkSendLightningQuoteParams = unknown; // settles in step 15 (spark send quote) +export type CreateSparkSendQuoteParams = unknown; // settles in step 15 (spark send quote) +export type GetTransferQuoteParams = unknown; // settles in step 16 (transfer) +export type InitiateTransferParams = unknown; // settles in step 16 (transfer) From e73b334fe7da285138efd3acc266821b9de26160 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 8 Jul 2026 07:03:36 -0700 Subject: [PATCH 7/8] fix(wallet-sdk): public entity projections on the contract surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model review on the contract types found the domain types leaking internals through the public surface: Account carries raw wallet handles (BreezSdk / ExtendedCashuWallet) and proof material, and the persisted entities (Transaction, Contact, quotes, swaps) carry userId/ownerId — both banned by the contract doc (userId is implicit from the session; "the contract Account carries balance and does not expose a raw wallet handle"). sdk.ts now defines public projections and every contract position uses them: SdkAccount = SdkCashuAccount | SdkSparkAccount with balance on every rail and no wallet/proofs/keysetCounters; Omit<..., 'userId'> for transaction/quote/swap entities and Omit; SdkTransferQuote stays a step-16 placeholder because today's TransferQuote embeds raw accounts. Each projection takes the bare domain name once its slice flips the web imports off /temporary. The root re-export list also gains the missing SetDefaultAccountParams and SetDefaultCurrencyParams. Co-Authored-By: Claude Fable 5 --- packages/wallet-sdk/index.ts | 14 ++++++ packages/wallet-sdk/sdk.ts | 95 +++++++++++++++++++++++------------- 2 files changed, 75 insertions(+), 34 deletions(-) diff --git a/packages/wallet-sdk/index.ts b/packages/wallet-sdk/index.ts index c152791b5..ef119deb9 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -37,12 +37,26 @@ export type { ReceiveApi, ReceiveCashuTokenQuote, Sdk, + SdkAccount, + SdkCashuAccount, + SdkCashuReceiveQuote, + SdkCashuReceiveSwap, + SdkCashuSendQuote, + SdkCashuSendSwap, SdkConfig, SdkConstructor, + SdkContact, + SdkSparkAccount, + SdkSparkReceiveQuote, + SdkSparkSendQuote, + SdkTransaction, + SdkTransferQuote, SendApi, ServerSdk, ServerSdkConfig, ServerSdkConstructor, + SetDefaultAccountParams, + SetDefaultCurrencyParams, TransactionsApi, TransferApi, UserApi, diff --git a/packages/wallet-sdk/sdk.ts b/packages/wallet-sdk/sdk.ts index f2c2b0bbd..02f314a17 100644 --- a/packages/wallet-sdk/sdk.ts +++ b/packages/wallet-sdk/sdk.ts @@ -15,7 +15,7 @@ import type { } from '@agicash/lnurl'; import type { Money } from '@agicash/money'; import type { SparkNetwork } from './db/json-models/spark-account-details-db-data'; -import type { Account, CashuAccount } from './domain/accounts/account'; +import type { CashuAccount, SparkAccount } from './domain/accounts/account'; import type { Contact } from './domain/contacts/contact'; import type { CashuReceiveQuote } from './domain/receive/cashu-receive-quote'; import type { CashuReceiveLightningQuote } from './domain/receive/cashu-receive-quote-core'; @@ -30,7 +30,6 @@ import type { SparkSendQuote } from './domain/send/spark-send-quote'; import type { SparkLightningQuote } from './domain/send/spark-send-quote-service'; import type { Transaction } 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'; @@ -38,6 +37,33 @@ import type { DestinationDetails } from './lib/send-destination'; export type { Cursor }; +// --- Public entity projections --------------------------------------------- +// The contract returns and emits public projections of the domain entities: +// `userId`/`ownerId` are implicit from the session, and raw wallet handles / +// proof material never appear on the public surface. Each projection takes +// the bare domain name once its slice flips the web imports off /temporary. + +/** + * The contract account: carries `balance` on every rail, never a raw wallet + * handle or proof material. The exact cashu field set (e.g. whether + * `keysetCounters` stays internal) settles in the accounts slice (step 6). + */ +export type SdkCashuAccount = Omit< + CashuAccount, + 'keysetCounters' | 'proofs' | 'wallet' +> & { balance: Money | null }; +export type SdkSparkAccount = Omit; +export type SdkAccount = SdkCashuAccount | SdkSparkAccount; + +export type SdkContact = Omit; +export type SdkTransaction = Omit; +export type SdkCashuReceiveQuote = Omit; +export type SdkSparkReceiveQuote = Omit; +export type SdkCashuReceiveSwap = Omit; +export type SdkCashuSendQuote = Omit; +export type SdkCashuSendSwap = Omit; +export type SdkSparkSendQuote = Omit; + /** * Host-backed session persistence. Binds to the React-agnostic * `@agicash/opensecret` release's storage-provider interface verbatim (method @@ -161,30 +187,30 @@ export type UserApi = { }; export type AccountsApi = { - get(id: string): Promise; + get(id: string): Promise; /** Active accounts of the current user. */ - list(): Promise; + list(): Promise; cashu: { - add(params: AddCashuAccountParams): Promise; + add(params: AddCashuAccountParams): Promise; }; }; export type ContactsApi = { - get(id: string): Promise; - list(): Promise; - create(params: CreateContactParams): Promise; + get(id: string): Promise; + list(): Promise; + create(params: CreateContactParams): Promise; delete(id: string): Promise; - findContactCandidates(query: string): Promise; + findContactCandidates(query: string): Promise; }; export type TransactionsApi = { - get(id: string): Promise; + 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 }>; + }): Promise<{ transactions: SdkTransaction[]; nextCursor: Cursor | null }>; countPendingAck(): Promise; acknowledge(transactionId: string): Promise; }; @@ -203,8 +229,8 @@ export type ReceiveApi = { ): Promise; createQuote( params: CreateCashuReceiveQuoteParams, - ): Promise; - getQuote(id: string): Promise; + ): Promise; + getQuote(id: string): Promise; }; spark: { getLightningQuote( @@ -212,8 +238,8 @@ export type ReceiveApi = { ): Promise; createQuote( params: CreateSparkReceiveQuoteParams, - ): Promise; - getQuote(id: string): Promise; + ): Promise; + getQuote(id: string): Promise; }; cashuToken: { getQuote( @@ -248,7 +274,7 @@ export type SendApi = { export type TransferApi = { /** Stateless preview. */ - getQuote(params: GetTransferQuoteParams): Promise; + getQuote(params: GetTransferQuoteParams): Promise; initiate(params: InitiateTransferParams): Promise<{ transactionId: string }>; }; @@ -301,9 +327,9 @@ export type WalletEventMap = { */ 'auth.session-expired': Record; 'user.updated': { user: User }; - 'account.created': { account: Account }; + 'account.created': { account: SdkAccount }; /** A persisted row changed; the payload carries a `version` consumers gate on. */ - 'account.updated': { account: Account }; + 'account.updated': { account: SdkAccount }; /** * Versionless balance signal, both rails: cashu alongside the versioned * `account.updated` for the same change, spark from the SDK's internal @@ -311,22 +337,22 @@ export type WalletEventMap = { * state, not rows). */ '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 }; + 'contact.created': { contact: SdkContact }; + 'contact.deleted': { contact: SdkContact }; + 'transaction.created': { transaction: SdkTransaction }; + 'transaction.updated': { transaction: SdkTransaction }; + 'cashu-receive-quote.created': { quote: SdkCashuReceiveQuote }; + 'cashu-receive-quote.updated': { quote: SdkCashuReceiveQuote }; + 'cashu-receive-swap.created': { swap: SdkCashuReceiveSwap }; + 'cashu-receive-swap.updated': { swap: SdkCashuReceiveSwap }; + 'spark-receive-quote.created': { quote: SdkSparkReceiveQuote }; + 'spark-receive-quote.updated': { quote: SdkSparkReceiveQuote }; + 'cashu-send-quote.created': { quote: SdkCashuSendQuote }; + 'cashu-send-quote.updated': { quote: SdkCashuSendQuote }; + 'cashu-send-swap.created': { swap: SdkCashuSendSwap }; + 'cashu-send-swap.updated': { swap: SdkCashuSendSwap }; + 'spark-send-quote.created': { quote: SdkSparkSendQuote }; + 'spark-send-quote.updated': { quote: SdkSparkSendQuote }; /** * Emits on every transition into `connected` — including the initial * connection — as the host's invalidate-all signal. `error` is terminal: @@ -427,3 +453,4 @@ export type GetSparkSendLightningQuoteParams = unknown; // settles in step 15 (s export type CreateSparkSendQuoteParams = unknown; // settles in step 15 (spark send quote) export type GetTransferQuoteParams = unknown; // settles in step 16 (transfer) export type InitiateTransferParams = unknown; // settles in step 16 (transfer) +export type SdkTransferQuote = unknown; // settles in step 16 (transfer): public projection of today's TransferQuote — must not embed raw accounts From 7c5123460559f04037889d75b93752561521df87 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 8 Jul 2026 08:54:47 -0700 Subject: [PATCH 8/8] refactor(wallet-sdk): contract types keep the domain names (review round) Per review: the projections reuse the bare domain names via aliased imports (import { X as DomainX } -> export type X = Omit); index.ts becomes `export * from './sdk'` - its explicit domain-type exports shadow the same-named projections (an explicit export beats `export *`), so the web-facing root surface is unchanged until each slice deletes its names there when flipping imports. A type-level probe plus the workspace typecheck (incl. web-wallet) confirm the shadowing direction. Also per review: - cashu send entities drop proof material from the public surface (CashuSendQuote.proofs; CashuSendSwap.inputProofs/proofsToSend) - init() is required before any Spark operation (no WASM lazy-load; Spark calls without a completed init() throw) - sdk.ts JSDoc and the contract doc updated - WebAssemblyUnavailableError lives in lib/spark/errors.ts: spark-local and import-clean, so wasm.ts's module-level @agicash/breez-sdk-spark import does not ride the root export into every consumer - editorial JSDoc trimmed to contract gist throughout sdk.ts Co-Authored-By: Claude Fable 5 --- ...2026-07-02-wallet-sdk-contract-proposal.md | 19 +- packages/wallet-sdk/index.ts | 67 +--- packages/wallet-sdk/lib/error.ts | 12 - packages/wallet-sdk/lib/spark/errors.ts | 14 + packages/wallet-sdk/lib/spark/wasm.ts | 4 +- packages/wallet-sdk/sdk.ts | 311 ++++++++---------- 6 files changed, 161 insertions(+), 266 deletions(-) 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 index e8ea8a725..9e020ceff 100644 --- a/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md +++ b/docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md @@ -67,7 +67,7 @@ type Logger = { class Sdk { static create(config: SdkConfig): Sdk; // sync; no I/O - init(): Promise; // optional async second phase (see notes) + init(): Promise; // async second phase; required before Spark use (see notes) dispose(): Promise; // tears down realtime + background } ``` @@ -86,20 +86,21 @@ Notes: 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 optional async second phase.** +- **`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 probe — and rejects with the + 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. If the host never calls `init()`, first use - lazy-initializes exactly as today. This preserves master's split verbatim — - **what is lazy stays lazy, what is eager stays eager**: eager = session - restore + WASM probe (`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). + 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`. diff --git a/packages/wallet-sdk/index.ts b/packages/wallet-sdk/index.ts index ef119deb9..f24a1472a 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -3,74 +3,19 @@ // 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. -export type { - AcceptTermsParams, - AccountsApi, - AddCashuAccountParams, - AuthApi, - AuthSession, - AuthStorage, - AuthUser, - BackgroundApi, - BackgroundState, - ClaimCashuTokenParams, - ClaimCashuTokenResult, - ContactsApi, - CreateCashuReceiveQuoteParams, - CreateCashuSendQuoteParams, - CreateCashuSwapParams, - CreateCashuSwapResult, - CreateContactParams, - CreateSparkReceiveQuoteParams, - CreateSparkSendQuoteParams, - Cursor, - FeatureFlagsApi, - GetCashuReceiveLightningQuoteParams, - GetCashuSendLightningQuoteParams, - GetCashuSwapQuoteParams, - GetReceiveCashuTokenQuoteParams, - GetSparkReceiveLightningQuoteParams, - GetSparkSendLightningQuoteParams, - GetTransferQuoteParams, - InitiateTransferParams, - Logger, - ReceiveApi, - ReceiveCashuTokenQuote, - Sdk, - SdkAccount, - SdkCashuAccount, - SdkCashuReceiveQuote, - SdkCashuReceiveSwap, - SdkCashuSendQuote, - SdkCashuSendSwap, - SdkConfig, - SdkConstructor, - SdkContact, - SdkSparkAccount, - SdkSparkReceiveQuote, - SdkSparkSendQuote, - SdkTransaction, - SdkTransferQuote, - SendApi, - ServerSdk, - ServerSdkConfig, - ServerSdkConstructor, - SetDefaultAccountParams, - SetDefaultCurrencyParams, - TransactionsApi, - TransferApi, - UserApi, - WalletEventMap, - WalletEvents, -} from './sdk'; +// 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, - WebAssemblyUnavailableError, } 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 64e5e7839..628d366b4 100644 --- a/packages/wallet-sdk/lib/error.ts +++ b/packages/wallet-sdk/lib/error.ts @@ -30,15 +30,3 @@ export class ConcurrencyError extends SdkError { this.name = 'ConcurrencyError'; } } - -/** - * 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'; - } -} 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 cb036b543..baaa2f558 100644 --- a/packages/wallet-sdk/lib/spark/wasm.ts +++ b/packages/wallet-sdk/lib/spark/wasm.ts @@ -1,7 +1,5 @@ import initBreezWasm from '@agicash/breez-sdk-spark'; -import { WebAssemblyUnavailableError } from '../error'; - -export { WebAssemblyUnavailableError }; +import { WebAssemblyUnavailableError } from './errors'; let wasmInitPromise: ReturnType | null = null; diff --git a/packages/wallet-sdk/sdk.ts b/packages/wallet-sdk/sdk.ts index 02f314a17..faf0742be 100644 --- a/packages/wallet-sdk/sdk.ts +++ b/packages/wallet-sdk/sdk.ts @@ -1,12 +1,5 @@ -/** - * @agicash/wallet-sdk public contract — the types the migration slices - * implement against. Prose contract (semantics, invariants, rationale): - * docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md - * - * Shapes marked "settles in step N" are placeholders a slice PR pins to the - * public projection of today's service types (internals like `userId` and raw - * wallet handles never appear on the public surface). - */ +// Public contract of @agicash/wallet-sdk. Prose contract: +// docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md import type { LNURLError, LNURLPayParams, @@ -15,21 +8,25 @@ import type { } from '@agicash/lnurl'; import type { Money } from '@agicash/money'; import type { SparkNetwork } from './db/json-models/spark-account-details-db-data'; -import type { CashuAccount, SparkAccount } from './domain/accounts/account'; -import type { Contact } from './domain/contacts/contact'; -import type { CashuReceiveQuote } from './domain/receive/cashu-receive-quote'; +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 } from './domain/receive/cashu-receive-swap'; -import type { SparkReceiveQuote } from './domain/receive/spark-receive-quote'; +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 } from './domain/send/cashu-send-quote'; +import type { CashuSendQuote as DomainCashuSendQuote } from './domain/send/cashu-send-quote'; import type { CashuLightningQuote } from './domain/send/cashu-send-quote-service'; -import type { CashuSendSwap } from './domain/send/cashu-send-swap'; +import type { CashuSendSwap as DomainCashuSendSwap } from './domain/send/cashu-send-swap'; import type { CashuSwapQuote } from './domain/send/cashu-send-swap-service'; -import type { SparkSendQuote } from './domain/send/spark-send-quote'; +import type { SparkSendQuote as DomainSparkSendQuote } from './domain/send/spark-send-quote'; import type { SparkLightningQuote } from './domain/send/spark-send-quote-service'; -import type { Transaction } from './domain/transactions/transaction'; +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'; @@ -37,50 +34,37 @@ import type { DestinationDetails } from './lib/send-destination'; export type { Cursor }; -// --- Public entity projections --------------------------------------------- -// The contract returns and emits public projections of the domain entities: -// `userId`/`ownerId` are implicit from the session, and raw wallet handles / -// proof material never appear on the public surface. Each projection takes -// the bare domain name once its slice flips the web imports off /temporary. +// Public projections of the domain entities: `userId`/`ownerId` are implicit +// from the session; raw wallet handles and proof material stay internal. -/** - * The contract account: carries `balance` on every rail, never a raw wallet - * handle or proof material. The exact cashu field set (e.g. whether - * `keysetCounters` stays internal) settles in the accounts slice (step 6). - */ -export type SdkCashuAccount = Omit< - CashuAccount, +/** 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 SdkSparkAccount = Omit; -export type SdkAccount = SdkCashuAccount | SdkSparkAccount; +export type SparkAccount = Omit; +export type Account = CashuAccount | SparkAccount; -export type SdkContact = Omit; -export type SdkTransaction = Omit; -export type SdkCashuReceiveQuote = Omit; -export type SdkSparkReceiveQuote = Omit; -export type SdkCashuReceiveSwap = Omit; -export type SdkCashuSendQuote = Omit; -export type SdkCashuSendSwap = Omit; -export type SdkSparkSendQuote = Omit; +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. 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. - */ +/** Host-backed session persistence. */ export 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. - */ +/** 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; @@ -90,28 +74,22 @@ export type Logger = { export type SdkConfig = { db: { - /** Supabase project URL — the host resolves the final URL before `create()`. */ url: string; anonKey: string; }; auth: { - /** Open Secret backend URL. */ apiUrl: string; - /** Open Secret client id. */ clientId: string; storage: AuthStorage; }; spark: { breezApiKey: string; - /** - * Default used when the SDK creates an account; the per-account value - * persisted in the DB is authoritative for every account after that. - */ + /** Default for account creation; the persisted per-account value is authoritative. */ network: SparkNetwork; /** Node hosts; browser default applies. */ storageDir?: string; }; - /** lud16 domain for contacts/display. */ + /** lud16 domain. */ lightningAddressDomain: string; logger?: Logger; }; @@ -129,11 +107,12 @@ export type Sdk = { readonly events: WalletEvents; readonly background: BackgroundApi; /** - * Optional async second phase: front-loads the inits that can fail — - * session restore and the Breez WASM probe. Resolves when no session exists - * (absence of a session is a state, not a failure); rejects only on actual - * failures, e.g. `WebAssemblyUnavailableError`. Never called → first use - * lazy-initializes. + * 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; /** @@ -144,12 +123,12 @@ export type Sdk = { dispose(): Promise; }; -/** The implementing class satisfies this statically: `create` is sync, no I/O. */ +/** `create` is sync; no I/O. */ export type SdkConstructor = { create(config: SdkConfig): Sdk; }; -export type AuthUser = unknown; // settles in step 5: binds to the @agicash/opensecret release's user shape +export type AuthUser = unknown; // settles in step 5 (auth & user) export type AuthSession = | { isLoggedIn: true; user: AuthUser } @@ -162,19 +141,18 @@ export type AuthApi = { signUpGuest(): Promise; signIn(email: string, password: string): Promise; /** - * Stops background, tears down realtime, clears the stored session. The - * instance stays alive in anonymous state — `dispose()` is instance - * teardown, not logout. + * 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; - /** Host redirects to `authUrl`. */ + /** Returns the URL to redirect to. */ initiateGoogleAuth(): Promise<{ authUrl: string }>; /** OAuth callback leg. */ completeGoogleAuth(params: { code: string; state: string }): Promise; - /** Sync snapshot for route guards; no I/O. */ + /** Sync snapshot; no I/O. */ getSession(): AuthSession; }; @@ -187,40 +165,38 @@ export type UserApi = { }; export type AccountsApi = { - get(id: string): Promise; + get(id: string): Promise; /** Active accounts of the current user. */ - list(): Promise; + list(): Promise; cashu: { - add(params: AddCashuAccountParams): Promise; + add(params: AddCashuAccountParams): Promise; }; }; export type ContactsApi = { - get(id: string): Promise; - list(): Promise; - create(params: CreateContactParams): Promise; + get(id: string): Promise; + list(): Promise; + create(params: CreateContactParams): Promise; delete(id: string): Promise; - findContactCandidates(query: string): Promise; + findContactCandidates(query: string): Promise; }; export type TransactionsApi = { - get(id: string): Promise; + get(id: string): Promise; list(params: { /** Opaque pagination token from a previous page's `nextCursor`. */ cursor?: Cursor; pageSize?: number; accountId?: string; - }): Promise<{ transactions: SdkTransaction[]; nextCursor: Cursor | null }>; + }): Promise<{ transactions: Transaction[]; nextCursor: Cursor | null }>; countPendingAck(): Promise; acknowledge(transactionId: string): Promise; }; /** - * `get*` methods are stateless previews — they compute and return without - * persisting. `create*` methods persist and enter the entity into the - * background lifecycle. Completion is not the host's job: execution is - * background-only, observed via `events` (subscribe first, then read the - * baseline — see "Observing an initiated payment" in the contract doc). + * `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: { @@ -229,8 +205,8 @@ export type ReceiveApi = { ): Promise; createQuote( params: CreateCashuReceiveQuoteParams, - ): Promise; - getQuote(id: string): Promise; + ): Promise; + getQuote(id: string): Promise; }; spark: { getLightningQuote( @@ -238,8 +214,8 @@ export type ReceiveApi = { ): Promise; createQuote( params: CreateSparkReceiveQuoteParams, - ): Promise; - getQuote(id: string): Promise; + ): Promise; + getQuote(id: string): Promise; }; cashuToken: { getQuote( @@ -274,34 +250,28 @@ export type SendApi = { export type TransferApi = { /** Stateless preview. */ - getQuote(params: GetTransferQuoteParams): Promise; + getQuote(params: GetTransferQuoteParams): Promise; // public projection of TransferQuote settles in step 16 initiate(params: InitiateTransferParams): Promise<{ transactionId: string }>; }; -/** - * Feature flags are the documented process-local cache exception (contract - * principles, rule 1) — hence the sync `get` + `subscribe` pair instead of - * promises. - */ +/** Flags are a process-local cached read — the one no-cache exception. */ export type FeatureFlagsApi = { get(flag: FeatureFlag): boolean; - /** Cache-change signal (web: `useSyncExternalStore`); returns unsubscribe. */ + /** Cache-change signal; returns unsubscribe. */ subscribe(listener: () => void): () => void; }; export type BackgroundState = 'stopped' | 'follower' | 'leader' | 'error'; /** - * Nothing moves money unless a background loop is running somewhere: an MCP / - * request-response host MUST call `start()` in-process, or its own sends sit - * UNPAID forever. The executing instance may differ from the initiating one - * (the leader lock is per-user across devices). + * 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 — background work - * is per-user by construction. + * @throws {SdkError} when no authenticated session exists. */ start(): void; /** @@ -314,64 +284,52 @@ export type BackgroundApi = { }; /** - * Payloads are decrypted domain objects. Naming invariant: `.`, - * verbs per entity (an entity emits the verbs its data model supports); - * terminal transitions arrive as `updated` with the new state on the payload. - * Event names are stable contract; adding events is non-breaking, renaming is - * breaking. + * 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) — - * the host renders its "session expired" path from this one subscription. - */ + /** The session died without a `signOut()` call (expiry / failed refresh). */ 'auth.session-expired': Record; 'user.updated': { user: User }; - 'account.created': { account: SdkAccount }; + 'account.created': { account: Account }; /** A persisted row changed; the payload carries a `version` consumers gate on. */ - 'account.updated': { account: SdkAccount }; - /** - * Versionless balance signal, both rails: cashu alongside the versioned - * `account.updated` for the same change, spark from the SDK's internal - * Breez listeners (spark's only balance path — spark balances are rail-side - * state, not rows). - */ + '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: SdkContact }; - 'contact.deleted': { contact: SdkContact }; - 'transaction.created': { transaction: SdkTransaction }; - 'transaction.updated': { transaction: SdkTransaction }; - 'cashu-receive-quote.created': { quote: SdkCashuReceiveQuote }; - 'cashu-receive-quote.updated': { quote: SdkCashuReceiveQuote }; - 'cashu-receive-swap.created': { swap: SdkCashuReceiveSwap }; - 'cashu-receive-swap.updated': { swap: SdkCashuReceiveSwap }; - 'spark-receive-quote.created': { quote: SdkSparkReceiveQuote }; - 'spark-receive-quote.updated': { quote: SdkSparkReceiveQuote }; - 'cashu-send-quote.created': { quote: SdkCashuSendQuote }; - 'cashu-send-quote.updated': { quote: SdkCashuSendQuote }; - 'cashu-send-swap.created': { swap: SdkCashuSendSwap }; - 'cashu-send-swap.updated': { swap: SdkCashuSendSwap }; - 'spark-send-quote.created': { quote: SdkSparkSendQuote }; - 'spark-send-quote.updated': { quote: SdkSparkSendQuote }; + '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 — as the host's invalidate-all signal. `error` is terminal: - * the channel is dead after retries exhaust, distinct from a long - * `reconnecting`. + * 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 don't change state, so they never fire it. + * `'error'`. Per-task errors never change state, so they never fire it. */ 'background.state-changed': { state: BackgroundState; error?: SdkError }; }; /** - * `on()` never initiates the realtime connection — it only registers a - * handler, and is callable with no session. The per-user channel is - * established by the session coming into existence (login, or `init()`'s - * session restore). + * `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( @@ -381,10 +339,8 @@ export type WalletEvents = { }; /** - * Lightning-address routes run server-side with different trust: env-provided - * secrets, no user session, per-request scope. `db` uses the service-role key - * (cross-user reads with no user session, where anon + RLS returns nothing). - * No `auth` port, no `events`, no `background`. + * 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 }; @@ -408,11 +364,7 @@ export type ServerSdk = { userId: string; amount: Money<'BTC'>; baseUrl: string; - /** - * Selects the agicash→agicash pay path; per-request by design — as - * instance state it would race across concurrent requests on the - * per-process singleton. - */ + /** Per-request by design — instance state would race on the per-process singleton. */ bypassAmountValidation?: boolean; }): Promise; handleLnurlpVerify(params: { @@ -421,36 +373,33 @@ export type ServerSdk = { }; }; -/** Singleton per process; the `Request` of the route becomes per-method `baseUrl`. */ +/** Singleton per process. */ export type ServerSdkConstructor = { create(config: ServerSdkConfig): ServerSdk; }; -// --- Slice-settled shapes ------------------------------------------------- -// Each alias is pinned by its slice PR to the public projection of today's -// service types. They exist now so the namespace structure, method names, and -// return types are compiler-checked contract from step 4 on. +// Settles in step N — pinned by that slice PR to the public projection of +// today's service types. -export type AcceptTermsParams = unknown; // settles in step 5 (auth & user) -export type SetDefaultAccountParams = unknown; // settles in step 5 (auth & user) -export type SetDefaultCurrencyParams = unknown; // settles in step 5 (auth & user) -export type AddCashuAccountParams = unknown; // settles in step 6 (accounts) -export type CreateContactParams = unknown; // settles in step 7 (contacts) -export type GetCashuReceiveLightningQuoteParams = unknown; // settles in step 9 (cashu receive quote) -export type CreateCashuReceiveQuoteParams = unknown; // settles in step 9 (cashu receive quote) -export type GetSparkReceiveLightningQuoteParams = unknown; // settles in step 11 (spark receive quote) -export type CreateSparkReceiveQuoteParams = unknown; // settles in step 11 (spark receive quote) -export type GetReceiveCashuTokenQuoteParams = unknown; // settles in step 12 (receive cashu token) -export type ReceiveCashuTokenQuote = unknown; // settles in step 12 (today: CrossAccountReceiveQuotesResult) -export type ClaimCashuTokenParams = unknown; // settles in step 12 (receive cashu token) -export type ClaimCashuTokenResult = unknown; // settles in step 12 (receive cashu token) -export type GetCashuSendLightningQuoteParams = unknown; // settles in step 13 (cashu send quote) -export type CreateCashuSendQuoteParams = unknown; // settles in step 13 (cashu send quote) -export type GetCashuSwapQuoteParams = unknown; // settles in step 14 (cashu send swap) -export type CreateCashuSwapParams = unknown; // settles in step 14 (cashu send swap) -export type CreateCashuSwapResult = unknown; // settles in step 14 (cashu send swap) -export type GetSparkSendLightningQuoteParams = unknown; // settles in step 15 (spark send quote) -export type CreateSparkSendQuoteParams = unknown; // settles in step 15 (spark send quote) -export type GetTransferQuoteParams = unknown; // settles in step 16 (transfer) -export type InitiateTransferParams = unknown; // settles in step 16 (transfer) -export type SdkTransferQuote = unknown; // settles in step 16 (transfer): public projection of today's TransferQuote — must not embed raw accounts +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)