docs(wallet-sdk): step-4 public contract proposal#1164
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
ditto-agent
left a comment
There was a problem hiding this comment.
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):
- 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. - No failure channel.
start(): void, no error/state events, no logger port. A processor dying mid-melt is invisible to both hosts; SDKconsole.*corrupts an MCP stdio transport. - 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);
networkis per-account, not global; the wholelib/sparkexport surface is missing from the migration table; WASM loading/warmup has no port. - ServerSdk: wrong key, missing field, dropped input.
serviceRoleKeynotanonKey;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.
| | `*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 | |
There was a problem hiding this comment.
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:
WebAssemblyUnavailableError—root.tsxdoesinstanceofon it for the WASM-fallback UI; it must join the root error exports and extendSdkError, or the one-instanceof-check claim above is false.getSparkIdentityPublicKeyFromMnemonic—spark-query-options.tsderives 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).
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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); ensureBreezWasm → warmup() per the lifecycle thread. No new API — just table rows so step 19 doesn't orphan them.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
seems like only WebAssemblyUnavailableError will need to be moved to main export but as @pmilic021 said we can see when the time comes
|
|
||
| type BackgroundApi = { | ||
| start(): void; // leader election + change feed + processors | ||
| stop(): Promise<void>; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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):
AuthApiis 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.- Change-feed ownership is stated twice, differently (
events.on()at L97 vsstart()'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. background.state-changedfires only on errors — loses leader/follower observability and contradicts its own name. Fire on every transition; strictly simpler to document.- The agreed
lib/sparkmigration-table rows didn't land in the fold-in commit — re-supplied inline. 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 |
There was a problem hiding this comment.
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.tsschedules refresh-or-expire (treats the session expired 5s early; on refresh failure → logout, auth.ts:365–401) andwallet.tsx:43wiresonLogoutto a toast + redirect. That trigger needs a contract home —auth.session-expired(orauth.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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
(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
There was a problem hiding this comment.
(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 |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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` | |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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."
There was a problem hiding this comment.
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.changedevent which will hint the web to bust all it's cache, similar to how on master that already works
There was a problem hiding this comment.
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.
|
Step-4 code part is on the branch (b541b68 + 68a6bf9): 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 — Known-open for review: the |
| * 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. | ||
| */ |
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>
7ab2877 to
7c51234
Compare
…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>
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>
Step 4 of the no-cache production design: defines the
Sdkclass +create(config), the per-domain namespaces, the separateServerSdk, 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
/temporarytoday; the doc includes the migration mapping table.Four decision points called out at the end of the doc:
{ url, anonKey }— the SDK builds its own Supabase client, since auth lives inside the SDK and the access-token getter would otherwise be circularsdk.receive.cashu) over rail-first (sdk.cashu.receive)userIdimplicit from the authenticated sessioncomplete/expire/failverbs are background-only (behindsdk.background, observed via events) — not on the public surface🤖 Generated with Claude Code