Skip to content

docs(wallet-sdk): step-4 public contract proposal#1164

Merged
pmilic021 merged 8 commits into
masterfrom
sdk/contract-proposal
Jul 9, 2026
Merged

docs(wallet-sdk): step-4 public contract proposal#1164
pmilic021 merged 8 commits into
masterfrom
sdk/contract-proposal

Conversation

@orveth

@orveth orveth commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Step 4 of the no-cache production design: defines the Sdk class + create(config), the per-domain namespaces, the separate ServerSdk, and the shape of the background contract. One doc, no code — the slice PRs (steps 5–16) implement it namespace by namespace.

Grounded in the extracted domain layer (#1162): the namespaces cover everything the web pulls from /temporary today; the doc includes the migration mapping table.

Four decision points called out at the end of the doc:

  1. db port = { url, anonKey } — the SDK builds its own Supabase client, since auth lives inside the SDK and the access-token getter would otherwise be circular
  2. flow-first namespaces (sdk.receive.cashu) over rail-first (sdk.cashu.receive)
  3. userId implicit from the authenticated session
  4. complete/expire/fail verbs are background-only (behind sdk.background, observed via events) — not on the public surface

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agicash Ready Ready Preview, Comment Jul 9, 2026 7:55am

Request Review

@supabase

supabase Bot commented Jul 3, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project hrebgkfhjpkbxpztqqke because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md Outdated
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md Outdated
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md Outdated

@ditto-agent ditto-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the parent spec, the extracted domain layer (#1162), and every current /temporary consumer. The shape is right: no-cache, events-out/promises-in, flow-first with per-rail nesting, and initiate-only are the correct calls, and the doc is short enough to actually govern the slice PRs.

On the four decision points: 1 ✅ for the client SDK (the ServerSdk variant is wrong — service-role, see inline); 2 ✅ flow-first; 3 ✅ implicit userId; 4 ✅ — but it creates an obligation the doc doesn't discharge (see the comment on L178).

The gaps cluster in four areas (specifics inline):

  1. The money-movement dependency is unstated. Initiate-only means nothing pays unless a background loop runs somewhere. Already true today (leader-gated TaskProcessor), but the contract never says it — and for the MCP host it's a hard requirement (background.start() in-process) plus a cross-device leader-contention question.
  2. No failure channel. start(): void, no error/state events, no logger port. A processor dying mid-melt is invisible to both hosts; SDK console.* corrupts an MCP stdio transport.
  3. Spark leaks through the DB-shaped model. Spark balances aren't DB rows, so the step-18 change feed can never emit them (today: raw Breez-handle listeners); network is per-account, not global; the whole lib/spark export surface is missing from the migration table; WASM loading/warmup has no port.
  4. ServerSdk: wrong key, missing field, dropped input. serviceRoleKey not anonKey; spark.storageDir; bypassAmountValidation.

Nothing here challenges the architecture — every item is a doc-level addition or a config-field fix, which is exactly what a step-4 gate is for.

Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md Outdated
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md Outdated
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md Outdated
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md Outdated
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md Outdated
| `*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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing row: the lib/spark surface reaches the web via export * from './lib/spark' in temporary.ts and none of it is classified: sparkDebugLog, clearSparkWallets, createSparkWalletStub, getSparkMnemonic, getSparkIdentityPublicKeyFromMnemonic, ensureBreezWasm, WebAssemblyUnavailableError (consumers: entry.client.tsx, root.tsx, _protected.tsx, spark-query-options.ts, use-track-spark-account-balances.ts). Three need named homes now:

  • WebAssemblyUnavailableErrorroot.tsx does instanceof on it for the WASM-fallback UI; it must join the root error exports and extend SdkError, or the one-instanceof-check claim above is false.
  • getSparkIdentityPublicKeyFromMnemonicspark-query-options.ts derives the identity pubkey host-side; needs a public read or an internalization plan.
  • ensureBreezWasm — see the warmup comment on L71.

The rest just need table rows (sparkDebugLog → logger port, clearSparkWallets → auth-slice internal, createSparkWalletStub → internal).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, we can have WebAssemblyUnavailableError extend SdkError.

For getSparkIdentityPublicKeyFromMnemonic I am not sure I understand. Same for the rest of this comment. Can you clarify?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified with its actual consumer. getSparkIdentityPublicKeyFromMnemonic exists only to feed signup bootstrap: spark-query-options.ts wraps it in sparkIdentityPublicKeyQueryOptions, whose sole consumer is the _protected loader, which passes the derived pubkey into writeUserRepository.upsert(...) to seed the default spark account (_protected.tsx:107→139). The auth/user slice internalizes that bootstrap wholesale (the SDK creates the user + default accounts itself), so no public method is needed — I overstated with "needs a public read"; it just becomes internal. Same for client-side getSparkMnemonic (bootstrap + repo constructors, all internalized). The server route's mnemonic is unrelated: that's LNURL_SERVER_SPARK_MNEMONIC from env into ServerSdkConfig.

"The rest" = the remaining lib/spark exports reach the web via export * from './lib/spark' in temporary.ts, and the migration table has no row saying what happens to them at step 19. Their fates: sparkDebugLog → logger port / internal; clearSparkWallets → internal (logout path, auth slice — user/auth.ts:231); createSparkWalletStub → internal behind the token-claim namespace (receive-cashu-token-hooks.ts:333); ensureBreezWasmwarmup() per the lifecycle thread. No new API — just table rows so step 19 doesn't orphan them.

@jbojcic1 jbojcic1 Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you are saying those don't need to exported via temporary route at all?

What exactly you think should be the action here? I don't know which migration table you are referring to

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No — they stay exported via /temporary for the whole migration (web imports them today). The question is their end state when step 19 deletes /temporary.

The table: the "Migration mapping" section at the bottom of this doc (| /temporary consumer group today | Contract home |). It assigns every consumer group a home; lib/spark is the one group with no row (it reaches the web through export * from './lib/spark' in temporary.ts, so it's easy to miss).

Action = docs-only, in this PR — add rows so step 19 doesn't orphan them:

/temporary consumer group today Contract home
lib/spark key/wallet plumbing (getSparkMnemonic, getSparkIdentityPublicKeyFromMnemonic, clearSparkWallets, createSparkWalletStub) internal (absorbed by auth/accounts/token-claim slices)
sparkDebugLog internal (logger port)
ensureBreezWasm replaced by the boot/init method (lifecycle thread)
WebAssemblyUnavailableError root error export, extends SdkError

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will see what happens to them when the moment comes, if they are still needed for a direct access for some reason, we will move it to the main barrel export. Doesn't seem like some consequential decision right now.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like only WebAssemblyUnavailableError will need to be moved to main export but as @pmilic021 said we can see when the time comes

Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md Outdated
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md Outdated
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md
Comment thread docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md

type BackgroundApi = {
start(): void; // leader election + change feed + processors
stop(): Promise<void>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is stop a promise? Is it going to wait for all the background tasks to finish before returning, basically being in a 'stopping' state for a while?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So looking at the dispose comment my understanding is that stop() is a Promise because callers (logout, process exit, sdk's own dispose()) must wait for cleanup before proceeding. stopping would mean: stop claiming new work immediately; await only in-flight iterations to their next "checkpoint" (bounded by a timeout, just in case); release the leader lock; abandon remaining queue.

@ditto-agent ditto-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh pass on the folded-in version (103e065), re-checked whole-contract against the parent spec and the code. Verdict: this is now a good contract — the four gap clusters from the first review are all closed (execution model + failure channel + spark/version semantics + ServerSdk config), the folded text matches what the threads agreed, and every claim I spot-checked against master holds (lease numbers, version-gating, runtime floor, Breez exports). Simplicity held up too: the doc gained 185 lines but no new concepts — everything added is semantics the implementation already had.

Five residuals, ranked (inline):

  1. AuthApi is still a bare name — and it's step 5, the first slice built against this contract; the map also has no session-lifecycle event, though master has real expiry machinery the host must keep reacting to. This is the one item I'd hold merge for, by the "define all slices before merging" bar you set yourselves.
  2. Change-feed ownership is stated twice, differently (events.on() at L97 vs start()'s comment at L215) — and the same failure (feed death) is routed to two different error paths. One-sentence fix, but it's the kind of ambiguity the step-18 implementer will trip on.
  3. background.state-changed fires only on errors — loses leader/follower observability and contradicts its own name. Fire on every transition; strictly simpler to document.
  4. The agreed lib/spark migration-table rows didn't land in the fold-in commit — re-supplied inline.
  5. init() vs logged-out needs one sentence (no session = resolve, not reject).

All five are doc edits. After 1–4 (5 is take-it-or-leave-it), I'd call this contract complete against the parent spec and ready to govern the slice PRs.


```ts
class Sdk {
auth: AuthApi; // step 5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every namespace now has a method list except two — and one of them is auth, step 5, the first slice that will be built against this contract. By the bar set in the other thread ("define contract for all slices completely before merging"), this is the remaining blocker.

What it needs, from what the login UI + guards actually call today:

  • Methods: signUp (email / guest), logIn, logOut, OAuth start/complete, verifyEmail, and a cheap session read for guards (isLoggedIn() / getSession()).
  • A session-lifecycle event. The map has no auth event, but master has real expiry machinery the host must keep reacting to once auth moves inside the SDK: auth.ts schedules refresh-or-expire (treats the session expired 5s early; on refresh failure → logout, auth.ts:365–401) and wallet.tsx:43 wires onLogout to a toast + redirect. That trigger needs a contract home — auth.session-expired (or auth.changed) on the event map, or the web can't render its "session expired" path anymore.

featureFlags is the other bare name — a two-liner (get/isEnabled or today's fn set) closes it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both lists added @d35bf747. AuthApi is master's useAuthActions verb set verbatim — signIn/signOut/signUpGuest/convertGuestToFullAccount/verifyEmail/requestNewVerificationCode/initiateGoogleAuth + completeGoogleAuth (the OAuth callback leg) — plus getSession() as the sync no-I/O guard read. The session-lifecycle event landed as auth.session-expired: fires on expiry / failed refresh, never on host-initiated signOut() (the host knows its own logout). featureFlags = get + subscribe, the documented process-local cache exception. redirectTo-style routing options stay host-side.

};

type BackgroundApi = {
start(): void; // leader election + change feed + processors

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment contradicts the init() note and misroutes one failure path.

L97 says the realtime subscription is driven by events.on(); this line says start() owns "leader election + change feed + processors". On master they have different gating: the feed runs on every client (useTrackWalletChanges(), wallet.tsx:54 — followers need cache freshness too), only the processor is leader-gated (isLead && <TaskProcessor/>, wallet.tsx:62). If start() owned the feed, an events-only host gets silence.

The mixup leaks into error routing: the Execution model lists "change feed dead after retries" as a background systemic failure (→ background.state-changed), while the Events section routes channel death to connection.changed: 'error' — same failure, two contract paths.

Fix is three edits: (1) drop "change feed" from this comment (start() = leader election + processors); (2) in Execution model, make the systemic example lock-centric ("leader lock unrenewable") and let feed death belong to connection.changed: 'error' alone; (3) one sentence on when the channel actually subscribes: first events.on() once an authenticated session exists (the topic is per-user).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1) and (2) agreed, though (3) is not correct, as discussed in the previous comment thread: https://github.com/MakePrisms/agicash/pull/1164/changes#r3539525597

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1) and (2) landed @d35bf747 — start() = leader election + processors; the systemic example is lock-centric and change-feed death routes to connection.changed: 'error' alone. For (3): per the resolution in the anon-context thread, the subscription-timing sentence says the channel rides the session (login / init() restore), not events.on().

(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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As folded, this event fires only on systemic failures — which contradicts its own name and quietly re-loses the observability my original comment on this namespace flagged: normal stopped → follower → leader transitions emit nothing, so a host that wants leader/follower UI (or an MCP host asking "did I win the election?") is back to polling state. The thread narrowed it this way while we were focused on error handling — my miss too.

Simplest fix is also the simplest contract: emit on every state transition, payload { state, error? } with error set when entering 'error'. That's one rule with no exception to document, the name becomes true, and the per-task guarantee below stays as-is (task errors don't change state, so they still never fire it).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm ok with emitting on every state transition for the completeness sake and because state-changed does not imply an error happened to be only published on errors. So do on all changes or rename, either works.

Regarding the argument "did I win the election?", it's not relevant since the app does nothing with that information.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with emit-on-every-transition @d35bf747 — kept the name; one rule, payload { state, error? } with error set on transitions into 'error'. The per-task guarantee stands. Election observability dropped from the rationale — it is completeness, not a feature.

| feature-flag fns | `sdk.featureFlags` |
| exchange-rate | root export (stateless) |
| account/user predicate helpers (`getAccountBalance`, `shouldVerifyEmail`…) | stay root exports (pure fns over domain types) |
| `TaskProcessingLockRepository`, processors | internal, behind `sdk.background` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one agreed thread item that didn't land in the fold-in: the lib/spark rows (the group reaches web via export * from './lib/spark' in temporary.ts, so the table misses it). Rows to add:

/temporary consumer group today Contract home
lib/spark key/wallet plumbing (getSparkMnemonic, getSparkIdentityPublicKeyFromMnemonic, clearSparkWallets, createSparkWalletStub) internal (absorbed by auth/accounts/token-claim slices)
sparkDebugLog internal (logger port)
ensureBreezWasm replaced by init()
WebAssemblyUnavailableError root error export, extends SdkError (already listed above)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rows added @d35bf747 — flagging that this re-opens what petar deferred in round 2: all four resolve to already-decided homes (internal / logger port / init() / existing root export), no new contract decisions. Easy to drop if the defer stands.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One sentence to add: no session is a resolve, not a reject. "Session restore" is listed among the inits that can fail, but being logged out is a normal state — the login pages construct the SDK too. Without the sentence, a host wrapping init() in try/catch treats every logged-out boot as a boot failure. Suggested: "init() resolves when no session exists (absence of a session is a state, not a failure); it rejects only on actual failures — WASM unavailable, storage unreadable, refresh errors."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. Further few things on anon to logged-in transition:

  • attempting to start the background task processing while logged out should throw an exception
  • events.on() call should not initiate the realtime connection, that shuold be done by the login flow. I.e. events.on() should be callable even in anon context, as there are events that are not strictly related to realtime connectoin. The actual realtime connection will cause the connection.changed event which will hint the web to bust all it's cache, similar to how on master that already works

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All folded @d35bf747: the no-session sentence in the init() note, plus the three anon-context rules — background.start() throws with no session (Execution model), events.on() only registers a handler / never initiates the connection and is callable anonymous (Events), and the channel is established by the session coming into existence (login or restore), establishment emitting connection.changed: 'connected' as the invalidate-all hint — master's onConnected role.

@orveth

orveth commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Step-4 code part is on the branch (b541b68 + 68a6bf9): packages/wallet-sdk/sdk.ts defines this doc as compilable types — config ports, Sdk/SdkConstructor, every namespace API, the typed WalletEventMap, ServerSdk. Return types wire the existing domain types; params the doc defers are named placeholder aliases (unknown, JSDoc'd with the slice that pins them). lib/error.ts adds the abstract SdkError base and re-parents the four existing error classes; WebAssemblyUnavailableError lives there now so the root export does not pull @agicash/breez-sdk-spark into every consumer (wasm.ts re-exports it, /temporary surface unchanged). The root exports the contract + errors.

We ran our automated cross-model review on the diff (additive to ditto's). It caught a real one: the domain entities leak internals through the contract surface — Account carries raw wallet handles (BreezSdk/ExtendedCashuWallet) and proof material; the persisted entities carry userId/ownerId. Fixed in 68a6bf9 with public projections: SdkAccount = SdkCashuAccount | SdkSparkAccount (balance on every rail, no wallet/proofs), Omit<…, 'userId'> for transaction/quotes/swaps, Omit<Contact, 'ownerId'>. The Sdk* prefix is transitional: the bare names are occupied at the root today by the extraction-era domain types the web already imports; each projection takes the bare name when its slice flips those imports. SdkTransferQuote stays a step-16 placeholder — today's TransferQuote embeds raw accounts.

Known-open for review: the get* preview returns (CashuLightningQuote etc.) still reference today's shapes (mint melt-quote internals embedded); those settle in their slices. Workspace typecheck green incl. web-wallet.

* 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.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment is redundant

Comment thread packages/wallet-sdk/sdk.ts Outdated
Comment thread packages/wallet-sdk/sdk.ts Outdated
Comment thread packages/wallet-sdk/sdk.ts Outdated
Comment thread packages/wallet-sdk/sdk.ts
Comment thread packages/wallet-sdk/lib/error.ts Outdated
Comment thread packages/wallet-sdk/index.ts Outdated
Comment thread packages/wallet-sdk/sdk.ts Outdated
@pmilic021 pmilic021 marked this pull request as ready for review July 8, 2026 18:39
orveth and others added 8 commits July 9, 2026 09:52
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 <noreply@anthropic.com>
accounts.add discriminated input, entity-named events with created
variants, SdkError base class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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<Contact, 'ownerId'>;
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 <noreply@anthropic.com>
…und)

Per review: the projections reuse the bare domain names via aliased
imports (import { X as DomainX } -> export type X = Omit<DomainX, ...>);
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 <noreply@anthropic.com>
@pmilic021 pmilic021 merged commit 31eca25 into master Jul 9, 2026
5 checks passed
pmilic021 added a commit that referenced this pull request Jul 11, 2026
…nd collapsing variants

The receive domain entities are intersections over variant unions, and the
contract projected them with a bare `Omit`. TypeScript collapses each union
to its shared keys under `keyof`, so the projections simultaneously leaked
the swap's top-level `tokenProofs` (spendable Cashu proof material, also via
the receive events' payload types) and silently dropped every variant-only
field (`tokenReceiveData`, `failureReason`, keyset fields) along with
discriminant narrowing.

The domain schemas now name their variant unions once (feeding both the
schema and an exported variant type), and the projections omit base keys
only, re-apply the variant unions, and strip proof material at both levels —
top-level `tokenProofs` and the melt data's nested `tokenProofs`. The
implementing slices (steps 9/11/12) must strip the same fields at runtime.

Inherited from #1164 (the projection sweep missed the receive entities);
finding 2 of the cross-model review on PR #1166.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pmilic021 added a commit that referenced this pull request Jul 11, 2026
Drops the rejection sampling added in 7d3bc1c (review LOW finding),
returning the generator to master's algorithm verbatim. The modulo skew is
~1 in 5.7e7 per character over a 32-character password — cryptographically
negligible — and master parity is preferred for the slice. The redraw test
goes with it; the length sanity test stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

revert(wallet-sdk): unfence the expiry handler, park its races as a known issue

Reverts the generation fencing of handleSessionExpiry from 1e6a627 (review
finding 1). The per-await fence pattern is too subtle to maintain — the
plan is to fix the race class structurally by serializing all session
transitions through a single command lane (single-writer) instead.

The handler returns to the shape inherited from master's expiry hook, with
a KNOWN ISSUE note: a sign-out landing while the guest re-sign-in is in
flight is silently undone, and an in-flight handler survives teardown()
(HMR). The two race regression tests stay as it.todo — they are the spec
the command-lane refactor must satisfy.

Kept from the reverted commit: the signInGuestAccount extraction (the
guest-persist undo lives there) and the test fake's real-SDK sign-out
fidelity (clears token keys), which later tests rely on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

fix(wallet-sdk): tighten three lib-level invariants from the review

- generateRandomPassword now rejection-samples instead of a bare modulo, so
  every charset character is exactly equally likely — this string is the
  sole credential of funded guest accounts.
- WalletEventEmitter.emit dispatches over a snapshot, so a handler that
  (un)subscribes mid-emit can't alter the current dispatch.
- AgicashSdk.create() now throws while an undisposed instance exists,
  making the one-instance-per-process constraint (module-global Open Secret
  state) self-enforcing; dispose() releases the slot, so the HMR
  dispose-then-recreate cycle still works.

LOW findings of the cross-model review on PR #1166; each regression test
fails against the previous code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

fix(web-wallet): make the session-end web cleanup unconditional and close two expiry-path gaps

Three findings from the PR #1166 cross-model review:

- A throwing sdk.auth.signOut() skipped the whole web cleanup while the SDK
  had already ended the local session (its own finally), leaving query
  caches, the Sentry identity, and the hint cookie serving a dead session —
  with the sign-out button wedged in its loading state. The cleanup and the
  loading reset now run in finally.
- A guest auto-extension during init() fires auth.session-refreshed before
  the events hook subscribes, so the auth query and session-hint cookie
  stayed pinned to the old expiry. The hook now re-syncs on mount when the
  stored refresh-token expiry moved past what the query captured.
- The expiry hard-fallback reload had no guard; it is now throttled through
  a sessionStorage timestamp so a persistently failing cleanup can't
  reload-loop the tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

fix(wallet-sdk): read the session once per setDefaultAccount verb

The verb derived the user from the live session twice — once inside the
account-ownership read and again, after that await, for the row update. A
session switch between the two wrote the previous user's account id onto the
next user's row (the schema has no same-user constraint on the default
account FKs, so the broken pointer persists; RLS still hides the other
user's data). The session is now captured once at verb entry and threaded
through, so the validating user and the written user cannot diverge.

Finding 3 of the cross-model review on PR #1166. The regression test fails
against the previous code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

fix(wallet-sdk): stop the public receive projections leaking proofs and collapsing variants

The receive domain entities are intersections over variant unions, and the
contract projected them with a bare `Omit`. TypeScript collapses each union
to its shared keys under `keyof`, so the projections simultaneously leaked
the swap's top-level `tokenProofs` (spendable Cashu proof material, also via
the receive events' payload types) and silently dropped every variant-only
field (`tokenReceiveData`, `failureReason`, keyset fields) along with
discriminant narrowing.

The domain schemas now name their variant unions once (feeding both the
schema and an exported variant type), and the projections omit base keys
only, re-apply the variant unions, and strip proof material at both levels —
top-level `tokenProofs` and the melt data's nested `tokenProofs`. The
implementing slices (steps 9/11/12) must strip the same fields at runtime.

Inherited from #1164 (the projection sweep missed the receive entities);
finding 2 of the cross-model review on PR #1166.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

fix(wallet-sdk): harden the restore memo and guest sign-up edge paths

Three LOW findings from the PR #1166 cross-model review:

- A slow-failing restore cleared the restore memo unconditionally and its
  endSession bumped the generation, fencing out a newer restore's apply —
  an anonymous boot despite valid tokens. The rejection now un-memoizes
  only its own memo, and the failure path leaves the session alone when
  another transition owns it.
- A restore after a same-lifetime sign-out/sign-in repeated the verb's user
  fetch; doRestore now returns early when a verb already established the
  session.
- A guest sign-up whose credential persist failed left a live session whose
  account would strand (with any funds) at its first expiry. The sign-up is
  now undone and the verb rejects, so the retry lands on a recoverable
  account.

All three regression tests fail against the previous code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

fix(wallet-sdk): fence the expiry handler against concurrent session transitions

A fired expiry timer can't be cancelled, so its handler raced every other
session transition. Worst case: a sign-out landing while the guest
auto-extension's re-sign-in was in flight got silently undone — the
re-sign-in wrote fresh tokens over the sign-out's clear and the unfenced
snapshot apply flipped the session back to logged-in (money access after a
believed sign-out on a shared device). An in-flight handler also survived
teardown(), letting a disposed instance clear tokens its successor had just
restored.

The handler now captures the session generation at entry and re-checks it
plus the disposed flag after every await: a raced re-sign-in's tokens are
cleared again instead of resurrecting the session, the extension's apply is
generation-fenced, and the death path (sign-out, end, expired event) only
runs while the handler still owns the session it started from.

Finding 1 (HIGH) of the cross-model review on PR #1166. Both regression
tests fail against the previous code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants