Skip to content

feat(wallet-sdk): accounts slice (step 6)#1167

Draft
pmilic021 wants to merge 49 commits into
sdk/auth-slicefrom
sdk/accounts-slice
Draft

feat(wallet-sdk): accounts slice (step 6)#1167
pmilic021 wants to merge 49 commits into
sdk/auth-slicefrom
sdk/accounts-slice

Conversation

@pmilic021

@pmilic021 pmilic021 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Step 6 of the wallet-SDK migration (docs/superpowers/specs/2026-06-24-wallet-sdk-no-cache-production-design.md): wrap the accounts domain in AccountsApi and flip the web's accounts data layer onto sdk.accounts.*. Plan with the full Decision Record (B1–B7, maintainer-resolved) and gate record: docs/superpowers/plans/2026-07-13-wallet-sdk-accounts-slice.md.

Stacked on sdk/auth-slice (#1166). After #1166 merges: rebase onto master, retarget, re-verify (two-green-PRs rule).

What lands

  • Session key plumbing (session-keys.ts): memoized, generation-fenced getters over Open Secret (encryption keypair, cashu seed, spark mnemonic, locking xpub, spark identity pubkey), cleared in onSessionEnded — a different user can never be served the previous user's keys.
  • sdk.accounts (get/list/cashu.add) over one shared domain→projection mapper; AddCashuAccountParams pinned ({ name, mintUrl, currency, purpose }); AgicashSdk Pick grows 'accounts'.
  • B1 (maintainer ruling): projection-typed, runtime-fat cache. The web ['accounts'] cache holds projection-typed objects fetched via sdk.accounts.list(); hidden domain fields ride along at runtime until step 18 (type-level strip now, physical then). The shared mapper is the only cache entry point (queryFn, realtime, add onSuccess, ensure seed, claim upserts). Reality-class: sdk.accounts.* is TYPE-honest / RUNTIME-fat-until-18, intended and time-boxed; assumes web-internal consumers only during the window.
  • /temporary bridge v2: internal-repo accessor + toDomainAccount() checked cast (throws MissingDomainFieldsError naming missing fields — never a bare cast) + mapper re-export. All carry step-18 removal notes.
  • sdk.user.ensure(): the _protected.tsx bootstrap ported verbatim (key derivation, default accounts, Zod-aware retry, session-fenced memo); returns { user, accounts } projection-typed. Timestamp params are replayed acceptance times from pending-terms storage (maintainer-confirmed intent, JSDoc'd). Web glue keeps master's structure exactly: short-circuit, prefetch warm-up, conditional seeding.
  • Web flip (48 files): root Account/CashuAccount/SparkAccount/ExtendedAccount are now the projections (domain types import from /temporary); display reads .balance off the cache; money flows stay domain end-to-end through sanctioned unwrap seams.

Unwrap seams (every hidden-field read passes toDomainAccount())

  1. account-hooks: the four getter hooks unwrap internally; useDefaultAccount/useAccountOrDefault return domain.
  2. buy-input / send-input / receive-input: unwrap at the selector option build; account-selector renders domain via getAccountBalance (master verbatim).
  3. send-provider getAccounts; routes _protected.buy.checkout / receive.cashu / receive.spark / transfer.$destinationAccountId / send; claim route.
  4. account-proofs, transaction-additional-details, use-track-spark-account-balances (spark listener) at their seam.
  5. receive-cashu-token-hooks: unwraps for getSourceAndDestinationAccounts; maps the domain source account back through the shared mapper for display items.

Declared (questions and transitional items, not silent changes)

  • accounts.get(id) is not session-gated (master's repository posture — RLS scopes it); list/add gate on the session for userId, user.* gates every verb. Parity kept; consistency call open to review.
  • withRetry/delay ported into the SDK lib; web copies remain for unmigrated consumers (dies with their slices).
  • account-repository-hooks relocated to features/receive/ (not deleted): its only consumers are unmigrated receive repos constructing AccountRepository synchronously; reshaping them is step 8–16 scope. account-service-hooks deleted.
  • One parity FLAG: session-keys.ts ports master's hardcoded MAINNET for the spark identity pubkey (master's own TODO).
  • sparkDebugLog stays a named /temporary exception (maintainer ruling); dies at 18 with its call site.

Verification

  • fix:all clean · workspace typecheck green (all 9 packages incl. web) · tests exit 0: wallet-sdk 96 (25 new: projection completeness type+runtime, mapper single-entry, checked-cast thin-object throw, ensure memo/retry, key fencing across session end), web 36, money 14, bolt11 9, ecies 18, cashu 35. Chain re-run independently at the keeper gate on the frozen tree.
  • Browser smoke (build box): boots clean, landing/signup render with zero console errors, guest flow drives to ToS and initiates registration; blocked there by the dev enclave's server-side 403 for this box's origin (environmental — master fails identically here). Post-auth surfaces want a maintainer-env smoke; the ensure/mapper/fencing paths are unit-covered.

🤖 Generated with Claude Code

pmilic021 and others added 23 commits July 9, 2026 19:49
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hUser, user params)

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h; add createUserApi

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ret config into it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…codable refresh tokens

A restore whose fetchUser resolved after a concurrent auth verb (or session
end) could clobber the newer session with the stale result; a session
generation counter now gates the apply. A present-but-undecodable refresh
token now restores anonymous instead of establishing a session the expiry
machinery can neither time out nor refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dd a reload fallback

The SDK arms its expiry timer during init(), so an expiry can fire before
Wallet's event subscription mounts and emit to no subscribers; the hook now
detects the already-dead SDK session on mount and runs the expiry handling.
The handler also gets a hard window.location.reload() fallback so an
unexpected failure mid-reset can't strand the user on a blank page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each migration slice now settles its types in its own sdk/<namespace>.ts
instead of growing one contract file (19 placeholders across steps 6-16
remain to land); cross-cutting pieces (SdkConfig, Sdk, Logger) stay in
sdk/index.ts. Pure move — the exported surface is unchanged, and existing
'./sdk' import specifiers resolve to the directory index as before.

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

The compiler now checks the implemented subset (auth, user, events, init,
dispose) against the Sdk contract; each slice adds its namespace to the
Pick until it collapses to the full Sdk. Unimplemented namespaces stay
absent rather than stubbed, so consuming them early remains a compile
error instead of a runtime crash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A host that wants no diagnostics now passes the exported no-op nullLogger
instead of omitting the field, so silence is an explicit choice. Internal
components take a required Logger too — forgetting to forward one is now a
compile error rather than a silently diagnostics-less component.

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

A fetchUser continuation resolving after teardown (restore or verb) could
re-arm the expiry timer on a disposed instance, resurrecting the zombie
timer the HMR dispose hook exists to prevent. A disposed flag now blocks
re-arming and a late-firing timer callback; verbs and the session snapshot
are unaffected — disposal is not logout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Vite awaits a promise returned from hot.dispose, so the old SDK's teardown
is guaranteed to complete before the replacement module constructs the new
instance — which matters once dispose() gains async teardown work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nOut and the expiry handler

Both ran the same order-sensitive sequence (flags reset, auth invalidation,
navigate/revalidate, Sentry clear, queryClient.clear) with the ordering
rationale documented on only one of them; useSessionEndCleanup now owns the
sequence and its invariants, while each caller keeps its own edges (the
sdk.auth.signOut() call and redirect target vs the toast and reload
fallback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fail-soft decode idiom (a corrupt STORED token must degrade to null,
never throw) was duplicated in the SDK's auth service and the web's auth
glue, and missing from shared/auth.ts's isLoggedIn — where a corrupt
refresh token would throw through the DB client's token getter into every
query; it now reads as logged out. Tokens freshly minted by a server keep
decoding with jwtDecode directly so a malformed one fails loudly.

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

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>
@vercel

vercel Bot commented Jul 13, 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 17, 2026 4:45pm

Request Review

@supabase

supabase Bot commented Jul 13, 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 ↗︎.

orveth and others added 9 commits July 16, 2026 11:01
- useHandleSessionEvents now takes an options object ({ onSessionExpired })
  so the call site names the event the toast fires on (Petar agreed).
- Delete the expiry hard-fallback reload, its 30s lastReloadAt guard, and
  the sessionStorage key: the SDK has already ended the session by the time
  cleanup can throw, so nothing sensitive survives; the catch now just logs
  to console + Sentry. This drops the risk of a deterministic cleanup
  failure reload-looping the tab against the server.
- Keep reading Open Secret's token keys from local storage (the web app owns
  the storage it hands the SDK); drop the "temporary leak / step 18" framing.
- Explain why entry.client's sdk.client side-effect import must precede the
  body (SDK construction configures Open Secret, which loadFeatureFlags
  needs for a returning user's token) and that the arrangement is temporary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1166)

Cover the charset invariants: throws when no set is selected, and the output
alphabet is restricted to the enabled sets (letters-only, digits-only,
special-only, and the letters+digits mix). Add a note on lib/password.ts
explaining why the Web Crypto global is used directly — there is no isomorphic
import (node:crypto is Node-only and breaks browser bundling).

Threads: [15] [17]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…1166)

`background` did not convey what the namespace does. Rename to `task-processor`
across the contract: the `taskProcessor` namespace, the `TaskProcessorApi` /
`TaskProcessorState` types, the `task-processor.state-changed` event, the
`task-processor.ts` file, and the related doc comments. This matches the term
already used in the web app (the TaskProcessor component).

Threads: [8] [13]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1166)

