Skip to content

With cache SDK migration wip#1155

Draft
pmilic021 wants to merge 78 commits into
masterfrom
sdk/with-cache-wip
Draft

With cache SDK migration wip#1155
pmilic021 wants to merge 78 commits into
masterfrom
sdk/with-cache-wip

Conversation

@pmilic021

Copy link
Copy Markdown
Contributor

No description provided.

pmilic021 and others added 30 commits June 8, 2026 16:39
…ckage

Move the framework-free money/, json, and zod utilities out of
apps/web-wallet/app/lib into a new @agicash/utils leaf package so the upcoming
@agicash/wallet-sdk can depend on them without importing from the web app.

- packages/utils: new @agicash/-scoped, source-linked package (money, json, zod).
  exports use a "./*": "./src/*.ts" wildcard plus an explicit "./money" entry
  (the wildcard cannot index-resolve the money/ directory under Bundler
  resolution; the bare "." root also needs its own entry)
- money/json/zod moved byte-identical via git mv; logic untouched (money is
  financial code)
- old ~/lib/{money,json,zod} paths kept as transitional re-export shims so the
  ~80 consumers stay untouched; a later cleanup PR rewrites the imports and
  deletes the shims
- big.js, @types/big.js, zod hoisted into the root workspaces.catalog (now
  shared by >=2 packages) and referenced via catalog:
- web-wallet now depends on @agicash/utils (workspace:*)

Phase 0 of the wallet-sdk extraction. typecheck, biome, tests (utils 14, web 119)
and full build all green; zero web behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cash/db-types

Move the generated supabase/database.types.ts into a new @agicash/db-types leaf
package so the upcoming @agicash/wallet-sdk can consume the DB schema types
without importing from the web app or relying on a cross-package tsconfig alias.

- packages/db-types: new @agicash/-scoped, source-linked, zero-dependency package
  exporting the generated Database / Json / Tables / ... types
- supabase/database.types.ts -> packages/db-types/src/database.types.ts
  (byte-identical move via git mv)
- db:generate-types script and the CI type-drift check now write/diff the new path
- biome "ignore" updated so the generated file stays unlinted
- removed the web app's "supabase/database.types" tsconfig path alias; the two
  importers (agicash-db/database.ts, transaction-details-types.ts) now import from
  @agicash/db-types directly (only 2 sites, so a direct repoint is cleaner than a
  cross-package alias shim)
- web-wallet depends on @agicash/db-types (workspace:*)

The augmented Database type (agicash-db/database.ts) and the json-models stay in
the web app for now: they depend on not-yet-extracted ~/lib/cashu and the send
feature, so they move when those are extracted in later phases.

Phase 0 of the wallet-sdk extraction. typecheck, biome, tests (web 119, utils 14)
and full build all green; zero web behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…icash/db-types

Move agicash-db/database.ts (the MergeDeep-augmented Database with wallet RPC
return/composite types, the AgicashDb* row types, and the isCashuAccount/isSparkAccount
guards) plus the 2 pure-zod account-detail json-models it needs into @agicash/db-types,
so the SDK's repositories can consume the DB type layer without importing from the web app.

- database.ts + json-models/{cashu,spark}-account-details-db-data.ts -> packages/db-types/src/
  (byte-identical git mv)
- package index re-exports the AUGMENTED Database (from ./database) + the generated helper
  types (Json/Tables/... from ./database.types) WITHOUT the generated Database, to avoid a
  name clash
- old agicash-db/database.ts + the 2 account-detail json-models kept as transitional
  re-export shims (35 + 8 importers untouched)
- db-types gains deps zod, @supabase/supabase-js, type-fest (latter two hoisted to the root
  catalog, now shared by web + db-types)

Phase 0 of the wallet-sdk extraction. typecheck, biome, tests (web 119, utils 14) and full
build all green; zero web behavior change. The 11 remaining json-models stay in the app for
now (they depend on ~/lib/cashu ProofSchema / the send feature — handled in the cashu lib-ify
and send phases).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o @agicash/cashu

First slice of lib-ifying ~/lib/cashu: move the pure, framework-free protocol files into a
new @agicash/cashu leaf package so the SDK cashu domain can consume them without dragging
React in.

- error-codes, types (ProofSchema), secret, proof, token, payment-request,
  protocol-extensions, blind-signature-matching (+ their 5 tests) -> packages/cashu/src
  (byte-identical git mv; only types.ts/secret.ts rewired to @agicash/utils for zod/json)
- the @agicash/cashu barrel deliberately exports NO React (the lib's framework-free contract)
- old ~/lib/cashu/<file> paths kept as transitional re-export shims so the web barrel and the
  ~20 consumers stay untouched
- @cashu/cashu-ts, @noble/hashes, @noble/curves hoisted to the root catalog (now shared by web
  + @agicash/cashu); cashu-ts pinned so the lib and web can't drift on the subclass overrides
- @agicash/cashu deps: @agicash/utils, @cashu/cashu-ts, @noble/hashes, zod

Phase 0. typecheck, biome, build all green; tests preserved (133 total: web 84, utils 14,
cashu 35); framework-free gate verified. Still in ~/lib/cashu (Chunk C): the utils.ts/wallet
split, mint-validation, the 2 subscription managers; staying in web: the React hook +
animated-qr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s into @agicash/cashu

Completes the framework-free @agicash/cashu lib (Chunk C of the cashu lib-ify):

- utils.ts (ExtendedCashuWallet, getCashuWallet, unit/keyset/mint-url helpers),
  mint-validation.ts, and the melt-quote/mint-quote subscription MANAGER classes
  -> packages/cashu/src (byte-identical git mv)
- the managers now import getCashuWallet from concrete ./utils (NOT the ~/lib/cashu barrel,
  which re-exports a React hook) and isSubset from @agicash/utils, so the lib stays React-free
- isSubset relocated from app/lib/utils.ts into @agicash/utils (single source); app/lib/utils.ts
  re-exports it so ~/lib/utils consumers (the proof-state manager) are untouched
- cashu utils money types now from @agicash/utils/money; type-fest added to @agicash/cashu
- old ~/lib/cashu/{utils,mint-validation,*-subscription-manager} kept as transitional shims
- proof-state-subscription-manager.ts (features/send): added a note that it likely belongs in
  @agicash/cashu too, deferred to the send/swap phase (it depends on send-domain types)

Web keeps the React hook (useOnMeltQuoteStateChange) + animated-qr. typecheck, biome, build all
green; 133 tests preserved; FRAMEWORK-FREE GATE verified (zero react/@tanstack/DOM in
@agicash/cashu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cash/db-types

Now that ProofSchema lives in @agicash/cashu, the remaining agicash-db json-models (jsonb
column schemas) can move into @agicash/db-types where they belong:

- cashu-lightning-receive, cashu-swap-receive, cashu-swap-send, cashu-token-melt,
  spark-lightning-receive, spark-lightning-send -> packages/db-types/src/json-models
  (byte-identical git mv; ~/lib/money -> @agicash/utils/money, ~/lib/cashu ProofSchema ->
  @agicash/cashu)
- old paths kept as transitional re-export shims so the json-models barrel + its 15 consumers
  stay untouched
- db-types now depends on @agicash/cashu (ProofSchema) + @agicash/utils (Money) — this coupling
  already existed in the app; the package graph now just reflects it. db-types tsconfig gains the
  DOM lib (it transitively pulls in money.ts's devtools-formatter window use)

Staying in the app for their own phases: cashu-lightning-send-db-data (needs the send-domain
DestinationDetailsSchema) and account-details-db-data (account-domain combined schema).

Phase 0. typecheck, biome, build all green; 133 tests preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The SDK now owns the single TanStack QueryClient the web app uses — the riskiest seam of
the extraction — proven behavior-neutral before any domain logic moves.

- @agicash/wallet-sdk gains getQueryClient() (packages/wallet-sdk/src/query-client.ts): the
  exact isServer/browser-singleton pattern from the web, now on @tanstack/query-core (pinned
  to 5.90.20 = the version @tanstack/react-query depends on, incl. the workspace patch — so
  this QueryClient is the SAME class react-query's hooks use and the web can mount it directly)
- web's features/shared/query-client.ts is now a thin re-export of the SDK's getQueryClient,
  so all ~16 call sites (root provider, the supabase-session module-load capture, loaders,
  auth/user/cashu) are untouched and the browser singleton has a single home in the SDK
- web now declares @agicash/wallet-sdk (workspace:*); also declares @agicash/cashu, which the
  Phase-0 cashu shims import but the app had never declared (phantom-dep fix)
- SDK test locks the per-request (server) vs singleton (browser) contract

typecheck, biome, build all green; 135 tests (web 84 + utils 14 + cashu 35 + sdk 2); zero web
behavior change; @agicash/wallet-sdk is framework-free (query-core only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cates) (Phase 2.1)

First slice of the accounts-domain extraction: move the framework-free leaf files into
@agicash/wallet-sdk/src/accounts so the SDK owns the account types/predicates the rest of the
domain (and its consumers) build on.

- account.ts (Account/CashuAccount/SparkAccount types + schemas + predicates
  canSend/ReceiveFromLightning + getAccountBalance), cashu-account.ts (CashuProofSchema, toProof),
  account-cryptography.ts (BIP-85 derivation path) -> packages/wallet-sdk/src/accounts
  (byte-identical git mv; ~/lib/cashu -> @agicash/cashu, ~/lib/money -> @agicash/utils,
  agicash-db/json-models -> @agicash/db-types)
- getAccountHomePath stays in web (it returns app route strings — web-only, meaningless to a
  non-React SDK consumer); the web account.ts shim re-exports the SDK + defines it locally
- old account/cashu-account/account-cryptography paths kept as transitional re-export shims so
  the ~31 consumers stay untouched
- SDK deps: @agicash/{cashu,db-types,utils} (workspace), @agicash/breez-sdk-spark (hoisted to the
  catalog), @cashu/cashu-ts, type-fest, zod

Phase 2. typecheck, biome, build all green; 135 tests; @agicash/wallet-sdk still framework-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold ~/lib/ecies (pure ECIES over @noble) into @agicash/utils so the SDK's encryption layer
(next chunk) can use it without importing from the web app. Kept in @agicash/utils rather than
a separate package per the decision to split later only if needed.

- ecies/{ecies,index,ecies.test}.ts -> packages/utils/src/ecies (byte-identical git mv; no
  import changes — only @noble imports)
- @agicash/utils exports a "./ecies" subpath + root re-export; gains @noble/ciphers/curves/hashes
  (ciphers hoisted to the catalog; curves/hashes already there)
- old ~/lib/ecies path kept as a transitional re-export shim (its one consumer,
  shared/encryption.ts, untouched)

typecheck, biome, build all green; 135 tests (utils now 32: money + ecies).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the framework-free encryption layer out of features/shared/encryption.ts into
@agicash/wallet-sdk — the first connection-core piece the SDK owns on the path to backing the
accounts domain with SDK-owned connections.

- packages/wallet-sdk/src/encryption.ts: ECIES wrappers (encryptToPublicKey/
  decryptWithPrivateKey + batch variants), the serialization rules (Date/undefined/non-finite/
  Money preservation), the Encryption type, getEncryption, and the opensecret-backed key
  derivation queryOptions (encryptionPrivateKey/PublicKey) — moved verbatim except:
  - imports repointed (~/lib/ecies -> @agicash/utils/ecies, ~/lib/money -> @agicash/utils/money)
  - the two queryOptions are now PLAIN query-options objects (the react-query queryOptions()
    helper is a typed identity fn — runtime-identical; web's useSuspenseQuery consumes them
    unchanged, per the SDK's query-core-types-only architecture)
- web's features/shared/encryption.ts keeps ONLY the React hooks (useEncryption,
  useEncryptionPrivateKey/PublicKeyHex), now consuming the SDK; everything else re-exported
  (all 12 consumer files untouched)
- SDK gains @agicash/opensecret (catalog) — the single TRACKED EXCEPTION to the framework-free
  rule (transitive react peer-dep + localStorage), documented in package.json
  dependencies:comments; removed by the in-flight storage-pluggable opensecret bump before the
  MCP phase. No DIRECT react/DOM imports in SDK src (gate verified).
- SDK barrel now documents the queryOptions-read pattern + the deferred Query<T>/MCP plan
- new encryption.test.ts locks the encrypt/decrypt roundtrip + the serialization rules

typecheck, biome, build all green; 138 tests (sdk 5, web 84, utils 32, cashu 35).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r (connection-core step 3)

The SDK now owns the RLS-scoped Supabase connection — the third connection-core piece on the
path to backing the accounts domain with SDK-owned connections.

- src/supabase-session.ts (moved verbatim from agicash-db/supabase-session.ts): the
  opensecret generateThirdPartyToken query with its dynamic JWT-exp staleTime, plus the
  module-load getQueryClient() capture — which now resolves INSIDE the SDK (same singleton,
  no more cross-package capture). FetchQueryOptions now typed via @tanstack/query-core.
- src/auth.ts (moved verbatim from features/shared/auth.ts): isLoggedIn — it reads the token
  keys the OpenSecret client writes, so it is opensecret-session logic; its direct
  window.localStorage read is folded into the same tracked exception as the opensecret
  dependency (switches to the StorageAdapter on the storage-pluggable bump).
- src/agicash-db.ts (new factory): createAgicashDb({url, anonKey}) — the createClient call
  moved verbatim (schema 'wallet', session-token accessToken, the production payload-redacting
  realtime logger). The consumer supplies connection params.
- web's database.client.ts keeps what is genuinely web: import.meta.env reading, the dev-LAN
  URL rewrite (window.location), the SupabaseRealtimeManager and the window debug attach —
  and now builds agicashDbClient via the SDK factory. shared/auth.ts +
  agicash-db/supabase-session.ts are transitional re-export shims (all consumers untouched;
  _protected.tsx's direct supabase-session import covered).
- SDK gains @supabase/supabase-js (catalog) + jwt-decode (hoisted to catalog, now shared)

typecheck, biome, build all green; 138 tests; no direct react/DOM imports in SDK src beyond
the documented opensecret/localStorage exception.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…connection-core step 4a)

The SDK now owns the cashu connection layer the AccountRepository depends on.

- src/cashu.ts (split out of features/shared/cashu.ts, bodies verbatim): the locking
  derivation path, tokenToMoney, CashuCryptography + seed/xpub/privateKey queryOptions +
  getCashuCryptography (opensecret + @Scure), getTokenHash, getMintAuthProvider, the mint
  info/keysets/keys queryOptions + keys, decodeCashuToken, and getInitializedCashuWallet
  (offline handling + keychain cache load). queryOptions() helper calls converted to plain
  query-options objects (typed identity — runtime-identical).
- src/agicash-mint-auth-provider.ts (moved verbatim): the NUT-21 CAT AuthProvider; its
  module-load getQueryClient capture + isLoggedIn now resolve inside the SDK.
- src/performance.ts (new): a minimal instrumentation seam — measureOperation delegates to a
  registered measurer (default pass-through); entry.client.tsx registers the web's
  Sentry-backed implementation at startup so the moved operations keep emitting the same
  Sentry spans (no server-side callers of the moved fns exist today).
- web keeps in features/shared/cashu.ts what is genuinely web: the env-derived
  cashuMintValidator (import.meta.env blocklist) and the useCashuCryptography React hook,
  plus a re-export of the SDK module (all consumers untouched).
- computeSHA256 -> @agicash/utils/sha256 (pure Web Crypto; web lib/sha256.ts is a shim;
  used by the moved getTokenHash and later by spark).
- SDK gains @scure/bip32 + @scure/bip39 (hoisted to the catalog; web flips to catalog:).

typecheck, biome, build all green; 138 tests; no direct react/DOM in SDK src beyond the
documented opensecret/localStorage exception.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The SDK now owns the spark/Breez connection layer — the last connection piece the
AccountRepository depends on.

- src/spark.ts (split out of features/shared/spark.ts, bodies verbatim): sparkDebugLog,
  the Breez initLogging once-guard, the mnemonic/identity-pubkey/wallet queryOptions
  (converted to plain query-options objects) and getInitializedSparkWallet (offline stub
  handling). The two web bindings are replaced by a configureSpark({apiKey,
  isDebugLoggingEnabled}) seam: the web supplies the env-derived Breez API key and the
  per-call DEBUG_LOGGING_SPARK feature-flag check; log formats unchanged.
- src/spark-utils.ts (moved verbatim from lib/spark/utils.ts): the Breez external signer
  identity-pubkey derivation + the offline wallet stub. lib/spark keeps errors.ts + wasm.ts
  (their consumers move in later phases) and re-exports the moved utils.
- web's features/shared/spark.ts keeps what is web: the eager VITE_BREEZ_API_KEY read/throw,
  the configureSpark call (module load — same ordering + fail-fast as today, and the
  server-side lightning-address path configures identically since it imports via this module),
  and the useTrackAndUpdateSparkAccountBalances React hook. Everything else re-exported (all
  consumers untouched).
- sparkDebugLog being SDK-owned dissolves the previously planned AccountsCache logger
  injection: when the cache moves (next chunk), it imports the SDK logger directly.

typecheck, biome, build all green; 138 tests; no new SDK deps.

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

The SDK now owns the DB client INSTANCE, not just its construction. The web supplies the
env-derived connection params via configureAgicashDb at module load and re-exports
getAgicashDb() as the transitional agicashDbClient for not-yet-migrated repositories
(removed in the import-cleanup PR). createAgicashDb becomes module-private. Once the Sdk
composition root lands, the configure/get pair is absorbed into it — the raw client is
never part of the public API.

typecheck, biome, build green; 138 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ns (Phase 2.5)

AccountRepository, AccountService, AccountsCache + accountsQueryOptions + the realtime
change-handler factory move into @agicash/wallet-sdk — the accounts domain now runs entirely
on connections the SDK already owns (db, encryption, cashu/spark wallet init, queryClient).

- src/error.ts (moved verbatim from shared/error.ts): DomainError/ConcurrencyError/
  NotFoundError/UniqueConstraintError + getErrorMessage — core domain primitives the
  repository throws; web shim keeps the 37 consumers untouched.
- src/accounts/account-repository.ts (method bodies verbatim): constructor switches to a
  typed AccountRepositoryDeps object (destructured into the same private fields); the
  wallet-init/auth-provider imports become plain SDK module imports ('../cashu', '../spark')
  — the injection promotion previously planned is no longer needed.
- src/accounts/account-service.ts (bodies verbatim): deps object; allMintKeysetsQueryOptions
  imported from '../cashu' directly; the User param narrows to a structural
  UserDefaultAccounts type (web's User satisfies it; replaced when the user domain moves).
- src/accounts/accounts-cache.ts: AccountsCache (version-guarded upsert + spark balance
  write-guard, verbatim), accountsQueryOptions (plain query-options object; staleTime
  Infinity + the load-bearing structuralSharing preserved verbatim with its comment), and
  createAccountChangeHandlers (the two realtime closures verbatim).
- src/spark-config.ts split out of spark.ts (configureSpark/getSparkConfig/sparkDebugLog) so
  light consumers like the cache don't pull the Breez connection module into their graph;
  spark.ts re-exports it (web surface unchanged).
- web keeps the React layer: useAccountRepository/useAccountService wiring (deps objects),
  useAccountsCache, useAccountChangeHandlers (wraps the factory), and all 16 read hooks
  unchanged; account-hooks re-exports AccountsCache + accountsQueryOptions so its importers
  are untouched. The two route construction sites switch to the deps-object shape.
- NEW src/accounts/accounts-cache.test.ts locks the load-bearing behaviors: version-guard
  (older/equal/newer/append), balance write-guard (same-ref on no-op), structuralSharing
  (preserves expired accounts, new wins on collision).

typecheck, biome, build all green; 146 tests (sdk 13, web 84, utils 32, cashu 35); no direct
react/DOM in SDK src beyond the documented opensecret exception.

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

Pulled forward deliberately: the user wants fully-migrated domains validated against the real
end-state API before the pattern is replicated across more domains. The SDK now has its
composition root, and the accounts domain is consumed through it.

- src/sdk.ts: WalletSdkConfig + configureWalletSdk (absorbs the four scattered configure
  seams — opensecret configure, configureAgicashDb, configureSpark, setOperationMeasurer;
  server-safe: it only records config, connections stay lazy) + the WalletSdk class
  (sdk.queryClient + sdk.accounts = {repository, service, cache, listOptions(userId),
  changeHandlers}) + getSdk() (client-only lazy browser singleton; throws on the server and
  before configuration).
- Lazy Encryption (createLazyEncryption): methods resolve the keys through the SDK's key
  queryOptions (staleTime Infinity) on first use, so the root can construct domains
  pre-login. Same Encryption interface; the repository is unchanged; the protected-route
  prewarms keep key-fetch timing identical. Consumers no longer suspend on key queries via
  useEncryption inside useAccountRepository — the keys resolve inside the first decrypt
  instead (and are prewarmed by the _protected middleware before any account read).
- src/spark-config.ts gains setSparkDebugLogging — an order-independent debug-gate binding,
  needed because the web's feature-flag module reads the DB client and would create an
  import cycle if the SDK-configuration entrypoint imported it.
- web features/shared/sdk.ts (new): the single env-derived configureWalletSdk call (incl.
  the dev-LAN supabase URL rewrite) + getSdk re-export. Evaluates on the server too (via the
  spark/lightning-address chain), which also restores Sentry spans for SDK operations on the
  server lnurlp path (gap from the 4a instrumentation seam).
- entry.client.tsx drops the opensecret configure + measurer block for a side-effect import
  of shared/sdk; database.client.ts drops env reading and just re-exports getAgicashDb()
  after importing shared/sdk; shared/spark.ts drops env/configureSpark and wires the
  DEBUG_LOGGING_SPARK gate via setSparkDebugLogging.
- The accounts wiring collapses onto the root: useAccountRepository/useAccountService/
  useAccountsCache/useAccountChangeHandlers are one-liners over getSdk().accounts.*;
  useAccounts consumes sdk.accounts.listOptions(user.id); useAccountService loses its
  queryClient param (single caller updated); the two route construction sites
  (_protected.tsx middleware, receive.cashu_.token clientLoader) read
  getSdk().accounts.repository/.service instead of constructing.
- sdk.test.ts: getSdk server-context throw + lazy-encryption roundtrip through a seeded
  query cache.

typecheck x6, biome, build green; 148 tests (sdk 15).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vice/cache become internal

App code must interact with accounts only through SDK interface methods — the internal
layering (repository = DB access, service = domain logic, cache = read-model writes) is no
longer reachable as the public surface.

- sdk.accounts public API: listOptions(userId), get(id) (DB fetch incl. expired accounts,
  write-through into the accounts state), getCached(id), listCached(), add(params) (create
  cashu account + immediate record — absorbs useAddCashuAccount's onSuccess upsert with the
  same ordering).
- sdk.accounts.internal = { repository, service, cache, changeHandlers } — a documented,
  grep-able transitional escape hatch ONLY for (a) not-yet-migrated SDK collaborators still
  composed in web code (user/receive/send repositories+services, the claim flow, the
  _protected user bootstrap) and (b) the web-owned realtime + spark-balance infrastructure
  until the SDK owns realtime. It shrinks each phase.
- web cutover of every app-code touchpoint to curated methods:
  - useAccountOrNull: cache-miss fetch+upsert -> sdk.accounts.getCached/get
  - useAddCashuAccount: -> sdk.accounts.add (mutation wrapper unchanged)
  - send-provider getAccounts + the send-route loader account matching -> listCached()
  - usePendingMeltQuotes account lookup -> getCached()
- useAccountService deleted (no consumers left); useAccountRepository/useAccountsCache/
  useAccountChangeHandlers remain but now return internal.* with transitional JSDoc, used
  only by stranded domains and realtime infra.
- verified: git grep accounts.internal shows only the sanctioned transitional sites.

typecheck x6, biome, build green; 148 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Status document for the extraction: settled architecture decisions, the per-chunk working
method and gates, the full phase/branch/commit ledger, the remaining roadmap (user domain ->
checkpoint -> remaining domains -> realtime hub -> auth shell -> import cleanup -> MCP
boundary), and the landmines/nuances future work must not rediscover. Updated at the end of
each phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Moves User, FullUser, GuestUser, UserProfile, UpdateUser, and the three
predicate functions into the SDK under packages/wallet-sdk/src/user/user.ts.

Replaces the structural UserDefaultAccounts workaround in AccountService
with the real User type now that it lives in the SDK.

Web user.ts becomes a transitional re-export shim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…DK (Phase 3.2)

Adds packages/wallet-sdk/src/user/{user-repository,user-service,user-cache}.ts
with ReadUserRepository, WriteUserRepository, ReadUserDefaultAccountRepository,
UserService, UserCache, userQueryOptions, and createUserChangeHandlers — all
framework-free. ReadUserDefaultAccountRepository keeps its positional ctor: its
only consumer is the server-side lightning-address path which constructs it
per-request with the server db and env mnemonic.

Web shims at features/user/{user-repository,user-service} re-export the SDK
classes and keep the React hook wrappers for one more chunk. user-hooks.tsx
imports UserCache and createUserChangeHandlers from the SDK instead of
defining them locally; the cache-read helpers stay local until the curated
surface lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… touchpoints (Phase 3.3)

Adds UserApi to WalletSdk: queryOptions, getCached/getCachedOrThrow, upsert
(records user + accounts in both in-memory states via AccountsCache.set),
update, setDefaultAccount — the cache write-backs that hooks previously did
in onSuccess are absorbed into the curated methods. internal escape hatch
carries {readRepository, writeRepository, service, cache, changeHandlers}
for the stranded receive collaborators and the web-owned realtime wiring.

ReadUserDefaultAccountRepository is intentionally NOT wired into the root:
its only consumer is the server-side lightning-address path, which
constructs it per-request with the server db client and the LNURL server
env mnemonic — a root instance would bind the logged-in user's mnemonic.

Web migrations to the curated surface:
- _protected.tsx ensureUserData: getSdk().user.getCached() + .upsert();
  no more direct repository construction or manual setQueryData calls
- useUser spreads getSdk().user.queryOptions() (local userQueryOptions and
  useReadUserRepository deleted); useUpdateUser/useSetDefaultAccount call
  sdk.user.update/.setDefaultAccount (useWriteUserRepository and
  useUserService deleted, mirroring the accounts-phase useAccountService
  removal)
- useUserCache/useUserChangeHandlers delegate to sdk.user.internal with
  transitional JSDoc, mirroring the accounts hooks
- accept-terms, verify-email, and the receive-token route use
  getSdk().user.getCachedOrThrow(); getUserFromCache(OrThrow) deleted
- receive-token route takes UserService from sdk.user.internal.service

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s caller policy (Phase 3.4)

Checkpoint review feedback: the SDK can't know whether a missing cached
user is exceptional (in the web's protected-layout guards it is; another
consumer may poll bootstrap state), and the accounts surface exposes no
orThrow variant either. The SDK keeps the single getCached(): User | null
primitive; web's getUserFromCacheOrThrow (user-hooks) wraps it with the
throw policy for the three protected-layout call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (Phase 3.5)

Checkpoint feedback: sdk.ts was ~300 lines and grows by a domain each
phase. Each domain now owns {domain}/{domain}-api.ts holding the *Api type
and a create*Api(deps) factory; sdk.ts shrinks to config + the composition
root (one create*Api call per domain).

createAccountsApi returns {api, repository, cache} so the root can wire
cross-domain deps (the user domain's WriteUserRepository and its upsert
accounts write-back) against the same instances without reaching through
the transitional `internal` escape hatch — the root must not depend on a
surface documented as removable.

createLazyEncryption moves to encryption.ts where its collaborators live;
package-root export is preserved via the existing barrel. AccountsApi and
UserApi stay exported from sdk.ts as type re-exports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…state (Phase 3.6)

Checkpoint feedback: the SDK is a single-user instance — the Supabase
client is RLS-scoped to the logged-in session, so any userId other than
the current user's cannot work. Requiring callers to pass identity the
SDK already holds was noise at best and a footgun at worst (a long-lived
query observer capturing a userId in a closure could pin a previous
session's id across an account switch; a call-time state read cannot).

- update(data) and setDefaultAccount(account) drop the userId/User params
  and resolve the current user from the user state, throwing if no user
  is loaded (precondition, documented with @throws)
- queryOptions() drops userId; userQueryOptions takes getUserId and
  resolves the id at fetch time, not at options creation
- upsert keeps id in its params: it is the identity injection point where
  the host's auth layer establishes who is logged in (documented)
- web: useUser passes nothing; useUpdateUser/useSetDefaultAccount no
  longer subscribe to the user query just to echo the SDK its own data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…user (Phase 3.7)

Same reasoning as 3.6 applied to the accounts surface: listOptions() and
add(account) no longer take caller-passed identity. AccountsApiDeps gains
getCurrentUserId — a thunk the composition root wires from
this.user.getCached(); a thunk because the accounts domain is constructed
before the user domain, and it is only invoked at query/call time after
the bootstrap upsert.

accountsQueryOptions takes getUserId resolved at fetch time (the
load-bearing structuralSharing is untouched; test call shape updated).
useAccounts/useAddCashuAccount stop subscribing to the user just to echo
its id back; the claim-token service keeps its explicit-user flow via
getUserId: () => user.id.

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

Checkpoint decision: auth and user are not merged. Phase 4's sdk.auth
becomes the identity source the root injects into the user domain
(auth → user → accounts), absorbing sdk.user.upsert's id param — the
last outside-passed identity after 3.6/3.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on-models to db-types (Phase 4.1)

DestinationDetailsSchema describes db-persisted jsonb data and was the
cross-package knot: the cashu-lightning-send json-model and the
transactions detail schemas need it, but db-types cannot import from the
send domain. It moves to @agicash/db-types/json-models/destination-details;
the send leaf re-exports it so send-domain imports are unchanged.

cashu-lightning-send-db-data.ts moves to db-types (the last entangled
json-model); web file is a shim. The orphaned account-details-db-data.ts
union (zero consumers) is deleted rather than moved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ns surface (Phase 4.2)

Moves transaction.ts (types/schemas + isTransactionReversable),
transaction-enums.ts, transaction-details/ (8 files), and
TransactionRepository into packages/wallet-sdk/src/transactions —
verbatim, import remaps only (json-models → @agicash/db-types subpaths,
Money → @agicash/utils). TransactionsCache + createTransactionChangeHandlers
extracted from transaction-hooks (version-guarded upsert + history-page
acknowledge absorbed as cache.acknowledgeInHistory); test-locked (4 tests).

Curated surface: queryOptions(id) (NotFoundError + retry semantics in the
SDK), listOptions(accountId?) (infinite query, page-size 25, per-id
write-through), pendingAckCountOptions() (primitive count — web derives
the boolean), acknowledge(tx) (absorbs the onSuccess cache updates).
internal = {repository, cache, changeHandlers} for receive/send hooks and
realtime wiring. Root reuses one lazy Encryption + one getCurrentUserId
across domains.

Web: transaction-hooks.ts delegates to the curated surface;
useTransactionRepository deleted; useReverseTransaction stays web-wired
(send-domain services) until Phase 7. transaction.ts/-enums/-repository
become shims. UI components and the ack-status store stay web.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pmilic021 and others added 26 commits June 16, 2026 14:47
…ider (async) — headless-ready

auth.ts no longer touches window.localStorage. It reads the session tokens
through the provider the host passed to configure({ storage }) — reached via
getConfig().storage.persistent, the single source of truth that also serves the
two SDK-internal isLoggedIn() callers (supabase-session, agicash-mint-auth).

The reads are async (await), so both synchronous (browser localStorage) and
asynchronous (keychain, file, remote secret store — i.e. a Node/MCP host) stores
work; the earlier sync-only constraint is gone. isLoggedIn / getSessionExpiresInMs
/ clearTokens are now async; the two SDK queryFns await isLoggedIn().

Web useHandleSessionExpiry resolves the now-async expiry into state and re-arms
explicitly after a guest extension. The previous re-arm leaned on OpenSecret's
React setState, which the 1.0 (react-free) bump removed, so the explicit re-read
is both required and more robust.

auth.test.ts (new, 4 tests) locks the contract: isLoggedIn reads through the
configured provider and supports an asynchronous store.

Verified: typecheck (6 packages), biome, 156 wallet-sdk tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er imports @agicash/opensecret

The web reached @agicash/opensecret directly in 6 places. The auth actions now
live on the curated sdk.auth surface: signUp, signIn, signInGuest, signUpGuest,
signOut, requestPasswordReset, confirmPasswordReset, verifyEmail,
requestNewVerificationCode, convertGuestToFullAccount, initiateGoogleAuth,
handleGoogleCallback.

Each is a thin async wrapper over OpenSecret plus the one portable bit
(password-reset secret hashing). Navigation, Sentry, query-cache clearing,
auth-state invalidation, guest-account storage, and the browser OAuth URL/session
glue stay web-side, so behavior is unchanged — only the call source moved. This
makes the actions reusable by a headless/MCP host, which cannot call React hooks.

browserStorage is re-exported from the SDK so the web configures storage without
importing opensecret. With every call rerouted, @agicash/opensecret is dropped
from apps/web-wallet/package.json: the web now reaches the auth backend only
through @agicash/wallet-sdk. Also deletes the dead useCryptography hook (zero
consumers).

Verified: typecheck (6 packages), biome, 156 wallet-sdk + 52 web tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…urface

Post-extraction surface cleanup from reviewing the branch. Three related
changes:

- useSdk(): add a web-side hook for SDK access in React render context and
  convert the render-time getSdk() call sites to it; getSdk() stays for
  loaders, query/mutation fns, effects, and module scope (where a hook can't
  run). Move the client-only (isServer) guard out of the framework-free SDK
  into useSdk() — whether a runtime may construct the SDK is host policy, so
  getSdk() is now host-agnostic and a headless host can call it server-side.

- Encapsulation: services, repositories, and caches are no longer importable
  from outside the SDK. Relocate the pure derivations (getExtendedAccounts,
  isDefaultAccount, getDefaultReceiveAccount) and the consumed quote/account
  types (CashuLightningQuote, SendQuoteRequest, CashuSwapQuote,
  SparkLightningQuote, TransferQuote, NewCashuAccount) into leaf modules;
  remove the five *-service subpath exports from package.json; drop the
  repo/service/cache re-exports from the barrel. The server lnurl path keeps a
  dedicated ./user/user-repository subpath (it has no sdk instance).

- Delete hooks orphaned by the tasks-engine extraction:
  useGetCashuAccountByMintUrlAndCurrency, useGetSparkAccount,
  useSelectItemsWithOnlineAccount.

typecheck, biome, unit tests (284), and the SSR/prerender build are green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The task processors and trackers were lifted from React hooks, and their JSDoc
described the code in terms of those hooks — "headless reproduction of the
web's useProcessCashuReceiveQuoteTasks", "mirrors the web's
useSelectItemsWithOnlineAccount", "the same options the web's useQuery used",
even JSX like {isLead && <TaskProcessor/>}. A framework-free SDK must not
reference the web app's hooks or React, and several references pointed at hooks
that no longer exist.

Rewrite the comments to describe behavior on its own terms, keeping the real
content: the query-core primitives the SDK actually uses (QueryObserver,
MutationObserver), retry policies, NUT-17 WS + polling fallback, expiry-timer
rationale, the nutshell #788 workaround, and the latent scope-id bug notes.
Also fix the now-false createTasksApi JSDoc that claimed families still run in
a web TaskProcessor gated on engine leadership — all families run in this
engine now.

Comments only; no code changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the sole accessor

The WalletSdk constructor was public over process-global state (the QueryClient,
the Agicash DB client + its RLS session token, the OpenSecret token store), so a
second `new WalletSdk()` would silently share one user's cache and session with
another — a cross-user leak. The SDK is single-user by design (RLS-scoped,
derives the current user from its own state), so one instance == one user.

Make the constructor private and expose `WalletSdk.getInstance()` as the sole
accessor (reads the recorded config, throws if unconfigured, builds at most one
instance). `new WalletSdk()` is now a compile error. Fold the old free
`getSdk()` (just a config-check + delegate) into getInstance; the web keeps a
one-line `getSdk` alias next to `useSdk` so its call sites are unchanged.

Multi-instance (a multi-tenant headless host) becomes safe only once those
resources are instance-owned (constructor-injected); a comment marks that as
MCP-phase work, and the guard lifts together with it.

typecheck, biome, and wallet-sdk tests (156) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pluck the pure, framework-free input-handling logic the web's scan/send flows
used into the SDK, where the MCP/agent wallet will reuse it:

- classifyInput (raw string -> cashu-token-receive / bolt11-send /
  ln-address-send) -> @agicash/wallet-sdk/scan. The import.meta.env.MODE read
  became an `allowLocalhost` option (web passes import.meta.env.DEV).
- buildLightningAddressFormatValidator (pure LUD-16 format regex) ->
  @agicash/wallet-sdk/lightning-address. Web ~/lib/lnurl re-exports it so the
  send-form validators are unchanged.
- validateBolt11 + ValidateResult (network/expiry/amount checks) ->
  @agicash/wallet-sdk/send/validation. Its sibling validateLightningAddressFormat
  stays web — it is only the SDK validator pre-bound with the app's message and
  dev-localhost flag (web glue). Web features/send/validation.ts re-exports
  validateBolt11 so resolve-destination and the scan route are unchanged.

All exposed as pure subpath exports (not sdk.* methods): they need no
user/connection/cache, so any caller can use them without bootstrapping the SDK.
The web scan feature dir is deleted; its test moved with the code.

typecheck, biome, and the unit suite (284) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Post-Phase-10 addendum" to the extraction doc covering everything after
the Phase-10 tip on sdk/phase12-tasks-engine: the tasks engine, then the
surface-hardening pass (useSdk + host-agnostic getSdk, the WalletSdk hard
singleton, the now-structural curated surface, dead-hook + JSDoc cleanup, and
the classifyInput/validateBolt11/lightning-address plucks). Records the two
deferred-and-settled decisions with rationale (wallet-core extraction; Money
stays in @agicash/utils) and the next-candidate cluster (the LNURL client +
resolve-destination). Marks the Phase-10 Final report as a historical snapshot
and points forward to the addendum as the current state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…domain

The throw-on-missing "current user" resolver was defined twice with identical
bodies and error strings: once in the WalletSdk composition root and once
privately inside createUserApi. The user domain owns the cache, so it now owns
the resolver — createUserApi exposes getCurrentUser on its factory return and
the root's shared identity thunk delegates to it instead of re-implementing it.

The public surface is unchanged: sdk.user.getCached() stays User | null
(throw-or-not remains caller policy); the throwing variant is exposed only on
the factory return, off the *Api surface, the same way accounts exposes its
repository/cache to the root.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dk/lnurl)

Move the client lightning-address resolver out of the web app into the SDK so a
headless wallet can resolve a lightning address to an invoice without the
browser. getInvoiceFromLud16 / getLNURLPayParams / isValidLightningAddress /
isLNURLError / buildLightningAddressValidator and the LNURL protocol types were
web-only (~/lib/lnurl); nothing in the SDK used them, so send-to-lightning-
address resolution happened web-side before handing a bolt11 to sdk.send — the
last thing keeping that path from running headless (the MCP wallet needs it).

Byte-identical move + import remap:
- ~/lib/lnurl/index.ts  -> packages/wallet-sdk/src/lnurl.ts
- ~/lib/lnurl/types.ts  -> packages/wallet-sdk/src/lnurl-types.ts
New package exports ./lnurl + ./lnurl-types. The helpers are stateless,
framework-free (ky + Money + the in-SDK format validator) and need no session,
so they stay plain module exports — not on the getSdk() singleton.

Rewired 5 consumers: 4 send-side (resolve-destination, send-input, validation,
use-get-invoice-from-lud16) and the server LightningAddressService's LNURL-type
imports. The server lnurlp path itself stays in web for now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uery

The auth-state query is the SDK's own cache, but every web caller had to pair
each sdk.auth mutation with a manual invalidate — the lone domain that didn't
absorb its own cache write-back. A headless consumer would get stale auth state
unless it replicated the web's invalidate dance.

Move it inward: signUp / signIn / signInGuest / signUpGuest / signOut /
verifyEmail / convertGuestToFullAccount / handleGoogleCallback now await
invalidateAuthState() after the OpenSecret call (refetchType 'all', so the
awaited mutation still waits for the refetch — behavior preserved). The
non-state-changing methods (requestPasswordReset / confirmPasswordReset /
requestNewVerificationCode / initiateGoogleAuth) are untouched, matching what
the web paired with an invalidate.

Web: the invalidateAuthQueries wrapper dropped its now-redundant
sdk.auth.invalidate() call (it would otherwise double-invalidate) and is renamed
invalidateFeatureFlags — it now only re-evaluates the web-owned feature flags on
auth change. This folds into the SDK when feature flags move (next chunk).

sdk.auth.invalidate is kept as a force-refresh primitive (now zero-consumer in
the web).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hange

Feature flags were the last web-side consumer of the client Supabase handle
(agicashDbClient) and the reason the web kept an invalidate-on-auth-change
wrapper. Move the data layer into the SDK and let auth drive the refresh, so
the web wrapper and the client db handle both disappear — and a headless wallet
can read flags without the browser.

SDK:
- new sdk.featureFlags (options / get / invalidate) on the injected db +
  captureException seam; the evaluate_feature_flags RPC, safe-off defaults, and
  retry/backoff move verbatim. Session-independent (works anon), so it's a
  standalone surface, not a curated single-user domain. +4 tests lock the
  outage -> defaults fallback.
- auth gains an onSessionChange dep; invalidateAuthState fires it after the
  auth-state refetch. The root wires onSessionChange -> featureFlags.invalidate,
  so the 8 state-changing mutations now refresh flags too, with auth staying
  unaware of the feature-flags domain.
- the DEBUG_LOGGING_SPARK -> setSparkDebugLogging seam moves into the root (was
  a web wiring that existed only to dodge an import cycle).

Web:
- shared/feature-flags.ts shrinks to the useFeatureFlag binding, which reads
  getSdk().featureFlags.options() directly. Safe because every flag consumer
  (the auth routes and the cashu-token route) forces client rendering via
  clientLoader.hydrate, so it never runs during SSR — unlike useAuthState,
  whose authQueryOptions defers getSdk() for the public pages built server-side.
- invalidateFeatureFlags + its 3 call sites deleted (auth self-handles it).
- database.client.ts deleted (agicashDbClient had no consumers left); its
  realtime debug handle relocated to entry.client; stale cycle comments fixed.

Gates: typecheck x6, full suite (wallet-sdk 177), SSR build incl. prerender;
SSR of /signup + /receive-cashu-token verified 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The guest auth operations were already in the SDK, but the recovery-credential
persistence (the {id, password} blob) and password generation lived in the web
(guest-account-storage.ts on raw localStorage; ~/lib/password-generator). That
left the guest flow un-runnable headless and stranded the last bits of the auth
domain in the web.

Move them in, reusing the StorageProvider the SDK auth already uses for tokens:
- @agicash/utils gains generateRandomPassword (window.crypto -> crypto,
  window.getMockPassword -> globalThis.getMockPassword so the e2e seam still
  works; +4 tests).
- sdk.auth.signUpGuest() is now no-arg restore-or-create: resume the persisted
  guest if present, else generate a password, create the guest, and persist its
  recovery credentials. Stored under the same 'guestAccount' key + shape the web
  used, so existing guest sessions carry over.
- sdk.auth.convertGuestToFullAccount clears the stored credentials on success
  (useless once the account is full) — absorbing the web's explicit clear.

Web: the signUpGuest wrapper collapses to sdk.auth.signUpGuest() + refreshSession;
the private signInGuest helper and the guestAccountStorage.clear() in the
guest->full upgrade are gone. guest-account-storage.ts and the web
password-generator are deleted; requestPasswordReset's secret now imports
generateRandomPassword from @agicash/utils.

Unblocks moving session-expiry handling into the SDK next.

Gates: typecheck x6, full suite (utils 45, wallet-sdk 177), SSR build incl.
prerender. e2e (guest signup) not run — it's covered by the getMockPassword
fixture; worth running before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…xpiry)

The session-expiry policy was React orchestration in the web's
useHandleSessionExpiry: read getSessionExpiresInMs, arm a long timeout, and on
expiry either extend a guest or sign out a full account (clearing tokens on
error). Now that the guest lifecycle lives in the SDK, that whole loop can too.

sdk.auth.watchSessionExpiry({ onExpire, onRecover }) returns a stop fn and owns
the mechanics: read the refresh-token expiry, arm a setLongTimeout, and on fire
either silently resume the guest session (and re-arm) or — for a full account —
fire onExpire so the host signs out and navigates; on an unrecoverable error it
clears the tokens and fires onRecover. Guest-vs-full is derived from the auth
state's user (no email = guest), so no caller passes it. signUpGuest's
restore-or-create body and the token clearing are extracted into shared helpers
the watcher reuses.

Web: useHandleSessionExpiry collapses to a watchSessionExpiry binding (callbacks
captured in refs so it arms once on mount); the isGuestAccount prop is gone
(wallet.tsx updated). onExpire = toast + signOut; onRecover = session-hint
cookie clear + reload — the only genuinely web-side bits. The orphaned
use-long-timeout.ts hook is deleted (the SDK uses @agicash/utils/timeout).

Headless-ready: a daemon drives watchSessionExpiry the same way the web does,
guests auto-extend, full-account expiry is host policy.

Gates: typecheck x6, full suite, SSR build incl. prerender. Session-expiry is
time-based and not unit-tested (faithful lift of the previously-untested hook);
worth a browser/e2e smoke before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
arm() checked `stopped` before the `await getJwt(...)` but not after, so a
stop() during that await — notably a StrictMode mount/unmount/remount, where
`timer` is still null and stop() therefore clears nothing — let arm() schedule
a timer after the watcher was stopped. The callback didn't consult `stopped`,
so the stranded timer would fire on a stopped watcher (double-fire alongside
the remount's timer). Re-check `stopped` after the await before arming, and
guard the timer callback so a stopped watcher never acts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The realtime channel + activity API already lived in the SDK; the one remaining
extractable piece of the tracking family was the spark-balance tracker's
reactivity. The web's useTrackAndUpdateSparkAccountBalances observed the online
spark accounts via useAccounts and called sdk.accounts.trackSparkBalances on
every change — React-owned reactivity over an SDK method.

Move it in: sdk.accounts.startSparkBalanceTracking() returns a stop fn and owns
an accounts QueryObserver (select = online, active spark accounts; reference-
equality gate) that re-tracks as the set changes — the same shape as the tasks
engine's work-set observers. trackSparkBalances becomes an internal helper; the
public surface is the lifecycle, not the per-list call.

Web: useTrackAndUpdateSparkAccountBalances collapses to a one-line
startSparkBalanceTracking() binding (no useAccounts). The 4 tasks/* doc
references to the now-internal accounts.trackSparkBalances point at the public
startSparkBalanceTracking.

The other two tracking hooks (useTrackWalletChanges, the realtime activity
tracking) stay web: they're the React-lifecycle + browser-event (navigator/
document) bindings a framework-free SDK is meant to have a host provide.

Gates: typecheck x6, full suite, SSR build incl. prerender.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ice)

The server lnurlp handler — look up a user by their lightning address, resolve
their default account, mint a cashu/spark receive quote on their behalf, and
report settlement — was wallet receive-domain logic living in the web app's
route layer with its own server repos. Move it into the SDK as a framework-free,
per-request-constructed class so the web routes and a future headless
lightning-address-hosting backend can share one implementation.

SDK:
- new @agicash/wallet-sdk/lightning-address-service: LightningAddressService +
  LightningAddressServiceConfig. Built from injected config — the env reads
  (LNURL_SERVER_SPARK_MNEMONIC / _ENCRYPTION_KEY), the service-role db, the
  per-request queryClient, the request origin (baseUrl), and the spark storage
  dir are all host-provided now (no process.env or Request in the SDK).
  @tanstack/react-query -> query-core.
- the 4 write-only server receive repos/services move to receive/ (colocated
  with their client siblings; verbatim + import remaps).
- xchacha20poly1305 -> @agicash/utils (next to ecies; utils already deps
  @noble/ciphers).

Web:
- new lightning-address-config.server.ts reads the LNURL_SERVER_* env and wires
  the service-role db / queryClient / origin into LightningAddressServiceConfig.
- the 3 lnurlp routes construct the SDK service from that config; no behavior
  change.

Gates: typecheck x6, full suite, SSR build incl. prerender; the server import
graph stays out of the client bundle. Live lnurlp endpoints not exercised here
— worth the lnurl-test skill / a manual hit before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the auth mutations moved their cache invalidation into the SDK, this
helper no longer refreshes the session — it only re-runs the current route (or
navigates) so React Router's loaders/guards re-evaluate against the auth state
the SDK already refreshed. Rename to match what it does, and add a comment for
the non-obvious "revalidate to trigger the guard redirect" intent.

Also fix the now-stale entry.client feature-flags comment (there's no anon-key
DB client anymore; the SDK re-evaluates flags on auth change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip the getSdk/useSdk convention: inside a component or hook, capture useSdk()
at the top and use it everywhere (including in handlers and effects), reserving
getSdk() for genuinely non-React code — loaders, actions, query/mutation
functions, plain functions, and module scope. useSdk() is the client-only,
render-guarded accessor, so using it at the hook top makes the client-only
assumption explicit.

Converted the three render-context consumers: useAuthActions (capture sdk, use
in every action handler), useHandleSessionExpiry (use in the watcher effect),
and useFeatureFlag. All their consumers sit on client-forced routes (auth /
protected / the cashu-token route), so the render-time SSR guard is safe —
verified by the prerender build.

Left on getSdk() (correctly — not React render): authQueryOptions' queryFn (an
options factory built during SSR on public pages), entry.client (module scope),
the route loaders/actions, and the plain helper functions
(getUserFromCacheOrThrow, ensureUserData, verify-email).

getSdk/useSdk JSDoc updated to state the preference.

Gates: typecheck x6, full suite, SSR build incl. prerender.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the seam: useSdk() returns the singleton today, but if we ever want
injectable-for-tests DI it can become a real context provider without changing
call sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…art()

Move the realtime subscribe lifecycle into the SDK. sdk.realtime.start() now
owns it: it observes the auth state (wired from the SDK root) and subscribes the
current user's wallet:{userId} channel, re-subscribing as the session changes
(login / logout / user switch) and tearing down the previous channel — the same
session-observer pattern the tasks engine and spark-balance tracker use. Returns
a stop fn; client-only; call once per host.

The auth user id is the wallet user id and the channel RLS is JWT-based
(realtime.topic() = 'wallet:' || auth.uid()), so subscribing on auth-resolve is
valid even before the user row is bootstrapped. Removed the public
subscribe/unsubscribe (only the web used them); getStatus / getError /
onStatusChange still read the current user's channel.

Web: useTrackWalletChanges drops the subscribe/unsubscribe + async-cleanup dance
(its StrictMode handling now lives in the SDK + the manager's ref-counting) — it
becomes useEffect(() => realtime.start()) plus the status read and the
error-boundary throw, which stay (irreducibly React).

A headless host now gets session-tied realtime from one start() call — including
correct re-subscribe on user switch, which the old getCurrentUserId-based
unsubscribe would have gotten wrong (it targeted the new topic, not the old).

Gates: typecheck x6, full suite, SSR build incl. prerender. The runtime channel
behavior (subscribe on login, re-subscribe on switch, error escalation) is not
exercised here — worth a browser smoke before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The engines the SDK's cache depends on — the realtime wallet channel, the
leader-elected task processor, and spark balance tracking — were three separate
host start() calls. Forget one and the SDK is silently stale (realtime is even
the write-back path for some mutations, so a forgotten start meant DB writes that
never reached the cache). Consolidate into one sdk.start() that starts all three
and returns a single stop, so a host can't forget a cog.

A JSDoc note records that it could be auto-started from the constructor if we see
the need — deferred because tasks and spark balance read the current user
eagerly and would first need the session-gating realtime already has.

Web: wallet.tsx replaces the three starts (useTrackWalletChanges' realtime.start,
the tasks start/stop effect, the spark-balance hook) with one
useEffect(() => sdk.start()). useTrackWalletChanges drops to just the
realtime-error boundary escalation; the activity feed stays. shared/spark.ts is
deleted (its lone hook folded into sdk.start()).

Gates: typecheck x6, full suite, SSR build incl. prerender.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After sdk.start() took over the realtime subscription, this hook no longer
tracks anything — it only reads the channel status and throws a terminal channel
error to the nearest error boundary. Rename it (and its file) to match what it
now does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that sdk.start() is the single entry point for the cache-correctness
engines, the per-engine start/stop methods it calls have no external callers —
leaving them public is just a footgun (a host could double-subscribe realtime,
leak a spark-balance tracker, or run a second task election).

- sdk.tasks: removed from the public surface entirely — nothing outside sdk.ts
  ever touched it. The engine is now a private field, so its
  start/stop/getStatus/onStatusChange/getError all come off the instance surface
  in one move; the dangling TasksApi re-export is dropped too.
- RealtimeApi.start: removed. createRealtimeApi now returns { api, start }; the
  root wires start into sdk.start() and exposes only api as sdk.realtime
  (status/error/online/active/__debugManager stay).
- AccountsApi.startSparkBalanceTracking: removed. Moved to the factory's
  existing { api, ... } return root; sdk.accounts keeps its data methods.

sdk.start() and its returned stop are unchanged in behavior — they now call the
captured private start fns instead of reaching through the public objects. The
realtime-api churn is mostly the start-body re-indent from un-nesting it out of
the returned object literal.

Gates: typecheck x6, full suite, SSR build incl. prerender.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cashu mint-socket trackers (proof-state, mint-quote, melt-quote) are
long-lived: activate/deactivate toggle across leader handoffs, but stop()
never closed the per-mint sockets. Unlike master (which unmounted the React
TaskProcessor and built a fresh manager on regain), the SDK reused the stale
socket via subset-dedup, so the mint never re-delivered current state on
reactivation.

For proof-state this could orphan a PENDING cashu send-swap: a deactivated-
but-still-socketed tab consumes the swap's edge-triggered "all spent" event
(dropping the completion and deleting the accumulator), and reactivation
never recovers. The quote trackers had a milder version (a follower-side
socket leak + a regain-recovery delay), saved from orphaning only by their
poll/expiry fallbacks.

- add unsubscribeAll() to the proof-state / mint-quote / melt-quote
  subscription managers (close sockets + clear state) and call it from each
  tracker's stop(), so reactivation re-subscribes fresh
- add a `stopped` guard to all three trackers so a subscribe mutation that is
  mid-retry when stop() fires cannot re-open a socket afterwards; reset on
  setQuotes/setSwaps
- spark trackers unchanged: they use Breez listeners (no mint sockets) and
  already resolve the in-flight registration before removing it

Tests: manager unsubscribeAll behavior (cashu) + a melt-quote-tracker
keystone locking the stop-teardown and the retry-after-stop guard +
processor-wiring assertions. cashu 39 pass, wallet-sdk 188 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The send flow's `resolveSendDestination` paralleled the scan flow's
`classifyInput`: both independently discriminated bolt11 vs lightning-address,
so a future parsing quirk would get fixed in one and not the other.

- move `resolveSendDestination` + `SendDestination` into
  packages/wallet-sdk/src/send/resolve-destination.ts, over the SDK primitives
  it already used (validateBolt11, isValidLightningAddress, the ln-address
  format validator, parseBolt11Invoice, parseCashuPaymentRequest, Contact);
  the web-bound `validateLightningAddressFormat` import becomes an
  `allowLocalhost` option
- the web resolve-destination.ts is now a thin shim that injects the app's
  dev-localhost allowance, so send-store / send-provider / the send route are
  unchanged
- redirect the scan route's validateBolt11 import to @agicash/wallet-sdk and
  delete the now-empty web send/validation.ts (its last consumer)

Behavior change: the SDK resolver lowercases the lightning-address before
format-validating (the format regex is lowercase-only), matching classifyInput.
The send path now accepts mixed-case addresses like `Alice@example.com` instead
of rejecting them, consistent with the scan path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…llable retry helper

The three cashu mint-socket trackers (proof-state, mint-quote, melt-quote) each
drove `subscriptionManager.subscribe()` through a query-core MutationObserver
purely to get retry-with-backoff. A query-core mutation can't be cancelled
mid-retry, so each tracker also carried a `stopped` flag to stop a retry that
resolves after stop() from re-opening a socket — the band-aid from the previous
commit.

- add @agicash/utils/retry: `retryWithBackoff(fn, { retries, signal, delayMs?,
  onError? })` with an abortable backoff (default exponential
  `min(1000 * 2 ** n, 30000)`, matching query-core's mutation default)
- each tracker now holds an AbortController instead of the MutationObserver +
  `stopped` flag; stop() aborts it, so an in-flight subscribe retry is genuinely
  cancelled (and a pending backoff is interrupted) rather than racing a guard.
  setQuotes/setSwaps mints a fresh controller after a stop
- `queryClient` is now unused in the proof-state and melt trackers (mint-quote
  keeps it for the poll observers) → dropped from their options and the four
  tracker construction sites
- spark trackers untouched (Breez listeners, no subscribe retry)

Tests: new retry.test.ts (success / retry-then-succeed / exhaust-and-throw /
abort-stops-retries / abort-interrupts-backoff / already-aborted); the
melt-quote-tracker item-2 test is rewritten around the abort and red-green
verified (fails if stop() does not abort). utils 52, wallet-sdk 188, cashu 39
pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 22, 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 Jun 22, 2026 9:42am

Request Review

@supabase

supabase Bot commented Jun 22, 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 ↗︎.

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