The receive and send namespaces re-export the domain entity types directly
instead of projecting them. Consumers of this SDK only read these shapes, so
exposing the domain fields (proofs, userId) is acceptable for now and keeps a
single source of truth; narrowing can come later. This removes the
projection type aliases in sdk/receive.ts and sdk/send.ts and the variant-union
type exports in the receive domain files that existed only to support them.

Threads: [11] [12] [14]  (accounts [0] flagged separately)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er note (#1166)

- supabase-session: `generateToken` is now required and injected from the
  composition root, so wallet-sdk/db no longer imports @agicash/opensecret.
  agicash-sdk passes openSecret.generateThirdPartyToken.
- Define the Supabase url + anon key in sdk.client.ts and import them into
  database.client.ts, which is the file being retired.
- Add a note on the console logger explaining why the meta === undefined check
  is shaped that way (it avoids logging a trailing "undefined").

Threads: [3] [24] [26]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update-only consumers (UserService, user-api) do not need AccountRepository, so
the write repository is split: UpdateUserRepository takes only `db`, and
UpsertUserRepository takes `db` + `accountRepository` in its constructor rather
than as a per-call parameter — consistent with how other services receive their
dependencies. The web _protected upsert glue constructs UpsertUserRepository
with the account repository.

Threads: [22]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rd disposal (#1166)

auth-service:
- The session is scoped by an AbortController (`sessionScope`): every session
  transition aborts the current scope and installs a fresh one, and an in-flight
  restore or apply bails when its scope is aborted. This fences a stale restore
  out when a sign-out or a newer login wins the race, and clears a raced guest
  re-sign-in's fresh tokens when a sign-out wins.
- Invoking an action after teardown throws DisposedError (new error type).
- The expiry timer helpers are named setExpiryTimer / clearExpiryTimer, and the
  discarded-promise call site no longer needs the `void` operator.
- Comments read in plain language: an auth action (sign-in, sign-up, …), with
  each explaining its own reason directly.
- events: auth.session-refreshed and the event-naming grammar read plainly.
- Tests cover the abort paths (a sign-out fences an in-flight restore) and that
  actions throw once the instance is disposed.

Threads: [6] [27] [29] [30] [31] [32] [34] [36] [37] [38] [39]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the self-review pass (Opus adversarial + codex-review) on the r3 diff:
- teardown's doc states that actions throw DisposedError after disposal and that
  stored tokens + the session snapshot are left intact for a successor instance.
- receive.ts / send.ts note that the public types are the domain entities for
  now (the apps only read them; proofs/userId ride along until a later slice
  narrows the surface, #1164).
- an auth-service test comment describes the fencing as aborting the session
  scope, matching the code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…actory to lib (#1166)

- setDefaultAccount takes `userId: string` (the SDK path has only the session
  user id; a full domain User would reintroduce the account read step 6 removed)
  and the full domain `account`.
- The guest account storage factory lives in lib/ (a runtime adapter, not domain
  wiring) and stays exported so its unit tests and the auth-service tests keep
  using it. AuthService builds its own guest storage from the storage + logger it
  already receives, so the composition root no longer wires it in.

Threads: [21] [2]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@orveth

orveth commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

On balance / domain-vs-projection returns (deferred here from jbojcic's #1166 review of sdk/accounts.ts — this PR is that contract's implementation, so the answer belongs here):

Why it currently is the way it is. A cashu account's balance is derived state — sumProofs over its proofs — so the domain type deliberately doesn't store it: a stored field would be a second source of truth that can drift from the proofs (master computes it at render via getAccountBalance for the same reason; spark differs because its balance comes from the wallet service, not from local data). The Omit<> projection implements the contract ruling in force when this slice was planned (#1164: wallet handles / proofs / keysetCounters don't cross the public surface), which this PR's decision record (B1) turned into the type-level-strip-until-step-18 arrangement — the shared mapper attaches balance at the single point where account objects enter the web cache, preserving the one-computation-point property.

What changes under the expose-full-domain-types direction (the direction #1166 has now taken for the receive/send equivalents): the projection rationale collapses, and accounts can align cleanly — declare balance on the domain CashuAccount and attach it in AccountRepository.toAccount, which is already the single constructor every account object flows through (list, realtime row mapping, add, ensure) — the anti-drift property survives intact, one layer deeper. sdk.accounts.* and ensure then return domain types directly, and this PR's projection machinery deletes wholesale: the projection types, the shared mapper, the toDomainAccount checked cast, the unwrap seams in the web hooks, and the type-honest/runtime-fat transitional record — a net-negative, contained diff (deletions dominate; the web's domain-type imports flip back to the root).

The call is yours + petar's, not mine — it reopens B1, which petar ruled explicitly for this slice. Two coherent end-states:

  • Align now, pre-merge on this branch: cheapest total cost if the full-domain-types direction is contract-wide — this PR becomes the first slice fully on it, and nobody migrates these files twice.
  • Merge as-ruled, align later with the spec-doc update: keeps the ruled design; costs a second pass over the same surface when accounts follows.

Sized and ready to execute either way on your word.

orveth and others added 6 commits July 17, 2026 06:37
…ltime + init doc (#1166)

- applySessionFromServer wipes the previous user's per-session caches when a
  different user's session starts over a live one (a login without a sign-out in
  between), keyed off the live session. Each memo cleared by onSessionEnded
  already fences its own in-flight writes, so a direct cross-user login is the
  only case that needs this check; agicash-sdk documents that contract at the
  onSessionEnded wiring. Tests cover the cross-user wipe, no double-wipe after
  sign-out, and same-user warmth.
- events: the connection.changed doc says it is the Supabase realtime data
  channel (the per-user row-change subscription), not the auth/network connection.
- web: the sdk.init() failure log reads "Failed to initialize sdk", tracking the
  method name as init grows.

Threads: [33] [7] [44]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… mid-extension sign-out (#1166)

Two session-scope gaps from the cross-model review:

- teardown() aborts the session scope and applySessionFromServer skips its
  writes when disposed, so an in-flight restore that resolves after dispose no
  longer writes a session or runs onSessionEnded — onSessionEnded clears
  process-wide caches (spark wallets, mint CAT) that a hot-reload successor
  instance may already own.
- The guest auto-extension threads its session scope through the post-re-sign-in
  apply. applySessionFromServer reports whether it applied, so a sign-out that
  wins while that apply is in flight skips it (no resurrection of the ended
  session) and the handler bails instead of running its death path over the
  transition that already owns the state.

Tests: dispose-mid-restore is fenced (no session write, no onSessionEnded);
sign-out during the post-extension apply does not resurrect the session.

Threads: round-3c H1, H3

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dk/ into domain/ (#1166)

- AgicashSdk implements the full Sdk; the eight not-yet-migrated namespaces
  are getters that throw NotImplementedError (auth/user/events + init/dispose
  stay live)
- move sdk/ to domain/sdk/ and rename agicash-sdk.ts to domain/sdk/sdk.ts
  (and its test), with imports updated
- generateRandomPassword is synchronous (its body has no awaits), fixing the
  missing-await at the call site and the redundant awaits in its test
- drop web-app references from the events and config contract docs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ontract (#1166)

Drop the projected CashuAccount/SparkAccount (the omit + computed balance) and
re-export the domain Account/CashuAccount/SparkAccount directly, matching the
receive/send contracts (#1164). Cashu accounts carry no balance field —
consumers sum the now-exposed proofs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move packages/wallet-sdk/supabase to packages/wallet-sdk/db/supabase and
retarget every reference: the Supabase CLI --workdir becomes
packages/wallet-sdk/db, and the generated-types output, biome ignore, both
tsconfig path-alias targets, the CI migration/types paths, and the live docs
(README + supabase skill) follow. The `supabase/database.types` import alias is
unchanged.

The self-signed TLS cert paths gain one `../` (ci.yml + web-wallet-e2e/.env.test)
because Supabase resolves config.toml paths relative to the now-deeper project
folder; the web app is unaffected (it loads its cert via a hardcoded path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…drop receive schema churn (#1166)

- move WalletEventEmitter from lib/events.ts into domain/sdk/events.ts, next to
  the WalletEventMap/WalletEvents types it implements, and move its test with it
- revert the schema-extraction refactor in the cashu-receive-quote,
  cashu-receive-swap, and spark-receive-quote domain files (unnecessary here;
  back to master)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@orveth

orveth commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Rebased onto the domain-types contract (d5d2f18 landing in #1166: domain Account/CashuAccount/SparkAccount exposed directly, no balance field — the direction decided for the question deferred here). The base also restructured (sdk/domain/sdk/, 16 commits), so the branch was rebuilt as 8 clean commits rather than replaying 17 through conflicts; the pre-rebase state is preserved at sdk/accounts-slice-pre-domain.

What changed vs the previous head:

  • The projection machinery is gone (mapper, checked cast, unwrap seams, projection types) — the cache holds domain accounts, balance reads are getAccountBalance(account) exactly like master, and getExtendedAccounts/isDefaultAccount keep their clean domain signatures (the generic hack died with the projections).
  • AddCashuAccountParams pinned in the new contract file: { name, mintUrl, currency, purpose }.
  • Everything operational carried over intact: sdk.accounts (get/list/cashu.add over the instance's repository), sdk.user.ensure() (merged with the base's split write repositories + withRetry around derivation and upsert), session-key plumbing + fencing (reset rides the base's session-teardown clear list), both /temporary accessors, the web re-sourcing (list/get/add/realtime/ensure glue), withRetry/delay in @agicash/utils, and the key-query delegation round (xpubQueryOptions untouched — live getXpub money-path consumer).

Verified at the keeper gate on the rebuilt head: fix:all clean, all packages typecheck, tests exit 0 (wallet-sdk 105, web 36); repo-wide grep confirms zero projection references remain. Independent delta review running.

orveth and others added 8 commits July 17, 2026 09:44
Add createSessionKeys: per-session memoized getters for the encryption
keypair, cashu seed, spark mnemonic, cashu locking xpub, and spark identity
public key, derived from Open Secret. Each memo is generation-fenced so a
derivation started before reset() cannot repopulate the cache for the next
session, and rejections are not cached so a retry can recover.

Wire keys.reset() into the AgicashSdk onSessionEnded teardown alongside the
existing session-token, spark-wallet, and mint-auth-token clears, so a signed-in
user's key material never survives into the next login. The accounts and user
namespaces consume these getters in later commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement createAccountsApi: get(id), list() (session-gated for the userId),
and cashu.add(params) over an internal AccountRepository built from the db,
session keys, and spark config. The namespace returns the domain account types
directly per the accounts contract (#1166) — no projection mapping. cashu.add
re-injects type:'cashu' and the session userId before the service call, and
list()/add gate on the session while get() relies on RLS (repository posture).

getRepository is exposed alongside the api so the /temporary bridge and the
user namespace's ensure() build the repository through one path.

Pin AddCashuAccountParams to { name, mintUrl, currency, purpose } and wire the
accounts field into AgicashSdk, replacing the not-implemented getter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expose getInternalAccountRepository through '@agicash/wallet-sdk/temporary':
a module-scoped accessor that hands unmigrated receive/send flows and realtime
row mapping the live instance's internal domain accounts repository, built
through the same path as sdk.accounts.*. It throws 'No live AgicashSdk instance'
before create and after dispose (the module reference is cleared on dispose
alongside liveInstance), so the domain repository never leaks onto the public
AgicashSdk surface. Removed at step 18 when those flows read from the SDK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK's user.ensure() needs the retry helper the web owned, so lift withRetry
and delay from apps/web-wallet/app/lib into @agicash/utils (named exports, barrel
re-exported) and delete the web copies. Flip the remaining consumers — the cashu
receive-quote hook, the protected-route bootstrap, and the e2e Open Secret
fixture — onto @agicash/utils, and add the workspace dependency to the e2e
package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add user.ensure(params) to the contract and implement it on the user namespace:
it derives the encryption public key, cashu locking xpub, and spark identity
public key, upserts the user row (creating the default accounts and persisting
the keys on first sign-in) through the base UpsertUserRepository, and returns
{ user, accounts } domain-typed for the host to seed its caches. The upsert
returns domain accounts already, so ensure returns them as-is — no mapping.

The key-derivation batch and the upsert each run under withRetry (matching the
resilience the host's query layer gave master); the upsert retry is Zod-aware so
a validation error fails fast. EnsureUserParams carries the replayed pending-terms
acceptance timestamps, distinct from acceptTerms' in-session boolean stamps.
ensure builds its repository through the accounts namespace's getRepository, so
the whole instance shares one construction path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flip the accounts data path onto the SDK namespace: accountsQueryOptions'
queryFn calls sdk.accounts.list(), useAccountOrNull's lazy fetch calls
sdk.accounts.get(id), useAddCashuAccount calls sdk.accounts.cashu.add (callers
drop the now-implicit type: 'cashu'), and the realtime ACCOUNT_CREATED/UPDATED
handlers map rows through getInternalAccountRepository().toAccount. The cache
still holds domain accounts, so every consumer (getAccountBalance, wallet/proofs
readers) is unchanged.

account-service-hooks is deleted (the service lives behind sdk.accounts.cashu.add)
and account-repository-hooks moves to features/receive, its only remaining
consumers being the unmigrated cashu receive-quote and receive-swap repos.

Extract getExtendedAccounts and isDefaultAccount from UserService into standalone
pure functions exported from the package root, and flip their consumers (the
accounts hooks, the claim route, and the SDK-internal claim service).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route _protected's bootstrap through sdk.user.ensure(): it replaces the inline
key-derivation + upsert, seeds the user and accounts caches from the public
return, and keeps master's structure (cache short-circuit, session-token warm,
ensureBreezWasm placement, conditional seed on the upsert branch).

Source the web-side key queries from the live instance's session keys via a new
getInternalSessionKeys /temporary accessor: useEncryption collapses to a single
['encryption'] query over getEncryption(), and the cashu-seed and spark-mnemonic
query fns delegate to the same getters. The per-key encryption query options and
their hooks are deleted (only encryption-hooks and the two route warms read them),
sparkIdentityPublicKeyQueryOptions is dropped (its only reader was the ensure
warm), and xpubQueryOptions is untouched. The bootstrap warms encryption, seed,
and mnemonic concurrently with ensure() so the unmigrated receive/send/claim
repos keep master's warm-cache and fail-in-the-middleware behavior. The web
defaultAccounts copy is removed now that ensure() owns it SDK-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bring the step-6 decision record onto the branch and append a supersession
entry: the maintainer's contract-level domain-types ruling (d5d2f18, #1166)
superseded B1's projection apparatus after the slice was first built, so
sdk.accounts.* returns domain types directly (no mapper, no checked cast), B5
balance reads revert to getAccountBalance, and the runtime-fat reality-class
record and the step-18 physical strip retire as moot. The record's prior entries
stand as the decision trail.

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.

2 participants