diff --git a/apps/web-wallet-e2e/e2e/fixtures/open-secret/fixture.ts b/apps/web-wallet-e2e/e2e/fixtures/open-secret/fixture.ts index 53097e4d0..ca8838561 100644 --- a/apps/web-wallet-e2e/e2e/fixtures/open-secret/fixture.ts +++ b/apps/web-wallet-e2e/e2e/fixtures/open-secret/fixture.ts @@ -1,7 +1,7 @@ import * as crypto from 'node:crypto'; +import { delay } from '@agicash/utils'; import { type Page, type Route, test as base, expect } from '@playwright/test'; import { decode } from '@stablelib/base64'; -import delay from '~/lib/delay'; import { session } from '../../mocks/open-secret'; import { openSecretEncryption } from './encryption'; diff --git a/apps/web-wallet-e2e/package.json b/apps/web-wallet-e2e/package.json index 6da43b215..3ab3aa495 100644 --- a/apps/web-wallet-e2e/package.json +++ b/apps/web-wallet-e2e/package.json @@ -8,6 +8,7 @@ }, "devDependencies": { "@agicash/opensecret": "catalog:", + "@agicash/utils": "workspace:*", "@playwright/test": "1.49.1", "@stablelib/base64": "catalog:", "@stablelib/chacha20poly1305": "catalog:", diff --git a/apps/web-wallet/app/features/accounts/account-hooks.ts b/apps/web-wallet/app/features/accounts/account-hooks.ts index 09c45c275..5d97692f3 100644 --- a/apps/web-wallet/app/features/accounts/account-hooks.ts +++ b/apps/web-wallet/app/features/accounts/account-hooks.ts @@ -1,20 +1,19 @@ import { type Currency, Money } from '@agicash/money'; -import type { - Account, - AccountPurpose, - AccountState, - AccountType, - CashuAccount, - ExtendedAccount, - SparkAccount, +import { + type Account, + type AccountPurpose, + type AccountState, + type AccountType, + type AddCashuAccountParams, + type CashuAccount, + type ExtendedAccount, + type SparkAccount, + getExtendedAccounts, } from '@agicash/wallet-sdk'; -import type { - AccountRepository, - AgicashDbAccountWithProofs, -} from '@agicash/wallet-sdk/temporary'; +import type { AgicashDbAccountWithProofs } from '@agicash/wallet-sdk/temporary'; import { - UserService, getAccountBalance, + getInternalAccountRepository, sparkDebugLog, } from '@agicash/wallet-sdk/temporary'; import { @@ -26,9 +25,8 @@ import { useSuspenseQuery, } from '@tanstack/react-query'; import { useCallback, useMemo, useRef } from 'react'; +import { sdk } from '~/features/shared/sdk.client'; import { useUser } from '../user/user-hooks'; -import { useAccountRepository } from './account-repository-hooks'; -import { useAccountService } from './account-service-hooks'; export class AccountsCache { public static Key = 'accounts'; @@ -117,13 +115,13 @@ export function useAccountsCache() { * Hook that returns an account change handlers. */ export function useAccountChangeHandlers() { - const accountRepository = useAccountRepository(); const accountCache = useAccountsCache(); return [ { event: 'ACCOUNT_CREATED', handleEvent: async (payload: AgicashDbAccountWithProofs) => { + const accountRepository = await getInternalAccountRepository(); const addedAccount = await accountRepository.toAccount(payload); accountCache.upsert(addedAccount); }, @@ -131,6 +129,7 @@ export function useAccountChangeHandlers() { { event: 'ACCOUNT_UPDATED', handleEvent: async (payload: AgicashDbAccountWithProofs) => { + const accountRepository = await getInternalAccountRepository(); const updatedAccount = await accountRepository.toAccount(payload); accountCache.upsert(updatedAccount); }, @@ -138,13 +137,10 @@ export function useAccountChangeHandlers() { ]; } -export const accountsQueryOptions = ({ - userId, - accountRepository, -}: { userId: string; accountRepository: AccountRepository }) => { +export const accountsQueryOptions = () => { return queryOptions({ queryKey: [AccountsCache.Key], - queryFn: () => accountRepository.getAllActive(userId), + queryFn: () => sdk.accounts.list(), staleTime: Number.POSITIVE_INFINITY, // Refetches use `getAllActive`, so any expired account previously in the // cache (lazy-fetched via useAccountOrNull, or just expired before the @@ -253,18 +249,17 @@ export function useAccounts< select?: UseAccountsSelect, ): UseSuspenseQueryResult[]> { const user = useUser(); - const accountRepository = useAccountRepository(); const { currency, type, isOnline, purpose, state = 'active' } = select ?? {}; return useSuspenseQuery({ - ...accountsQueryOptions({ userId: user.id, accountRepository }), + ...accountsQueryOptions(), refetchOnWindowFocus: 'always', refetchOnReconnect: 'always', select: useCallback( (data: Account[]) => { const allowedStates = Array.isArray(state) ? state : [state]; - const extendedData = UserService.getExtendedAccounts(user, data); + const extendedData = getExtendedAccounts(user, data); const filteredData = extendedData.filter((account) => { if (!allowedStates.includes(account.state)) { @@ -330,14 +325,13 @@ const ALL_ACCOUNT_STATES: AccountState[] = ['active', 'expired']; */ export function useAccountOrNull(id: string | null): Account | null { const accountsCache = useAccountsCache(); - const accountRepository = useAccountRepository(); const { data: accounts } = useAccounts({ state: ALL_ACCOUNT_STATES }); useSuspenseQuery({ queryKey: ['fetch-account-by-id', id], queryFn: async () => { if (!id || accountsCache.get(id)) return null; - const fetched = await accountRepository.get(id); + const fetched = await sdk.accounts.get(id); if (fetched) accountsCache.upsert(fetched); return null; }, @@ -468,14 +462,11 @@ export function useAccountOrDefault(accountId: string | null) { } export function useAddCashuAccount() { - const userId = useUser((x) => x.id); const accountCache = useAccountsCache(); - const accountService = useAccountService(); const { mutateAsync } = useMutation({ - mutationFn: async ( - account: Parameters[0]['account'], - ) => accountService.addCashuAccount({ userId, account }), + mutationFn: (params: AddCashuAccountParams) => + sdk.accounts.cashu.add(params), onSuccess: (account) => { // We add the account as soon as it is created so that it is available in the cache immediately. // This is important when using other hooks that are trying to use the account immediately after it is created. diff --git a/apps/web-wallet/app/features/accounts/account-service-hooks.ts b/apps/web-wallet/app/features/accounts/account-service-hooks.ts deleted file mode 100644 index 307159cb4..000000000 --- a/apps/web-wallet/app/features/accounts/account-service-hooks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AccountService } from '@agicash/wallet-sdk/temporary'; -import { useAccountRepository } from './account-repository-hooks'; - -export function useAccountService() { - const accountRepository = useAccountRepository(); - return new AccountService(accountRepository); -} diff --git a/apps/web-wallet/app/features/gift-cards/add-gift-card.tsx b/apps/web-wallet/app/features/gift-cards/add-gift-card.tsx index 60fc554e5..5cd7e7eaa 100644 --- a/apps/web-wallet/app/features/gift-cards/add-gift-card.tsx +++ b/apps/web-wallet/app/features/gift-cards/add-gift-card.tsx @@ -39,7 +39,6 @@ function useAddGiftCard() { name, currency, mintUrl: url, - type: 'cashu', purpose: 'gift-card', }); } diff --git a/apps/web-wallet/app/features/accounts/account-repository-hooks.ts b/apps/web-wallet/app/features/receive/account-repository-hooks.ts similarity index 100% rename from apps/web-wallet/app/features/accounts/account-repository-hooks.ts rename to apps/web-wallet/app/features/receive/account-repository-hooks.ts diff --git a/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts b/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts index ad7f9276d..1a8f086ed 100644 --- a/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts +++ b/apps/web-wallet/app/features/receive/cashu-receive-quote-hooks.ts @@ -9,6 +9,7 @@ import { type LongTimeout, clearLongTimeout, setLongTimeout, + withRetry, } from '@agicash/utils'; import type { CashuAccount, @@ -40,18 +41,17 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useOnMeltQuoteStateChange } from '~/lib/cashu/melt-quote-subscription'; import { MintQuoteSubscriptionManager } from '~/lib/cashu/mint-quote-subscription-manager'; import { useLatest } from '~/lib/use-latest'; -import { withRetry } from '~/lib/with-retry'; import { useGetCashuAccount, useGetCashuAccountByMintUrlAndCurrency, useSelectItemsWithOnlineAccount, } from '../accounts/account-hooks'; -import { useAccountRepository } from '../accounts/account-repository-hooks'; import { agicashDbClient } from '../agicash-db/database.client'; import { useCashuCryptography } from '../shared/cashu-hooks'; import { useEncryption } from '../shared/encryption-hooks'; import { useTransactionsCache } from '../transactions/transaction-hooks'; import { useUser } from '../user/user-hooks'; +import { useAccountRepository } from './account-repository-hooks'; type CreateProps = { account: CashuAccount; diff --git a/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts b/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts index 3d07fdcaf..faf490de9 100644 --- a/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts +++ b/apps/web-wallet/app/features/receive/cashu-receive-swap-hooks.ts @@ -17,10 +17,10 @@ import { useGetCashuAccount, useSelectItemsWithOnlineAccount, } from '../accounts/account-hooks'; -import { useAccountRepository } from '../accounts/account-repository-hooks'; import { agicashDbClient } from '../agicash-db/database.client'; import { useEncryption } from '../shared/encryption-hooks'; import { useUser } from '../user/user-hooks'; +import { useAccountRepository } from './account-repository-hooks'; type CreateProps = { token: Token; diff --git a/apps/web-wallet/app/features/settings/accounts/add-mint-form.tsx b/apps/web-wallet/app/features/settings/accounts/add-mint-form.tsx index 4761db336..88b5f834b 100644 --- a/apps/web-wallet/app/features/settings/accounts/add-mint-form.tsx +++ b/apps/web-wallet/app/features/settings/accounts/add-mint-form.tsx @@ -100,7 +100,6 @@ export function AddMintForm() { name: data.name, currency: ACCOUNT_CURRENCY, mintUrl: data.mintUrl, - type: 'cashu', purpose, }); toast({ diff --git a/apps/web-wallet/app/features/shared/cashu-query-options.ts b/apps/web-wallet/app/features/shared/cashu-query-options.ts index 622e3cd25..c2ac9c7a8 100644 --- a/apps/web-wallet/app/features/shared/cashu-query-options.ts +++ b/apps/web-wallet/app/features/shared/cashu-query-options.ts @@ -3,7 +3,7 @@ import { deriveCashuXpub, getAllMintKeysets, getCashuPrivateKey, - getCashuSeed, + getInternalSessionKeys, getMintInfo, } from '@agicash/wallet-sdk/temporary'; import { type QueryClient, queryOptions } from '@tanstack/react-query'; @@ -11,7 +11,7 @@ import { type QueryClient, queryOptions } from '@tanstack/react-query'; export const seedQueryOptions = () => queryOptions({ queryKey: ['cashu-seed'], - queryFn: () => getCashuSeed(), + queryFn: () => getInternalSessionKeys().getCashuSeed(), staleTime: Number.POSITIVE_INFINITY, }); diff --git a/apps/web-wallet/app/features/shared/encryption-hooks.ts b/apps/web-wallet/app/features/shared/encryption-hooks.ts index ad53c17de..53dc01e64 100644 --- a/apps/web-wallet/app/features/shared/encryption-hooks.ts +++ b/apps/web-wallet/app/features/shared/encryption-hooks.ts @@ -1,43 +1,14 @@ -import { getPrivateKeyBytes, getPublicKey } from '@agicash/opensecret'; import type { Encryption } from '@agicash/wallet-sdk/temporary'; -import { getEncryption } from '@agicash/wallet-sdk/temporary'; -import { hexToBytes } from '@noble/hashes/utils'; +import { getInternalSessionKeys } from '@agicash/wallet-sdk/temporary'; import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; -// 10111099 is 'enc' (for encryption) in ascii -const encryptionKeyDerivationPath = `m/10111099'/0'`; - -export const encryptionPrivateKeyQueryOptions = () => +export const encryptionQueryOptions = () => queryOptions({ - queryKey: ['encryption-private-key'], - queryFn: () => - getPrivateKeyBytes({ - private_key_derivation_path: encryptionKeyDerivationPath, - }).then((response) => hexToBytes(response.private_key)), + queryKey: ['encryption'], + queryFn: () => getInternalSessionKeys().getEncryption(), staleTime: Number.POSITIVE_INFINITY, }); -export const useEncryptionPrivateKey = () => { - const { data } = useSuspenseQuery(encryptionPrivateKeyQueryOptions()); - return data; -}; - -export const encryptionPublicKeyQueryOptions = () => - queryOptions({ - queryKey: ['encryption-public-key'], - queryFn: () => - getPublicKey('schnorr', { - private_key_derivation_path: encryptionKeyDerivationPath, - }).then((response) => response.public_key), - staleTime: Number.POSITIVE_INFINITY, - }); - -export const useEncryptionPublicKeyHex = () => { - const { data } = useSuspenseQuery(encryptionPublicKeyQueryOptions()); - return data; -}; - /** * Hook that provides the encryption functions. * Reference of the returned data is stable and doesn't change between renders. @@ -48,11 +19,6 @@ export const useEncryptionPublicKeyHex = () => { * @returns The encryption functions. */ export const useEncryption = (): Encryption => { - const privateKey = useEncryptionPrivateKey(); - const publicKeyHex = useEncryptionPublicKeyHex(); - - return useMemo( - () => getEncryption(privateKey, publicKeyHex), - [privateKey, publicKeyHex], - ); + const { data } = useSuspenseQuery(encryptionQueryOptions()); + return data; }; diff --git a/apps/web-wallet/app/features/shared/spark-query-options.ts b/apps/web-wallet/app/features/shared/spark-query-options.ts index 92b53f7dd..df1d0aa89 100644 --- a/apps/web-wallet/app/features/shared/spark-query-options.ts +++ b/apps/web-wallet/app/features/shared/spark-query-options.ts @@ -1,26 +1,9 @@ -import type { SparkNetwork } from '@agicash/wallet-sdk'; -import { - getSparkIdentityPublicKeyFromMnemonic, - getSparkMnemonic, -} from '@agicash/wallet-sdk/temporary'; -import { type QueryClient, queryOptions } from '@tanstack/react-query'; +import { getInternalSessionKeys } from '@agicash/wallet-sdk/temporary'; +import { queryOptions } from '@tanstack/react-query'; export const sparkMnemonicQueryOptions = () => queryOptions({ queryKey: ['spark-mnemonic'], - queryFn: () => getSparkMnemonic(), + queryFn: () => getInternalSessionKeys().getSparkMnemonic(), staleTime: Number.POSITIVE_INFINITY, }); - -export const sparkIdentityPublicKeyQueryOptions = ({ - queryClient, - network, -}: { queryClient: QueryClient; network: SparkNetwork }) => - queryOptions({ - queryKey: ['spark-identity-public-key'], - queryFn: async () => - getSparkIdentityPublicKeyFromMnemonic( - await queryClient.fetchQuery(sparkMnemonicQueryOptions()), - network.toLowerCase() as 'mainnet' | 'regtest', - ), - }); diff --git a/apps/web-wallet/app/features/user/user-hooks.tsx b/apps/web-wallet/app/features/user/user-hooks.tsx index 693d97601..ae4ee0439 100644 --- a/apps/web-wallet/app/features/user/user-hooks.tsx +++ b/apps/web-wallet/app/features/user/user-hooks.tsx @@ -92,44 +92,6 @@ export const useUser = ( return data; }; -const isDevelopmentMode = import.meta.env.MODE === 'development'; - -export const defaultAccounts = [ - { - type: 'spark', - currency: 'BTC', - name: 'Bitcoin', - network: 'MAINNET', - isDefault: true, - purpose: 'transactional', - expiresAt: null, - }, - ...(isDevelopmentMode - ? ([ - { - type: 'cashu', - currency: 'BTC', - name: 'Testnut BTC', - mintUrl: 'https://testnut.cashu.space', - isTestMint: true, - isDefault: false, - purpose: 'transactional', - expiresAt: null, - }, - { - type: 'cashu', - currency: 'USD', - name: 'Testnut USD', - mintUrl: 'https://testnut.cashu.space', - isTestMint: true, - isDefault: true, - purpose: 'transactional', - expiresAt: null, - }, - ] as const) - : []), -] as const; - export const useUserRef = () => { const user = useUser(); return useLatest(user); diff --git a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx index 68d8af895..60db5fc59 100644 --- a/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx +++ b/apps/web-wallet/app/routes/_protected.receive.cashu_.token.tsx @@ -1,5 +1,6 @@ import { validateCashuToken } from '@agicash/cashu'; import type { Account, User } from '@agicash/wallet-sdk'; +import { isDefaultAccount } from '@agicash/wallet-sdk'; import { AccountRepository, AccountService, @@ -12,9 +13,7 @@ import { ReceiveCashuTokenService, SparkReceiveQuoteRepository, SparkReceiveQuoteService, - UserService, decodeCashuToken, - getEncryption, } from '@agicash/wallet-sdk/temporary'; import * as Sentry from '@sentry/react-router'; import type { QueryClient } from '@tanstack/react-query'; @@ -33,10 +32,7 @@ import { getCashuCryptography, seedQueryOptions, } from '~/features/shared/cashu-query-options'; -import { - encryptionPrivateKeyQueryOptions, - encryptionPublicKeyQueryOptions, -} from '~/features/shared/encryption-hooks'; +import { encryptionQueryOptions } from '~/features/shared/encryption-hooks'; import { getQueryClient } from '~/features/shared/query-client'; import { sdk } from '~/features/shared/sdk.client'; import { sparkMnemonicQueryOptions } from '~/features/shared/spark-query-options'; @@ -49,14 +45,12 @@ import { ReceiveCashuTokenSkeleton } from './receive-cashu-token-skeleton'; const getServices = async () => { const queryClient = getQueryClient(); - const [encryptionPrivateKey, encryptionPublicKey] = await Promise.all([ - queryClient.ensureQueryData(encryptionPrivateKeyQueryOptions()), - queryClient.ensureQueryData(encryptionPublicKeyQueryOptions()), - ]); const getCashuWalletSeed = () => queryClient.fetchQuery(seedQueryOptions()); const getSparkWalletMnemonic = () => queryClient.fetchQuery(sparkMnemonicQueryOptions()); - const encryption = getEncryption(encryptionPrivateKey, encryptionPublicKey); + const encryption = await queryClient.ensureQueryData( + encryptionQueryOptions(), + ); const accountRepository = new AccountRepository( agicashDbClient, encryption, @@ -99,7 +93,7 @@ const getServices = async () => { (ticker) => getExchangeRate(queryClient, ticker), ); - return { claimCashuTokenService, accountRepository }; + return { claimCashuTokenService }; }; /** @@ -115,7 +109,7 @@ async function trySetReceiveAccountAsDefault( ): Promise { if ( account.currency === user.defaultCurrency && - UserService.isDefaultAccount(user, account) + isDefaultAccount(user, account) ) { return; } @@ -167,11 +161,9 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { if (claimTo) { const user = getUserFromCacheOrThrow(); - const { claimCashuTokenService, accountRepository } = await getServices(); + const { claimCashuTokenService } = await getServices(); const queryClient = getQueryClient(); - const accounts = await queryClient.fetchQuery( - accountsQueryOptions({ userId: user.id, accountRepository }), - ); + const accounts = await queryClient.fetchQuery(accountsQueryOptions()); const result = await claimCashuTokenService.claimToken( user, diff --git a/apps/web-wallet/app/routes/_protected.tsx b/apps/web-wallet/app/routes/_protected.tsx index 5fb4e3cb7..fc633631c 100644 --- a/apps/web-wallet/app/routes/_protected.tsx +++ b/apps/web-wallet/app/routes/_protected.tsx @@ -1,47 +1,24 @@ -import type { User } from '@agicash/wallet-sdk'; +import type { AuthUser, User } from '@agicash/wallet-sdk'; import { shouldAcceptTerms } from '@agicash/wallet-sdk'; -import type { AuthUser } from '@agicash/wallet-sdk'; -import { - AccountRepository, - BASE_CASHU_LOCKING_DERIVATION_PATH, - UpsertUserRepository, - ensureBreezWasm, - getEncryption, -} from '@agicash/wallet-sdk/temporary'; +import { ensureBreezWasm } from '@agicash/wallet-sdk/temporary'; import type { QueryClient } from '@tanstack/react-query'; import { Outlet, redirect } from 'react-router'; -import { core } from 'zod/mini'; import { AccountsCache } from '~/features/accounts/account-hooks'; -import { agicashDbClient } from '~/features/agicash-db/database.client'; import { supabaseSessionTokenQuery } from '~/features/agicash-db/supabase-session'; import { LoadingScreen } from '~/features/loading/LoadingScreen'; -import { - seedQueryOptions as cashuSeedQueryOptions, - xpubQueryOptions, -} from '~/features/shared/cashu-query-options'; -import { - encryptionPrivateKeyQueryOptions, - encryptionPublicKeyQueryOptions, -} from '~/features/shared/encryption-hooks'; +import { seedQueryOptions } from '~/features/shared/cashu-query-options'; +import { encryptionQueryOptions } from '~/features/shared/encryption-hooks'; import { getQueryClient } from '~/features/shared/query-client'; -import { - sparkIdentityPublicKeyQueryOptions, - sparkMnemonicQueryOptions, -} from '~/features/shared/spark-query-options'; +import { sdk } from '~/features/shared/sdk.client'; +import { sparkMnemonicQueryOptions } from '~/features/shared/spark-query-options'; import { authQueryOptions, useAuthState } from '~/features/user/auth'; import { pendingGiftCardMintTermsStorage, pendingWalletTermsStorage, } from '~/features/user/pending-terms-storage'; import { requireSessionHintOrRedirect } from '~/features/user/require-session-hint.server'; -import { - UserCache, - defaultAccounts, - getUserFromCache, -} from '~/features/user/user-hooks'; +import { UserCache, getUserFromCache } from '~/features/user/user-hooks'; import { Wallet } from '~/features/wallet/wallet'; -import { breezApiKey } from '~/lib/breez'; -import { withRetry } from '~/lib/with-retry'; import type { Route } from './+types/_protected'; const shouldUserVerifyEmail = (user: AuthUser) => { @@ -85,64 +62,19 @@ const ensureUserData = async ( } if (!user || hasUserChanged(user, authUser)) { - const [ - encryptionPrivateKey, - encryptionPublicKey, - cashuLockingXpub, - sparkIdentityPublicKey, - ] = await Promise.all([ - queryClient.ensureQueryData(encryptionPrivateKeyQueryOptions()), - queryClient.ensureQueryData(encryptionPublicKeyQueryOptions()), - queryClient.ensureQueryData( - xpubQueryOptions({ - queryClient, - derivationPath: BASE_CASHU_LOCKING_DERIVATION_PATH, - }), - ), - // TODO: how to handle this network? We specify the network on the account creation. - queryClient.ensureQueryData( - sparkIdentityPublicKeyQueryOptions({ queryClient, network: 'MAINNET' }), - ), + // These warms populate the cache entries that the receive/send/claim + // repositories not yet migrated into the SDK still read, all sourced from + // the same derivation ensure() runs SDK-side. Removing a warm before its + // domain migrates into the SDK breaks that domain's bootstrap. + const [{ user: upsertedUser, accounts }] = await Promise.all([ + sdk.user.ensure({ + termsAcceptedAt, + giftCardMintTermsAcceptedAt, + }), + queryClient.ensureQueryData(encryptionQueryOptions()), queryClient.ensureQueryData(sparkMnemonicQueryOptions()), - queryClient.ensureQueryData(cashuSeedQueryOptions()), + queryClient.ensureQueryData(seedQueryOptions()), ]); - const encryption = getEncryption(encryptionPrivateKey, encryptionPublicKey); - const getCashuWalletSeed = () => - queryClient.fetchQuery(cashuSeedQueryOptions()); - const getSparkWalletMnemonic = () => - queryClient.fetchQuery(sparkMnemonicQueryOptions()); - const accountRepository = new AccountRepository( - agicashDbClient, - encryption, - getCashuWalletSeed, - getSparkWalletMnemonic, - { storageDir: './.spark-data', apiKey: breezApiKey }, - ); - const upsertUserRepository = new UpsertUserRepository( - agicashDbClient, - accountRepository, - ); - - const { user: upsertedUser, accounts } = await withRetry({ - fn: () => - upsertUserRepository.upsert({ - id: authUser.id, - email: authUser.email, - emailVerified: authUser.email_verified, - accounts: [...defaultAccounts], - cashuLockingXpub, - encryptionPublicKey, - sparkIdentityPublicKey, - termsAcceptedAt, - giftCardMintTermsAcceptedAt, - }), - retry: (attemptIndex, error) => { - if (error instanceof core.$ZodError) { - return false; - } - return attemptIndex < 2; - }, - }); user = upsertedUser; queryClient.setQueryData([UserCache.Key], user); queryClient.setQueryData([AccountsCache.Key], accounts); diff --git a/bun.lock b/bun.lock index b16b90afe..d6b8f0a61 100644 --- a/bun.lock +++ b/bun.lock @@ -103,6 +103,7 @@ "name": "web-wallet-e2e", "devDependencies": { "@agicash/opensecret": "catalog:", + "@agicash/utils": "workspace:*", "@playwright/test": "1.49.1", "@stablelib/base64": "catalog:", "@stablelib/chacha20poly1305": "catalog:", diff --git a/docs/superpowers/plans/2026-07-13-wallet-sdk-accounts-slice.md b/docs/superpowers/plans/2026-07-13-wallet-sdk-accounts-slice.md new file mode 100644 index 000000000..a47e01581 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-wallet-sdk-accounts-slice.md @@ -0,0 +1,197 @@ +# Wallet SDK Accounts Slice (Step 6) Implementation Plan + +Sources of truth: + +- `docs/superpowers/specs/2026-06-24-wallet-sdk-no-cache-production-design.md` — step 6: wrap the accounts domain in `AccountsApi` + flip the web's accounts imports off `/temporary`. +- `docs/superpowers/specs/2026-07-02-wallet-sdk-contract-proposal.md` — `AccountsApi` shape, projections, migration mapping. +- `docs/superpowers/plans/2026-07-09-wallet-sdk-auth-slice.md` — step-5 decisions (A1–A13) and its Deferred list, three items of which land here. +- Branch base: `sdk/accounts-slice` off `sdk/auth-slice` @937e23a9 (step 5, unmerged). Behavior baseline for parity is `master`. + +## Global Constraints + +1. **Behavior parity with `master`.** Where the port surfaces something improvable, port the master behavior as-is and flag the spot in the Decision Record for a now-vs-defer call. Nothing gets silently "fixed". +2. Web keeps TanStack Query + its realtime layer until step 18. The SDK owns no cache. +3. `userId` never crosses the public surface (contract decision #3); namespaces close over the session. +4. **Projection discipline per the B1 ruling:** the public types strip `wallet`/`proofs`/`keysetCounters` at the *type level* now; at runtime the objects stay fat during migration (hidden fields ride along) and the strip becomes physical at step 18. Hidden fields are reachable only through the sanctioned unwrap sites (B1.3); anywhere else is banned and grep-enforced. +5. One instance per process; namespace factories close over the SDK's own db client + key getters (step-5 `AgicashSdk` pattern). +6. **Single mapper choke point:** objects enter the `['accounts']` cache only through the shared domain→projection mapper (computes `balance`, keeps hidden fields). Cashu `balance` must be recomputed wherever proofs change — one entry point or it drifts. + +## Decision Record + +### B1 — Projection-typed cache over a runtime-fat migration representation (RESOLVED, maintainer 2026-07-13) + +Ruling: `sdk.accounts.*` does **not** sit test-only until step 18 — each slice moves real consumers toward end-state; step 18 keeps only what truly depends on it. + +1. **The `['accounts']` cache holds projection-typed objects, fetched via `sdk.accounts.list()`** — a production consumer today. At runtime the objects stay fat during migration: domain fields ride along hidden, plus computed `balance` attached. The strip is type-level until step 18, when it becomes physical and this arrangement ends. +2. **One shared mapper** (attach `balance`, keep hidden fields) is the only way objects enter the cache — queryFn, realtime row mapping, `add` onSuccess, `ensure` seed, claim upserts. Never hand-built elsewhere. +3. **`/temporary` bridge shrinks to:** the internal-repo accessor (unmigrated receive/send repo constructors + realtime row mapping) and a **`toDomainAccount()` checked cast** (asserts hidden fields present). The shared mapper is also `/temporary`-exported for the web-side cache-entry paths (realtime) until step 18. All domain access routes through the existing getter hooks (`useGetCashuAccount`, spark listeners, selector→store handoffs), which unwrap internally; touching hidden fields anywhere else is banned (self-review grep). +4. **Display consumers** read projections straight off the cache (`.balance` etc.). Root `Account` types flip to projections — the index.ts domain shadow for accounts types is deleted; code needing domain types imports them from `/temporary`. + +Invariant (stands): the bridge and `sdk.accounts.*` are two faces of **one** instance-internal repository — a single data path with a dual type-surface. At no point do two sources of truth exist. + +### B2 — `ensureUserData` → `sdk.user.ensure()` (RESOLVED, maintainer 2026-07-13) + +Ruling: `sdk.user.ensure(params): Promise<{ user: User; accounts: Account[] }>` — accounts projection-typed, mapped through the same shared mapper; the web seeds its cache from the **public return**. No bridge involvement in the seed; the signature is already the end-state one. User-side home confirmed. The timestamp params are **intentional** (replayed acceptance times from pending-terms storage) — kept, documented in JSDoc. Both sub-calls below are closed by this ruling; retained as the rationale trail. + +Master (`_protected.tsx:75–152`): derives 4 keys + warms seed/mnemonic, constructs `AccountRepository` + `WriteUserRepository`, calls `writeUserRepository.upsert({...authUser fields, accounts: defaultAccounts, ...pubkeys, terms}, accountRepository)` with Zod-aware retry, seeds **both** the user cache and the accounts cache from the returned `{ user, accounts }`. + +Key derivation, repositories, retry and the default-accounts constant move SDK-internal. The web middleware keeps: pending-terms storage reads, change-detection short-circuit semantics (the SDK upserts on every call — the host owns caching and call frequency), redirect logic, cache seeding. + +`ensureUserData` is cross-domain (user row + default accounts in one operation) — A1 parked it here because of the `AccountRepository` dependency; the verb reads user-side (`sdk.user.ensure`, recommended). + +**Open sub-call 1 — return shape:** under B1 the seed no longer needs the bridge: propose `ensure(params): Promise<{ user: User; accounts: Account[] }>` with accounts projection-typed (runtime-fat, through the shared mapper) — the web seeds both caches exactly as master does, zero extra fetch, contract-legal now. Alternative: `Promise` + a follow-up `sdk.accounts.list()` seed (+1 round-trip cold login). + +**Open sub-call 2 — terms param shape:** `EnsureUserParams = { termsAcceptedAt?, giftCardMintTermsAcceptedAt? }` carries *timestamps* (replayed from the web's pending-terms storage) while the existing `acceptTerms` takes *booleans* and stamps the time internally — two terms verbs, two philosophies on one `UserApi`. Intentional (bootstrap replays stored acceptance times) or harmonize? Maintainer call. + +### B3 — `AddCashuAccountParams` (RESOLVED — verified exact against the domain signature) + +```ts +export type AddCashuAccountParams = { + name: string; + mintUrl: string; + currency: Currency; + purpose: AccountPurpose; +}; +``` + +`type` implied by the rail-nested method; `userId` session-implicit — the `cashu.add` mapper re-injects both before the service call. Return `Promise` (projection-typed; fresh account `balance` = zero from empty proofs). + +### B4 — `useAddCashuAccount` flips now (RESOLVED by B1) + +The mutation calls `sdk.accounts.cashu.add()`; the projection-typed return is runtime-fat, so `onSuccess` upserts it into the cache through the shared mapper — master's immediate-availability semantics preserved, no extra read. (The cache's `version`-guarded upsert works unchanged: `version` is a public projection field.) + +### B5 — Statics and balance reads (updated per B1) + +- `UserService.getExtendedAccounts` / `isDefaultAccount` → root exports, re-typed over the **projection** types (pure fns over public fields — id/currency/default ids; no hidden-field access). `useAccounts` flips its import; root `ExtendedAccount` flips with the root-type flip (B1.4). +- Balance reads off the cache (`useBalance`, tiles, selectors) read the mapper-computed `.balance` field directly — same values and the same freshness as master's render-time `getAccountBalance` (both derive from the proofs of the object that last entered the cache). +- `getAccountBalance` is `/temporary`-exported, typed over *domain* accounts (a root export would re-leak domain types through its signature after the root-type flip); the mapper uses it SDK-internally. +- `ReadUserRepository.toUser` + realtime row mapping stay `/temporary` → step 18 (step-5 Deferred, unchanged). + +### B6 — `sparkDebugLog` inside the web's `AccountsCache` (RESOLVED, maintainer 2026-07-13) + +Ruling: neither root-export nor drop — the `/temporary` import **stays as a named exception**, same as the other spark consumers; it dies at step 18 with its call site. `/temporary` exists exactly for this. (`updateSparkAccountBalance` is an in-place field update on an existing cache entry — not a cache-entry path, so not mapper-gated.) + +### B7 — WASM posture (RESOLVED, maintainer 2026-07-13: master parity confirmed, good to go) + +The fat cache must carry live spark `wallet` handles during migration (unmigrated flows unwrap and use them), so `sdk.accounts.list()`/`get()` **do** construct wallets on the fetch path until step 18 — the earlier "reads never touch WASM" gate cannot hold during migration; it becomes the **step-18 end-state property** (physical strip ⇒ no wallet construction on reads). + +Migration-time acceptance instead = **master WASM-posture parity, byte-for-byte**: the web's `ensureBreezWasm` guards stay exactly where master has them (entry + `_protected` middleware before any accounts fetch), and `list()`/`get()`/`toAccount` preserve master's behavior under WASM-unavailable — including the offline/stub wallet path (`domain` spark accounts carry a throwing stub when not online). Build-time verify: what master's protected surface actually does under iOS Lockdown (stub-and-degrade vs throw) — `list()` must match it exactly, whatever it is. A3's login-page concern is untouched: auth surfaces fetch no accounts. + +## Accepted behavior deltas (candidates — settle with the remaining rulings) + +1. **Reality-class record — `sdk.accounts.*` is TYPE-honest / RUNTIME-fat until step 18** (B1 ruling: "the strip is type-level until step 18"). Public types understate the runtime objects during migration — intended, not incidental. Holds under three conditions, all tracked: (i) **web-internal consumers only** — no external/untrusted host consumes `sdk.accounts.*` before the physical strip, so the fat is reachable only by code already holding the proofs (to confirm with the maintainer alongside the open B points); (ii) time-boxed to step 18, where the strip becomes physical and this record closes; (iii) nobody claims runtime projection-honesty for accounts returns meanwhile. Contained by the mapper choke point, the checked-cast unwrap, and the hidden-fields grep. +2. **`balance` becomes a cache-entry-computed field** read by display consumers (B5): equal values/freshness to master's render-time compute; listed because the mechanism changes. +3. SDK-internal key getters memoize per session **generation-fenced** (cleared in `onSessionEnded` alongside the existing spark-wallet/mint-CAT clears) — same effective lifetime as master's infinity-stale TanStack entries dying with `queryClient.clear()` on sign-out; listed because the mechanism changes. +4. **Transitional key double-derivation** (review round): the SDK's session-keys memos and the web's key query entries both derive the same keys on cold login, concurrently — total latency unchanged, extra enclave calls until the consuming domains migrate (steps 8–16). The middleware warms the five web entries the unmigrated receive/send/claim repos still read (master's warm-cache and fail-in-middleware properties preserved; the sixth, spark-identity, was dropped with its dead query options — no readers remain). `ensure()`'s internal derivation batch runs under `withRetry`, standing in for the host query layer's transient-failure retries that served master. Partial-failure corner: a warm can fail after the concurrent idempotent upsert completed (master's sequential order prevented the upsert) — the middleware still rejects and nothing is seeded; the persisted pending-terms replay is the favorable direction vs. master's loss of the popped timestamps. + +## Deferred (tracked, out of scope) + +- **Physical projection strip + bridge/mapper deletion → step 18/19** (B1.1: "this whole arrangement ends"); the never-touch-WASM read property lands there too (B7). **Strip precondition:** every unwrap site (the getter hooks over `toDomainAccount()`) must already read `wallet`/`proofs` from the SDK instead of the cache *before* the strip lands — once the mapper stops carrying hidden fields, `toDomainAccount()` can no longer unwrap. Cheap to carry now, expensive to discover at 18. +- `useAccountChangeHandlers`' row mapping (`toAccount` + `AgicashDbAccountWithProofs` row types) stays bridge-served → step 18; its cache writes go through the shared mapper now. +- Web `encryption-hooks.ts` / `cashu-hooks.ts` / `spark-query-options.ts` remain for the *unmigrated* domains (transactions/receive/send construct their own repos until steps 8–16); accounts stops consuming them. +- `sdk.accounts.spark.add` — no addable spark rail today; contract already reserves the shape. +- WASM into `init()` → first Spark slice (A3, restated). +- SdkError wrapping for repo-thrown errors → step 17/19 (step-5 deferred, unchanged by this slice). +- Root types of **unmigrated** domains still embed domain accounts, wallet handles and proof material (`GetLightningQuoteParams`, `CreateQuoteBaseParams`, `ReceiveCashuTokenAccount`/`CashuAccountWithTokenFlags`, `CrossAccountReceiveQuotesResult`, the `Transfer*` types, quote/swap `proofs` fields) — baseline-inherited; they flip with their slices (steps 8–16). The step-18 strip must re-audit the whole root surface rather than trust the accounts-only cleanup above. + +## Scope map (import-by-import) + +| Today (`/temporary` or web-local) | After step 6 | +| --- | --- | +| `account-repository-hooks.ts` (web builds `AccountRepository` + encryption/seed getters) | **relocated to `features/receive/`** — its only consumers are the unmigrated receive repos, which construct synchronously (gate record); the accounts feature no longer imports it | +| `account-service-hooks.ts` (web builds `AccountService`) | **deleted** — service inside the SDK behind `accounts.cashu.add` | +| `accountsQueryOptions` queryFn `repository.getAllActive` | `sdk.accounts.list()` (projection-typed, mapper-fed); query key/staleTime/structuralSharing unchanged | +| `useAccountOrNull` lazy `repository.get` | `sdk.accounts.get(id)` + mapper-gated upsert | +| `useAddCashuAccount` → `service.addCashuAccount` | `sdk.accounts.cashu.add()` (B4) | +| `ensureUserData` in `_protected.tsx` | `sdk.user.ensure()` (B2) | +| Realtime `ACCOUNT_CREATED/UPDATED` → `repository.toAccount` → upsert | bridge repo accessor → `toAccount` → **shared mapper** → upsert | +| Root `Account`/`CashuAccount`/`SparkAccount`/`ExtendedAccount` = domain (index.ts shadow) | shadow **deleted** — root types are the projections; domain-type importers flip to `/temporary` | +| `useAccounts` → `UserService.getExtendedAccounts` | root export re-typed over projections (B5) | +| `useBalance`/display → `getAccountBalance(account)` | `.balance` off the cache (B5); `getAccountBalance` is `/temporary` for domain contexts | +| Money flows reading `wallet`/`proofs` off the cache | unchanged call sites via getter hooks, which unwrap through `/temporary` `toDomainAccount()` internally | +| `AccountsCache.updateSparkAccountBalance` → `sparkDebugLog` | unchanged — named `/temporary` exception, dies at 18 with its call site (B6) | +| `sdk/accounts.ts` `AddCashuAccountParams = unknown` | pinned (B3, resolved) | + +## Task outline + +1. **SDK-internal key plumbing** — encryption keypair (m/10111099'/0'), cashu seed, spark mnemonic, cashu locking xpub, spark identity pubkey: memoized getters over `@agicash/opensecret`, generation-fenced, cleared in `onSessionEnded`. +2. **Shared mapper + `createAccountsApi`** — the domain→projection mapper (attach `balance` via `getAccountBalance`, keep hidden fields; fresh types — domain `RedactedAccount` strips only `proofs` and must not be reused); factory wraps repository/service; `cashu.add` re-injects `type`+`userId`; wired into `AgicashSdk` (`Pick` grows `'accounts'`); `AddCashuAccountParams` lands in `sdk/accounts.ts`. +3. **`/temporary` bridge v2** — internal-repo accessor; **`toDomainAccount()` checked cast — the integrity linchpin of the fat-cache arrangement**: genuinely asserts the runtime object carries the hidden domain fields and throws a loud typed error naming the missing fields when handed a thin object. Never a bare `as`-cast — that would let a mapper bug flow a thin object into a money path expecting `.wallet`/`.proofs` and explode past the type checker. Mapper re-export; step-18 removal note on all three. +4. **`sdk.user.ensure()`** — port `ensureUserData` internals verbatim (keys, repos, Zod-aware retry, default-accounts constant; change detection stays host-side — `ensure()` upserts on every call, no SDK memo); return shape per B2 sub-call 1; web `_protected.tsx` flip with cache seeding through the mapper. +5. **Web flip sweep** — scope map rows: queryFn/lazy-get/add/realtime re-source; delete the two hook files; root shadow deletion + flip web domain-type importers to `/temporary`; display consumers to `.balance`; unwrap sites route through `toDomainAccount()` at the getter hooks and the named picker/route seams (gate record). **Glue-parity notes:** `_protected.tsx` keeps master's own structure around `sdk.user.ensure()` — the `getUserFromCache` + `hasUserChanged` short-circuit and the **conditional** cache seeding (seed only on the upsert branch; unconditional seeding would clobber a realtime-fresher cache with the ensure-returned bootstrap snapshot on route remounts) and the `!user → prefetchQuery(supabaseSessionTokenQuery())` warm-up; master's key warms run on the same branch, concurrent with `ensure()` (delta 4); `ensureBreezWasm` calls stay where master has them (B7). +6. **PR declarations** (declared questions, not changes): `accounts.get(id)` is not session-gated (master's repository posture — RLS scopes it; `list()`/`add` gate on the session for `userId`) while `user.*` gates every verb — parity kept, consistency question declared; `withRetry`/`delay` are ported into the SDK lib while the web copies remain for unmigrated consumers (transitional duplication, dies with their slices). +6. **Tests** — projections complete (type-level: no `wallet`/`proofs`/`keysetCounters` on public types; runtime: hidden fields present + `balance` correct through every SDK mapper-fed path, incl. `ensure`'s account mapping); checked-cast failure on a stripped object; params; ensure retry/no-memo; key-getter fencing across session end. Web-side cache-entry paths (realtime, claim, seed) are compile-enforced and grep-audited, not unit-run — the web suite has no accounts harness (master parity). WASM-posture parity is structural, not a test: the guards sit at master's exact sites and reads reuse the unchanged repository (B7). +7. **Verification** — `bun run fix:all` + `bun run typecheck` (workspace incl. web), unit suite, production build; **hidden-fields grep** (no `.proofs`/`.wallet`/`.keysetCounters` outside sanctioned unwrap sites + `/temporary` importers); browser smoke: cold login (user+accounts bootstrap), add mint, accounts settings pages, default-account switch, balances render, send/receive still work off the cache (unwrap path intact). +8. **PR** — base `master` after step 5 merges (two-green-PRs rule: rebase + re-verify against merged master pre-merge); title `feat(wallet-sdk): accounts slice (step 6)`. + +## Gate record (2026-07-13, keeper-verified) + +Built state = commits `58742fca`/`b614b9a2`/`3c8c5b60`/`8a1e1125` (SDK, tasks 1–4) + `2da05af0`/`ccffcdba`/`c204b96b` (task 5). Full chain re-run on the frozen tree by the gate, not taken from the builder: `fix:all` clean, workspace typecheck green, tests exit 0 (wallet-sdk 96, web 36, money 14, bolt11 9, ecies 18, cashu 35). + +- **Selector boundary as built:** `account-selector` renders DOMAIN accounts via `getAccountBalance` (master verbatim) and the picker option-build sites unwrap via `toDomainAccount()` — a sanctioned B1.3 "selector→store handoff". Display hooks (`useAccounts`/`useAccount`/`useAccountOrNull`) stay projection-typed with `.balance`; the getter hooks and `useDefaultAccount`/`useAccountOrDefault` unwrap internally and return domain for the money flows. Hidden-fields grep clean; every `.wallet`/`.proofs` read passes `toDomainAccount()` at a named seam (seam list in the PR body). +- **Declared deviation:** `account-repository-hooks` is relocated to `features/receive/` rather than deleted — its only remaining consumers are the unmigrated receive repos, which construct `AccountRepository` synchronously (the bridge accessor is async; reshaping them is step 8–16 scope). `account-service-hooks` is deleted. The accounts feature no longer imports either. +- **Browser smoke scope (env-limited on the build box):** verified on the exact head — app boots, landing + signup render with zero console/page errors, guest flow drives to Terms of Service and initiates enclave registration. Registration itself is blocked by the dev enclave's server-side 403 for this box's origin (environmental; master fails identically here) — post-auth surfaces (wallet home, add mint, balances) were NOT browser-verified on this box and want a maintainer-env smoke at review. Compensating evidence: the ensure/mapper/projection/fencing paths are unit-covered (25 new tests), and the full suite is green. + +## Review round (2026-07-14, lead-verified) + +Four clean-context reviewers (general ×2 — fable + opus, master-parity, plan-compliance/boundary) over `origin/sdk/auth-slice...HEAD`. Fencing, mapper choke point, checked-cast discipline, hidden-fields grep, payload/retry parity independently confirmed. Findings, fixed on top of the gate tree: + +- **Key-warm parity restored** (`_protected.tsx`): the flip dropped master's middleware `ensureQueryData` warms for the web-side key entries, so unmigrated receive/send/claim consumers cold-derived at first Wallet render — duplicate enclave calls serialized after `ensure()`, a new mount suspense, and the failure surface moved out of the middleware. Restored on the upsert branch, concurrent with `ensure()`; recorded as delta 4. +- **`ensure()` derivation retry** (`user-api.ts`): the key batch runs under `withRetry` — master derived through the host query layer, which retried transient failures; single-shot was a silent resilience loss on the login path. The memoized getters make each retry re-fetch only the keys that failed. +- **Dead code dropped**: the web `defaultAccounts` copy (`user-hooks.tsx` — B2 moved the constant SDK-internal; the leftover export invited silent drift), `sparkIdentityPublicKeyQueryOptions` (`spark-query-options.ts` — its cache entry lost its only reader in the flip), and the `RedactedAccount`/`RedactedCashuAccount` root re-exports (`index.ts` — the last **accounts-domain** root types referencing wallet handles; both types stay SDK-internal, zero web importers). +- **`ensure` mapping test**: `createRepository` test seam on `createUserApi` (mirroring `accounts-api`); a new test drives a returned account row through `toAccount` → shared mapper and asserts the projection is runtime-fat with the computed balance (previously only exercised with `accounts: []`). +- **Doc drift amended in place**: B2/tasks 4–6 no longer describe the SDK ensure-memo dropped in `0eda60b5` (the host owns caching); B5 and the scope map match the `/temporary`-only `getAccountBalance`; scope-map row 1 records the relocation the gate declared; task 6 states the test scope honestly (web-side entry paths compile-enforced + grep-audited, WASM parity structural). +- **Recorded, not changed**: realtime handlers resolve the bridge accessor per event, so an event landing in the dev-HMR dispose→create window rejects instead of mapping (master captured the repository at hook mount). Kept as-is — per-event resolution retries transient failures that a mount-captured promise would latch until remount; the window is dev-only and self-corrects on reload. + +Chain re-run on this tree: `fix:all` clean; tests exit 0 — wallet-sdk 96 (+2 todo), web 36, money 14, bolt11 9, ecies 18, cashu 35. Supersedes the counts above — the prior gate attested the tree before `0eda60b5` (which landed at 95) and before this round's additions. + +**Round 2 (fresh opus general + fix-verification):** all round-1 fixes verified — warm set exact with per-entry reader evidence, retry non-duplicating over the memos, seam test-only and unreachable from the exports map, deletions reference-free repo-wide, SDK↔web derivation byte-identical, gate counts reconciled statically. One claim-accuracy finding, amended above: the `Redacted*` removal cleaned the last **accounts-domain** root types, not the whole root — unmigrated-domain root types still carry wallet handles/proofs (new Deferred bullet; step 18 re-audits the full root surface). Also added: delta-4 partial-failure clause and a transient key-derivation retry test. Chain re-run: `fix:all` clean; wallet-sdk 97 (+2 todo), others unchanged. + +## Self-Review Checklist + +1. Spec coverage: `AccountsApi` methods wrapped and web-consumed (B1 ruling); web accounts imports flipped per scope map; step-5 deferred items A1/getEncryption landed here or explicitly re-deferred with reason. +2. Parity scan: every master behavior preserved or listed under Accepted behavior deltas. +3. Projection discipline: public types carry no hidden fields; hidden-fields grep clean; every cache write goes through the shared mapper; `toDomainAccount()` is the only cast site. +4. Key-fencing: sign-out → sign-in as a different user cannot serve the first user's keys/seeds from any SDK memo. +5. `/temporary` surface: only the declared bridge v2 (repo accessor, checked cast, mapper), each carrying a step-18 removal note. +6. WASM posture: master parity verified per B7's build-time check, not assumed. + +## Supersession — accounts contract exposes domain types (2026-07-17, maintainer) + +Everything above records the slice as first built, against a projection-typed +`AccountsApi`. A maintainer ruling on the contract itself (commit `d5d2f18a`, +PR #1166) changed that shape after the slice was written, and the slice was +reconstructed on the new base to match it. The past entries stand as the +decision trail; this section states what is now true where it diverges. + +- **B1 projection apparatus — superseded.** `sdk.accounts.*` returns the domain + account entities directly (`Account`/`CashuAccount`/`SparkAccount`, re-exported + by the contract), not projection types. There is no shared domain→projection + mapper, no `toAccountProjection`, and no `toDomainAccount` checked cast; the + `['accounts']` cache holds domain accounts exactly as master did, and the + single-data-path invariant now means one repository behind both the namespace + and the bridge, with one type-surface rather than two. `/temporary` carries the + internal-repository accessor and the session-keys accessor only. The root + `Account` family stays the domain entities (the explicit `index.ts` account-type + exports are kept, not deleted), and web domain-type importers stay on the root + package — no consumer moved to `/temporary` for account types. +- **B5 balance reads — revert to `getAccountBalance`.** Cashu accounts carry no + `balance` field; display consumers read `getAccountBalance(account)` exactly as + master does, not a mapper-computed `.balance`. `getAccountBalance` stays a + `/temporary` export. `getExtendedAccounts`/`isDefaultAccount` are still extracted + to standalone root exports, now typed over the domain accounts with the plain + (non-generic) signature. +- **B3 pin — unchanged.** `AddCashuAccountParams = { name; mintUrl; currency; + purpose }` stands as ruled. +- **B2 ensure — same shape, simpler body.** `sdk.user.ensure()` still returns + `{ user; accounts }`; the accounts come straight from the base + `UpsertUserRepository`, which is already domain-typed, so there is no projection + mapping on the return. `withRetry`/`delay` moved into `@agicash/utils` (their web + copies deleted) so the SDK can share the retry helper. +- **Reality-class record and the physical projection strip — retired as moot.** + Accepted-delta 1 ("TYPE-honest / RUNTIME-fat until step 18") and the step-18 + physical strip in the Deferred list no longer apply: there is no projection to + strip, so accounts returns are runtime-honest today. The `/temporary` bridge and + the delegating web key queries still retire at step 18 with their unmigrated + consumers, and the transitional key double-derivation (delta 4) still holds + until then. diff --git a/apps/web-wallet/app/lib/delay.ts b/packages/utils/src/delay.ts similarity index 95% rename from apps/web-wallet/app/lib/delay.ts rename to packages/utils/src/delay.ts index 132fbe8ab..4b2677ab2 100644 --- a/apps/web-wallet/app/lib/delay.ts +++ b/packages/utils/src/delay.ts @@ -7,7 +7,7 @@ export type DelayOptions = { * @param ms Number of milliseconds to wait * @param signal Abort signal that can be used to cancel the delay */ -export default async function delay( +export async function delay( ms: number, { signal }: DelayOptions = {}, ): Promise { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 73992e90b..9de0394cc 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -6,3 +6,5 @@ export * from './type-utils'; export * from './sha256'; export * from './timeout'; export * from './xchacha20poly1305'; +export * from './delay'; +export * from './with-retry'; diff --git a/apps/web-wallet/app/lib/with-retry.ts b/packages/utils/src/with-retry.ts similarity index 98% rename from apps/web-wallet/app/lib/with-retry.ts rename to packages/utils/src/with-retry.ts index 442ddd28a..8e57d34f3 100644 --- a/apps/web-wallet/app/lib/with-retry.ts +++ b/packages/utils/src/with-retry.ts @@ -1,4 +1,4 @@ -import delay from './delay'; +import { delay } from './delay'; /** * Predicate that determines whether a function should be retried. diff --git a/packages/wallet-sdk/domain/accounts/accounts-api.test.ts b/packages/wallet-sdk/domain/accounts/accounts-api.test.ts new file mode 100644 index 000000000..f709a1779 --- /dev/null +++ b/packages/wallet-sdk/domain/accounts/accounts-api.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'bun:test'; +import { Money } from '@agicash/money'; +import type { AgicashDb } from '../../db/database'; +import { NoSessionError } from '../../lib/error'; +import type { SparkWalletConfig } from '../../lib/spark/wallet'; +import { createSessionKeys } from '../../session-keys'; +import type { AddCashuAccountParams, AuthSession, AuthUser } from '../sdk'; +import { + type Account as DomainAccount, + type CashuAccount as DomainCashuAccount, + type SparkAccount as DomainSparkAccount, + getAccountBalance, +} from './account'; +import type { AccountRepository } from './account-repository'; +import { createAccountsApi } from './accounts-api'; + +const authUser = (id: string): AuthUser => + ({ + id, + name: null, + email: 'a@b.c', + email_verified: true, + login_method: 'email', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }) as AuthUser; + +const loggedIn = (id: string): AuthSession => ({ + isLoggedIn: true, + user: authUser(id), +}); + +const cashuDomain = ( + overrides: Partial> = {}, +): DomainCashuAccount => + ({ + id: 'acct-cashu', + name: 'Testnut BTC', + type: 'cashu', + purpose: 'transactional', + state: 'active', + isOnline: true, + currency: 'BTC', + createdAt: '2026-01-01T00:00:00Z', + version: 1, + expiresAt: null, + mintUrl: 'https://testnut.cashu.space', + isTestMint: true, + keysetCounters: {}, + proofs: [{ amount: 100 }, { amount: 50 }], + wallet: { marker: 'cashu-wallet' }, + ...overrides, + }) as unknown as DomainCashuAccount; + +const sparkDomain = (): DomainSparkAccount => + ({ + id: 'acct-spark', + name: 'Bitcoin', + type: 'spark', + purpose: 'transactional', + state: 'active', + isOnline: true, + currency: 'BTC', + createdAt: '2026-01-01T00:00:00Z', + version: 1, + expiresAt: null, + network: 'MAINNET', + balance: new Money({ amount: 42, currency: 'BTC', unit: 'sat' }), + wallet: { marker: 'spark-wallet' }, + }) as unknown as DomainSparkAccount; + +const makeApi = (deps: { + session: AuthSession; + repository?: Partial; +}) => + createAccountsApi({ + db: {} as unknown as AgicashDb, + keys: createSessionKeys(), + sparkConfig: { storageDir: '.', apiKey: 'k' } satisfies SparkWalletConfig, + getSession: () => deps.session, + createRepository: async () => + (deps.repository ?? {}) as unknown as AccountRepository, + }); + +describe('createAccountsApi', () => { + describe('cashu.add', () => { + it('re-injects type:cashu and the session userId, then returns the created account', async () => { + let created: Record | undefined; + const { api } = makeApi({ + session: loggedIn('user-x'), + repository: { + create: (async (input: Record) => { + created = input; + return cashuDomain({ mintUrl: input.mintUrl as string }); + }) as unknown as AccountRepository['create'], + }, + }); + + const params: AddCashuAccountParams = { + name: 'My mint', + mintUrl: 'https://testnut.cashu.space', + currency: 'BTC', + purpose: 'transactional', + }; + const result = await api.cashu.add(params); + + expect(created?.type).toBe('cashu'); + expect(created?.userId).toBe('user-x'); + expect(created?.name).toBe('My mint'); + expect(created?.currency).toBe('BTC'); + expect(created?.purpose).toBe('transactional'); + // the return is the domain account: proofs ride along, balance derives + expect(result.proofs).toBeDefined(); + expect(getAccountBalance(result)?.amount('sat').toNumber()).toBe(150); + }); + + it('throws NoSessionError without a session', async () => { + const { api } = makeApi({ session: { isLoggedIn: false } }); + await expect( + api.cashu.add({ + name: 'x', + mintUrl: 'https://testnut.cashu.space', + currency: 'BTC', + purpose: 'transactional', + }), + ).rejects.toBeInstanceOf(NoSessionError); + }); + }); + + describe('list', () => { + it('returns every active account for the session user', async () => { + let requestedUserId: string | undefined; + const { api } = makeApi({ + session: loggedIn('user-x'), + repository: { + getAllActive: (async (userId: string) => { + requestedUserId = userId; + return [cashuDomain(), sparkDomain()] as DomainAccount[]; + }) as unknown as AccountRepository['getAllActive'], + }, + }); + + const accounts = await api.list(); + + expect(requestedUserId).toBe('user-x'); + expect(accounts).toHaveLength(2); + const balances = accounts.map((account) => + getAccountBalance(account)?.amount('sat').toNumber(), + ); + expect(balances).toEqual([150, 42]); + }); + + it('throws NoSessionError without a session', async () => { + const { api } = makeApi({ session: { isLoggedIn: false } }); + await expect(api.list()).rejects.toBeInstanceOf(NoSessionError); + }); + }); + + describe('get', () => { + it('returns a found account', async () => { + const { api } = makeApi({ + session: loggedIn('user-x'), + repository: { + get: (async () => + cashuDomain()) as unknown as AccountRepository['get'], + }, + }); + + const account = await api.get('acct-cashu'); + + if (!account) throw new Error('expected an account'); + expect(getAccountBalance(account)?.amount('sat').toNumber()).toBe(150); + }); + + it('returns null when the account is not found', async () => { + const { api } = makeApi({ + session: loggedIn('user-x'), + repository: { + get: (async () => null) as unknown as AccountRepository['get'], + }, + }); + + expect(await api.get('missing')).toBeNull(); + }); + }); + + describe('AddCashuAccountParams (B3)', () => { + it('accepts exactly name, mintUrl, currency, purpose', () => { + const params: AddCashuAccountParams = { + name: 'My mint', + mintUrl: 'https://testnut.cashu.space', + currency: 'BTC', + purpose: 'transactional', + }; + expect(params).toBeDefined(); + + const withUserId: AddCashuAccountParams = { + name: 'My mint', + mintUrl: 'https://testnut.cashu.space', + currency: 'BTC', + purpose: 'transactional', + // @ts-expect-error - userId is session-implicit, not part of the params + userId: 'user-x', + }; + expect(withUserId).toBeDefined(); + }); + }); +}); diff --git a/packages/wallet-sdk/domain/accounts/accounts-api.ts b/packages/wallet-sdk/domain/accounts/accounts-api.ts new file mode 100644 index 000000000..8711416aa --- /dev/null +++ b/packages/wallet-sdk/domain/accounts/accounts-api.ts @@ -0,0 +1,73 @@ +import type { AgicashDb } from '../../db/database'; +import { NoSessionError } from '../../lib/error'; +import type { SparkWalletConfig } from '../../lib/spark/wallet'; +import type { SessionKeys } from '../../session-keys'; +import type { AccountsApi, AuthSession, CashuAccount } from '../sdk'; +import { AccountRepository } from './account-repository'; +import { AccountService } from './account-service'; + +type Deps = { + db: AgicashDb; + getSession: () => AuthSession; + keys: SessionKeys; + sparkConfig: SparkWalletConfig; + /** Test seam; defaults to building the repository from db + session keys. */ + createRepository?: () => Promise; +}; + +/** + * The `accounts` namespace and its bridge share one data path: every method + * builds the repository from the same db + session keys, and `getRepository` + * hands `/temporary` the same repository for unmigrated flows. + */ +export function createAccountsApi(deps: Deps): { + api: AccountsApi; + getRepository: () => Promise; +} { + const requireUserId = (): string => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + return session.user.id; + }; + + const getRepository = + deps.createRepository ?? + (async (): Promise => { + const encryption = await deps.keys.getEncryption(); + return new AccountRepository( + deps.db, + encryption, + deps.keys.getCashuSeed, + deps.keys.getSparkMnemonic, + deps.sparkConfig, + ); + }); + + return { + getRepository, + api: { + get: async (id) => { + const repository = await getRepository(); + return repository.get(id); + }, + list: async () => { + const userId = requireUserId(); + const repository = await getRepository(); + return repository.getAllActive(userId); + }, + cashu: { + add: async (params): Promise => { + const userId = requireUserId(); + const repository = await getRepository(); + const service = new AccountService(repository); + return service.addCashuAccount({ + userId, + account: { ...params, type: 'cashu' }, + }); + }, + }, + }, + }; +} diff --git a/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts b/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts index ca79d7630..855915845 100644 --- a/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts +++ b/packages/wallet-sdk/domain/receive/claim-cashu-token-service.ts @@ -5,7 +5,7 @@ import type { Account, CashuAccount, SparkAccount } from '../accounts/account'; import type { AccountService } from '../accounts/account-service'; import type { Ticker } from '../exchange-rate'; import type { User } from '../user/user'; -import { UserService } from '../user/user-service'; +import { getExtendedAccounts } from '../user/user-service'; import type { CashuReceiveQuoteService } from './cashu-receive-quote-service'; import type { CashuReceiveSwap } from './cashu-receive-swap'; import type { CashuReceiveSwapService } from './cashu-receive-swap-service'; @@ -82,7 +82,7 @@ export class ClaimCashuTokenService { ): Promise { const changedAccounts: Account[] = []; - const extendedAccounts = UserService.getExtendedAccounts(user, accounts); + const extendedAccounts = getExtendedAccounts(user, accounts); const preferredReceiveAccountId = claimTo === 'spark' ? extendedAccounts.find((a) => a.type === 'spark')?.id diff --git a/packages/wallet-sdk/domain/sdk/accounts.ts b/packages/wallet-sdk/domain/sdk/accounts.ts index d0cd783e7..c2cc41e95 100644 --- a/packages/wallet-sdk/domain/sdk/accounts.ts +++ b/packages/wallet-sdk/domain/sdk/accounts.ts @@ -1,4 +1,10 @@ -import type { Account, CashuAccount, SparkAccount } from '../accounts/account'; +import type { Currency } from '@agicash/money'; +import type { + Account, + AccountPurpose, + CashuAccount, + SparkAccount, +} from '../accounts/account'; // The public account types are the domain entities for now: only the apps // consume the SDK and they just read these shapes, so fields like proofs, @@ -16,4 +22,9 @@ export type AccountsApi = { }; }; -export type AddCashuAccountParams = unknown; // step 6 (accounts) +export type AddCashuAccountParams = { + name: string; + mintUrl: string; + currency: Currency; + purpose: AccountPurpose; +}; diff --git a/packages/wallet-sdk/domain/sdk/sdk.test.ts b/packages/wallet-sdk/domain/sdk/sdk.test.ts index e701f1334..e8a04ecbc 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.test.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'bun:test'; import type { AuthKeyValueStore, SdkConfig } from '.'; import { nullLogger } from '../../lib/logger'; -import { AgicashSdk } from './sdk'; +import { + AgicashSdk, + getInternalAccountRepository, + getInternalSessionKeys, +} from './sdk'; const createMemoryStore = (): AuthKeyValueStore => { const data = new Map(); @@ -43,3 +47,55 @@ describe('AgicashSdk.create', () => { await next.dispose(); }); }); + +describe('getInternalAccountRepository', () => { + it('throws when no instance is live', () => { + expect(() => getInternalAccountRepository()).toThrow( + 'No live AgicashSdk instance', + ); + }); + + it('resolves through the live instance and clears on dispose', async () => { + const sdk = AgicashSdk.create(createConfig()); + + // While live the guard passes and the accessor returns the repository + // promise. Its key derivation is exercised in the accounts-api tests; here + // we only assert the bridge is wired, so the derivation is left to settle. + const pending = getInternalAccountRepository(); + expect(pending).toBeInstanceOf(Promise); + pending.catch(() => { + // The derivation reaches Open Secret, which this env cannot; the bridge + // wiring is all we assert here, so let the rejection settle unobserved. + }); + + await sdk.dispose(); + + expect(() => getInternalAccountRepository()).toThrow( + 'No live AgicashSdk instance', + ); + }); +}); + +describe('getInternalSessionKeys', () => { + it('throws when no instance is live', () => { + expect(() => getInternalSessionKeys()).toThrow( + 'No live AgicashSdk instance', + ); + }); + + it('returns the live instance session keys and clears them on dispose', async () => { + const sdk = AgicashSdk.create(createConfig()); + + const keys = getInternalSessionKeys(); + // Same reference every call: the accessor hands back the instance's single + // session-keys object, not a fresh derivation per call. + expect(getInternalSessionKeys()).toBe(keys); + expect(typeof keys.getEncryption).toBe('function'); + + await sdk.dispose(); + + expect(() => getInternalSessionKeys()).toThrow( + 'No live AgicashSdk instance', + ); + }); +}); diff --git a/packages/wallet-sdk/domain/sdk/sdk.ts b/packages/wallet-sdk/domain/sdk/sdk.ts index a0c780b24..10cd1543c 100644 --- a/packages/wallet-sdk/domain/sdk/sdk.ts +++ b/packages/wallet-sdk/domain/sdk/sdk.ts @@ -19,7 +19,13 @@ import { createSupabaseSessionTokenGetter } from '../../db/supabase-session'; import { clearAgicashMintAuthToken } from '../../lib/agicash-mint-auth-provider'; import { NotImplementedError } from '../../lib/error'; import { generateRandomPassword } from '../../lib/password'; -import { clearSparkWallets } from '../../lib/spark/wallet'; +import { + type SparkWalletConfig, + clearSparkWallets, +} from '../../lib/spark/wallet'; +import { type SessionKeys, createSessionKeys } from '../../session-keys'; +import type { AccountRepository } from '../accounts/account-repository'; +import { createAccountsApi } from '../accounts/accounts-api'; import { AuthService } from '../user/auth-service'; import { createUserApi } from '../user/user-api'; import { WalletEventEmitter } from './events'; @@ -29,19 +35,29 @@ import { WalletEventEmitter } from './events'; // the module-global Open Secret configuration. let liveInstance: AgicashSdk | undefined; +// The live instance's internal accounts repository builder, reached only +// through the '@agicash/wallet-sdk/temporary' bridge (removed at step 18). +// Module-scoped like liveInstance so the bridge never exposes the domain +// repository on the public AgicashSdk surface. +let liveAccountRepository: (() => Promise) | undefined; + +// The live instance's session keys, reached only through the +// '@agicash/wallet-sdk/temporary' bridge (removed at step 18). Module-scoped +// like liveInstance so the bridge never exposes the key derivations on the +// public AgicashSdk surface. +let liveSessionKeys: SessionKeys | undefined; + /** * Runtime implementation of the SDK contract. Namespaces land slice by slice — - * auth, user, and events so far; accessing a namespace whose migration slice - * hasn't landed throws `NotImplementedError`. + * auth, user, accounts, and events so far; accessing a namespace whose migration + * slice hasn't landed throws `NotImplementedError`. */ export class AgicashSdk implements Sdk { readonly auth: AuthApi; readonly user: UserApi; + readonly accounts: AccountsApi; readonly events: WalletEvents; - get accounts(): AccountsApi { - throw new NotImplementedError('accounts'); - } get contacts(): ContactsApi { throw new NotImplementedError('contacts'); } @@ -79,6 +95,8 @@ export class AgicashSdk implements Sdk { const events = new WalletEventEmitter(config.logger); + const keys = createSessionKeys(); + // Created before authService — the isLoggedIn closure dereferences it // lazily at request time, after the constructor has assigned it. const sessionToken = createSupabaseSessionTokenGetter({ @@ -102,6 +120,7 @@ export class AgicashSdk implements Sdk { sessionToken.reset(); clearSparkWallets(); clearAgicashMintAuthToken(); + keys.reset(); }, logger: config.logger, }); @@ -112,12 +131,28 @@ export class AgicashSdk implements Sdk { accessToken: sessionToken.getToken, }); + const sparkConfig: SparkWalletConfig = { + storageDir: config.spark.storageDir ?? './.spark-data', + apiKey: config.spark.breezApiKey, + }; + const accounts = createAccountsApi({ + db, + getSession: () => this.authService.getSession(), + keys, + sparkConfig, + }); + this.auth = this.authService; this.user = createUserApi({ db, getSession: () => this.authService.getSession(), + keys, + getAccountRepository: accounts.getRepository, }); + this.accounts = accounts.api; this.events = events; + liveAccountRepository = accounts.getRepository; + liveSessionKeys = keys; } /** Sync; no I/O. Throws when an undisposed instance already exists (see the constructor note). */ @@ -145,6 +180,38 @@ export class AgicashSdk implements Sdk { this.authService.teardown(); if (liveInstance === this) { liveInstance = undefined; + liveAccountRepository = undefined; + liveSessionKeys = undefined; } } } + +/** + * The live instance's internal domain accounts repository, for the host's + * unmigrated flows (receive/send repo construction, realtime row mapping) that still read + * wallet/proofs. Re-exported from '@agicash/wallet-sdk/temporary'; not on the + * public surface. + * + * @remarks Removed at step 18 when those flows read wallet/proofs from the SDK. + */ +export function getInternalAccountRepository(): Promise { + if (!liveAccountRepository) { + throw new Error('No live AgicashSdk instance'); + } + return liveAccountRepository(); +} + +/** + * The live instance's session keys, for the host's key queries the unmigrated + * receive/send/claim flows still read (encryption, cashu seed, spark mnemonic). + * Re-exported from '@agicash/wallet-sdk/temporary'; not on the public surface. + * + * @remarks Removed at step 18 when those flows source their keys from the SDK + * and the host's key queries die with them. + */ +export function getInternalSessionKeys(): SessionKeys { + if (!liveSessionKeys) { + throw new Error('No live AgicashSdk instance'); + } + return liveSessionKeys; +} diff --git a/packages/wallet-sdk/domain/sdk/user.ts b/packages/wallet-sdk/domain/sdk/user.ts index 343715399..16afa52b6 100644 --- a/packages/wallet-sdk/domain/sdk/user.ts +++ b/packages/wallet-sdk/domain/sdk/user.ts @@ -8,6 +8,32 @@ export type UserApi = { acceptTerms(params: AcceptTermsParams): Promise; setDefaultAccount(params: SetDefaultAccountParams): Promise; setDefaultCurrency(params: SetDefaultCurrencyParams): Promise; + /** + * Bootstraps the signed-in user: upserts the user row (creating default + * accounts and persisting the derived public keys on first sign-in) and + * returns the user with their accounts for the host to seed its caches. + * Idempotent — performs the upsert on every call with the given params; the + * host owns caching and call-frequency (the host gates it behind its own + * user-cache short-circuit). + */ + ensure( + params: EnsureUserParams, + ): Promise<{ user: User; accounts: Account[] }>; +}; + +export type EnsureUserParams = { + /** + * ISO 8601 timestamp replayed from the host's pending-terms storage — the + * time the user accepted wallet terms before the user row existed, not + * "now". `acceptTerms` is the in-session boolean that stamps the time + * itself. + */ + termsAcceptedAt?: string; + /** + * ISO 8601 timestamp replayed from the host's pending-terms storage — the + * time the user accepted gift-card-mint terms before the user row existed. + */ + giftCardMintTermsAcceptedAt?: string; }; export type AcceptTermsParams = { diff --git a/packages/wallet-sdk/domain/user/user-api.test.ts b/packages/wallet-sdk/domain/user/user-api.test.ts index 2b28a6289..1e1fd3f64 100644 --- a/packages/wallet-sdk/domain/user/user-api.test.ts +++ b/packages/wallet-sdk/domain/user/user-api.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from 'bun:test'; import type { Currency } from '@agicash/money'; +import { core, z } from 'zod/mini'; import type { AgicashDb } from '../../db/database'; -import type { Account } from '../accounts/account'; +import type { Encryption } from '../../lib/encryption'; +import type { SessionKeys } from '../../session-keys'; +import { + type Account, + type CashuAccount as DomainCashuAccount, + getAccountBalance, +} from '../accounts/account'; +import type { AccountRepository } from '../accounts/account-repository'; import type { AuthUser } from '../sdk'; import { createUserApi } from './user-api'; @@ -36,12 +44,25 @@ const dbUserRow = (id: string) => ({ const account = (id: string, currency: Currency): Account => ({ id, currency }) as unknown as Account; +const fakeAccountRepository = {} as AccountRepository; +const getAccountRepository = async () => fakeAccountRepository; + +const fakeKeys = (): SessionKeys => ({ + getEncryption: async () => ({}) as Encryption, + getEncryptionPublicKey: async () => 'enc-pub', + getCashuSeed: async () => new Uint8Array(64), + getSparkMnemonic: async () => 'mnemonic', + getCashuLockingXpub: async () => 'xpub', + getSparkIdentityPublicKey: async () => 'spark-id', + reset: () => undefined, +}); + type Filters = Record; /** - * Fake covering the one query setDefaultAccount now issues: the users row - * update. A read from any other table would mean the api regressed to fetching - * the account instead of trusting the cached one the caller passes. + * Fake covering the one query setDefaultAccount issues: the users row update. A + * read from any other table would mean the api regressed to fetching the account + * instead of trusting the cached one the caller passes. */ const createDbFake = ( onUserUpdate: (filters: Filters, data: Record) => void, @@ -72,6 +93,40 @@ const createDbFake = ( return { from } as unknown as AgicashDb; }; +/** Fake covering the single `upsert_user_with_accounts` RPC ensure issues. */ +const createUpsertDbFake = ( + outcomes: Array<{ reject: unknown } | { ok: true }>, + accountRows: Record[] = [], +) => { + const calls: Record[] = []; + const db = { + rpc: (_name: string, params: Record) => { + const outcome = outcomes[Math.min(calls.length, outcomes.length - 1)]; + calls.push(params); + if (outcome && 'reject' in outcome) { + return Promise.reject(outcome.reject); + } + return Promise.resolve({ + data: { + user: dbUserRow(String(params.p_user_id)), + accounts: accountRows, + }, + error: null, + }); + }, + } as unknown as AgicashDb; + return { db, calls }; +}; + +const makeZodError = (): unknown => { + try { + z.number().parse('not a number'); + } catch (error) { + return error; + } + throw new Error('expected zod parse to throw'); +}; + describe('createUserApi', () => { describe('setDefaultAccount', () => { it('writes the cached account onto the session user, keyed by its currency', async () => { @@ -83,6 +138,8 @@ describe('createUserApi', () => { updatedData = data; }), getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, }); await api.setDefaultAccount({ account: account('acct-1', 'BTC') }); @@ -91,4 +148,200 @@ describe('createUserApi', () => { expect(updatedData.default_btc_account_id).toBe('acct-1'); }); }); + + describe('ensure', () => { + it('upserts with the derived keys and terms, returning the user and accounts', async () => { + const { db, calls } = createUpsertDbFake([{ ok: true }]); + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + const result = await api.ensure({ + termsAcceptedAt: '2026-01-02T00:00:00Z', + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.p_user_id).toBe('user-a'); + expect(calls[0]?.p_cashu_locking_xpub).toBe('xpub'); + expect(calls[0]?.p_encryption_public_key).toBe('enc-pub'); + expect(calls[0]?.p_spark_identity_public_key).toBe('spark-id'); + expect(calls[0]?.p_terms_accepted_at).toBe('2026-01-02T00:00:00Z'); + expect(result.user.id).toBe('user-a'); + expect(result.accounts).toEqual([]); + }); + + it('returns the upserted account rows as domain accounts through the repository', async () => { + const row = { id: 'acct-cashu' }; + const { db, calls } = createUpsertDbFake([{ ok: true }], [row]); + const domainCashuAccount = { + id: 'acct-cashu', + name: 'Testnut BTC', + type: 'cashu', + purpose: 'transactional', + state: 'active', + isOnline: true, + currency: 'BTC', + createdAt: '2026-01-01T00:00:00Z', + version: 1, + expiresAt: null, + mintUrl: 'https://testnut.cashu.space', + isTestMint: true, + keysetCounters: { ks1: 3 }, + proofs: [{ amount: 100 }, { amount: 50 }], + wallet: { marker: 'cashu-wallet' }, + } as unknown as DomainCashuAccount; + const toAccountCalls: unknown[] = []; + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository: async () => + ({ + toAccount: async (input: unknown) => { + toAccountCalls.push(input); + return domainCashuAccount; + }, + }) as unknown as AccountRepository, + }); + + const result = await api.ensure({}); + + expect(calls).toHaveLength(1); + expect(toAccountCalls).toEqual([row]); + const [account] = result.accounts; + if (!account) throw new Error('expected an account'); + expect(account.type).toBe('cashu'); + expect(getAccountBalance(account)?.amount('sat').toNumber()).toBe(150); + expect('proofs' in account).toBe(true); + expect('wallet' in account).toBe(true); + expect('keysetCounters' in account).toBe(true); + }); + + it('upserts on every call and returns each call fresh result (no memo)', async () => { + const rows = [ + dbUserRow('user-a'), + { ...dbUserRow('user-a'), username: 'renamed' }, + ]; + const calls: Record[] = []; + const db = { + rpc: (_name: string, params: Record) => { + const row = rows[Math.min(calls.length, rows.length - 1)]; + calls.push(params); + return Promise.resolve({ + data: { user: row, accounts: [] }, + error: null, + }); + }, + } as unknown as AgicashDb; + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + const first = await api.ensure({}); + const second = await api.ensure({}); + + expect(calls).toHaveLength(2); + expect(first.user.username).toBe('name'); + expect(second.user.username).toBe('renamed'); + }); + + it('carries the terms params into the upsert on every call', async () => { + const { db, calls } = createUpsertDbFake([{ ok: true }]); + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + await api.ensure({ termsAcceptedAt: 't1' }); + await api.ensure({ + termsAcceptedAt: 't2', + giftCardMintTermsAcceptedAt: 'g2', + }); + + expect(calls).toHaveLength(2); + expect(calls[0]?.p_terms_accepted_at).toBe('t1'); + expect(calls[0]?.p_gift_card_mint_terms_accepted_at).toBeUndefined(); + expect(calls[1]?.p_terms_accepted_at).toBe('t2'); + expect(calls[1]?.p_gift_card_mint_terms_accepted_at).toBe('g2'); + }); + + it('retries a transient key-derivation failure before upserting', async () => { + const { db, calls } = createUpsertDbFake([{ ok: true }]); + let attempts = 0; + const keys = fakeKeys(); + keys.getSparkIdentityPublicKey = async () => { + attempts += 1; + if (attempts === 1) { + throw new Error('transient enclave failure'); + } + return 'spark-id'; + }; + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys, + getAccountRepository, + }); + + const result = await api.ensure({}); + + expect(attempts).toBe(2); + expect(calls).toHaveLength(1); + expect(calls[0]?.p_spark_identity_public_key).toBe('spark-id'); + expect(result.user.id).toBe('user-a'); + }); + + it('retries a generic upsert failure, then succeeds', async () => { + const { db, calls } = createUpsertDbFake([ + { reject: new Error('transient') }, + { ok: true }, + ]); + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + const result = await api.ensure({}); + + expect(calls).toHaveLength(2); + expect(result.user.id).toBe('user-a'); + }); + + it('does not retry a Zod validation error', async () => { + const zodError = makeZodError(); + expect(zodError).toBeInstanceOf(core.$ZodError); + const { db, calls } = createUpsertDbFake([{ reject: zodError }]); + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: true, user: authUser('user-a') }), + keys: fakeKeys(), + getAccountRepository, + }); + + await expect(api.ensure({})).rejects.toBe(zodError); + expect(calls).toHaveLength(1); + }); + + it('throws NoSessionError without a session', async () => { + const { db } = createUpsertDbFake([{ ok: true }]); + const api = createUserApi({ + db, + getSession: () => ({ isLoggedIn: false }), + keys: fakeKeys(), + getAccountRepository, + }); + + await expect(api.ensure({})).rejects.toThrow(); + }); + }); }); diff --git a/packages/wallet-sdk/domain/user/user-api.ts b/packages/wallet-sdk/domain/user/user-api.ts index f7889f30b..a3b32c1a2 100644 --- a/packages/wallet-sdk/domain/user/user-api.ts +++ b/packages/wallet-sdk/domain/user/user-api.ts @@ -1,14 +1,63 @@ +import { withRetry } from '@agicash/utils'; +import { core } from 'zod/mini'; import type { AgicashDb } from '../../db/database'; import { NoSessionError } from '../../lib/error'; +import type { SessionKeys } from '../../session-keys'; +import type { AccountRepository } from '../accounts/account-repository'; import type { AuthSession, UserApi } from '../sdk'; -import { ReadUserRepository, UpdateUserRepository } from './user-repository'; +import { + ReadUserRepository, + UpdateUserRepository, + UpsertUserRepository, +} from './user-repository'; import { UserService } from './user-service'; type Deps = { db: AgicashDb; getSession: () => AuthSession; + keys: SessionKeys; + /** The accounts namespace's repository — one construction path for the whole instance. */ + getAccountRepository: () => Promise; }; +const isDevelopmentMode = import.meta.env.MODE === 'development'; + +const defaultAccounts = [ + { + type: 'spark', + currency: 'BTC', + name: 'Bitcoin', + network: 'MAINNET', + isDefault: true, + purpose: 'transactional', + expiresAt: null, + }, + ...(isDevelopmentMode + ? ([ + { + type: 'cashu', + currency: 'BTC', + name: 'Testnut BTC', + mintUrl: 'https://testnut.cashu.space', + isTestMint: true, + isDefault: false, + purpose: 'transactional', + expiresAt: null, + }, + { + type: 'cashu', + currency: 'USD', + name: 'Testnut USD', + mintUrl: 'https://testnut.cashu.space', + isTestMint: true, + isDefault: true, + purpose: 'transactional', + expiresAt: null, + }, + ] as const) + : []), +] as const; + export function createUserApi(deps: Deps): UserApi { const readRepository = new ReadUserRepository(deps.db); const updateRepository = new UpdateUserRepository(deps.db); @@ -43,5 +92,55 @@ export function createUserApi(deps: Deps): UserApi { userService.setDefaultAccount(requireUserId(), params.account, { setDefaultCurrency: params.setDefaultCurrency, }), + ensure: async (params) => { + const session = deps.getSession(); + if (!session.isLoggedIn) { + throw new NoSessionError(); + } + const authUser = session.user; + + // The memoized getters re-fetch only on failure, so retrying the batch + // re-derives only the keys that failed, not the ones already resolved. + const [ + encryptionPublicKey, + cashuLockingXpub, + sparkIdentityPublicKey, + accountRepository, + ] = await withRetry({ + fn: () => + Promise.all([ + deps.keys.getEncryptionPublicKey(), + deps.keys.getCashuLockingXpub(), + deps.keys.getSparkIdentityPublicKey(), + deps.getAccountRepository(), + ]), + }); + + const upsertRepository = new UpsertUserRepository( + deps.db, + accountRepository, + ); + + return withRetry({ + fn: () => + upsertRepository.upsert({ + id: authUser.id, + email: authUser.email, + emailVerified: authUser.email_verified, + accounts: [...defaultAccounts], + cashuLockingXpub, + encryptionPublicKey, + sparkIdentityPublicKey, + termsAcceptedAt: params.termsAcceptedAt, + giftCardMintTermsAcceptedAt: params.giftCardMintTermsAcceptedAt, + }), + retry: (attemptIndex, error) => { + if (error instanceof core.$ZodError) { + return false; + } + return attemptIndex < 2; + }, + }); + }, }; } diff --git a/packages/wallet-sdk/domain/user/user-service.ts b/packages/wallet-sdk/domain/user/user-service.ts index ec1a5e157..0ddad2ac9 100644 --- a/packages/wallet-sdk/domain/user/user-service.ts +++ b/packages/wallet-sdk/domain/user/user-service.ts @@ -2,6 +2,41 @@ import type { Account, ExtendedAccount } from '../accounts/account'; import type { User } from './user'; import type { UpdateUserRepository } from './user-repository'; +type UserDefaults = Pick; + +/** + * Returns true if the account is the user's default account for its currency. + */ +export function isDefaultAccount( + user: UserDefaults, + account: Pick, +): boolean { + if (account.currency === 'BTC') { + return user.defaultBtcAccountId === account.id; + } + if (account.currency === 'USD') { + return user.defaultUsdAccountId === account.id; + } + return false; +} + +/** + * Returns the accounts with the isDefault flag set to true if the account is the + * user's default account for the respective currency. Sorts the default account + * to the top. + */ +export function getExtendedAccounts( + user: UserDefaults, + accounts: Account[], +): ExtendedAccount[] { + return accounts + .map((account) => ({ + ...account, + isDefault: isDefaultAccount(user, account), + })) + .sort((_, b) => (b.isDefault ? 1 : -1)); +} + type SetDefaultAccountOptions = { /** * Whether to set the user'sdefault currency to the account's currency. @@ -13,35 +48,6 @@ type SetDefaultAccountOptions = { export class UserService { constructor(private readonly userRepository: UpdateUserRepository) {} - /** - * Returns true if the account is the user's default account for the respective currency. - */ - static isDefaultAccount(user: User, account: Account) { - if (account.currency === 'BTC') { - return user.defaultBtcAccountId === account.id; - } - if (account.currency === 'USD') { - return user.defaultUsdAccountId === account.id; - } - return false; - } - - /** - * Returns the accounts with the isDefault flag set to true if the account is the user's - * default account for the respective currency. Sorts the default account to the top. - */ - static getExtendedAccounts( - user: User, - accounts: Account[], - ): ExtendedAccount[] { - return accounts - .map((account) => ({ - ...account, - isDefault: UserService.isDefaultAccount(user, account), - })) - .sort((_, b) => (b.isDefault ? 1 : -1)); // Sort the default account to the top; - } - /** * Sets the account as the user's default account for the respective currency. * If setDefaultCurrency option is set to true, the user's default currency will also be set to the account's currency. diff --git a/packages/wallet-sdk/index.ts b/packages/wallet-sdk/index.ts index a759d3e89..bc4d81445 100644 --- a/packages/wallet-sdk/index.ts +++ b/packages/wallet-sdk/index.ts @@ -50,6 +50,10 @@ export { shouldAcceptTerms, shouldVerifyEmail, } from './domain/user/user'; +export { + getExtendedAccounts, + isDefaultAccount, +} from './domain/user/user-service'; export type { Contact } from './domain/contacts/contact'; export type { TransactionDirection, diff --git a/packages/wallet-sdk/session-keys.test.ts b/packages/wallet-sdk/session-keys.test.ts new file mode 100644 index 000000000..f0ddc1b0a --- /dev/null +++ b/packages/wallet-sdk/session-keys.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'bun:test'; +import { BASE_CASHU_LOCKING_DERIVATION_PATH } from './lib/cashu'; +import { deriveCashuXpub } from './lib/cryptography'; +import { createSessionKeys } from './session-keys'; + +const seedA = new Uint8Array(64).fill(1); +const seedB = new Uint8Array(64).fill(2); + +describe('createSessionKeys', () => { + it('memoizes each derivation within a session (the reader runs once)', async () => { + let calls = 0; + const keys = createSessionKeys({ + readCashuSeed: async () => { + calls += 1; + return seedA; + }, + }); + + await Promise.all([ + keys.getCashuSeed(), + keys.getCashuSeed(), + keys.getCashuSeed(), + ]); + await keys.getCashuSeed(); + + expect(calls).toBe(1); + }); + + it('serves the next session fresh keys after reset (a different user never gets the first user keys)', async () => { + let session: 'a' | 'b' = 'a'; + const keys = createSessionKeys({ + readEncryptionPublicKey: async () => + session === 'a' ? 'pub-a' : 'pub-b', + readCashuSeed: async () => (session === 'a' ? seedA : seedB), + readSparkMnemonic: async () => + session === 'a' ? 'mnemonic a' : 'mnemonic b', + }); + + expect(await keys.getEncryptionPublicKey()).toBe('pub-a'); + expect(await keys.getCashuSeed()).toBe(seedA); + expect(await keys.getSparkMnemonic()).toBe('mnemonic a'); + expect(await keys.getCashuLockingXpub()).toBe( + deriveCashuXpub(seedA, BASE_CASHU_LOCKING_DERIVATION_PATH), + ); + + session = 'b'; + keys.reset(); + + expect(await keys.getEncryptionPublicKey()).toBe('pub-b'); + expect(await keys.getCashuSeed()).toBe(seedB); + expect(await keys.getSparkMnemonic()).toBe('mnemonic b'); + expect(await keys.getCashuLockingXpub()).toBe( + deriveCashuXpub(seedB, BASE_CASHU_LOCKING_DERIVATION_PATH), + ); + }); + + it('does not let a derivation in flight at reset populate the next session', async () => { + let session: 'a' | 'b' = 'a'; + let releaseA: (value: string) => void = () => undefined; + const gate = new Promise((resolve) => { + releaseA = resolve; + }); + const keys = createSessionKeys({ + readSparkMnemonic: () => + session === 'a' ? gate : Promise.resolve('mnemonic b'), + }); + + const inFlight = keys.getSparkMnemonic(); + session = 'b'; + keys.reset(); + releaseA('mnemonic a'); + + // The caller that started the fetch under session a still resolves to its + // own session's value... + expect(await inFlight).toBe('mnemonic a'); + // ...but that stale resolution must not have populated the cache, so the + // next read derives session b fresh. + expect(await keys.getSparkMnemonic()).toBe('mnemonic b'); + }); +}); diff --git a/packages/wallet-sdk/session-keys.ts b/packages/wallet-sdk/session-keys.ts new file mode 100644 index 000000000..c7dcb94ec --- /dev/null +++ b/packages/wallet-sdk/session-keys.ts @@ -0,0 +1,161 @@ +import { getPrivateKeyBytes, getPublicKey } from '@agicash/opensecret'; +import { hexToBytes } from '@noble/hashes/utils'; +import { BASE_CASHU_LOCKING_DERIVATION_PATH, getCashuSeed } from './lib/cashu'; +import { deriveCashuXpub } from './lib/cryptography'; +import { type Encryption, getEncryption } from './lib/encryption'; +import { + getSparkIdentityPublicKeyFromMnemonic, + getSparkMnemonic, +} from './lib/spark/wallet'; + +// 10111099 is 'enc' (for encryption) in ascii +const encryptionKeyDerivationPath = `m/10111099'/0'`; + +/** + * The per-session key material the accounts and user namespaces derive from + * Open Secret. Every getter is memoized for the life of the session and + * cleared by {@link SessionKeys.reset}; a getter never resolves one user's key + * into the next user's session. + */ +export type SessionKeys = { + /** ECIES encryption functions bound to the session's encryption keypair. */ + getEncryption(): Promise; + /** Hex-encoded encryption public key, as persisted on the user row. */ + getEncryptionPublicKey(): Promise; + /** BIP39 master seed for Cashu wallet derivation. */ + getCashuSeed(): Promise; + /** BIP39 mnemonic for Spark wallet derivation. */ + getSparkMnemonic(): Promise; + /** Extended public key for locking proofs and mint quotes, as persisted on the user row. */ + getCashuLockingXpub(): Promise; + /** Spark identity public key, as persisted on the user row. */ + getSparkIdentityPublicKey(): Promise; + /** Clears every memo. Call on session end so the next user derives fresh keys. */ + reset(): void; +}; + +/** + * Test seam. Each reader defaults to the real Open Secret derivation; tests + * override them to assert the memo fencing without a live Open Secret. + */ +type SessionKeysDeps = { + readEncryptionPrivateKey?: () => Promise; + readEncryptionPublicKey?: () => Promise; + readCashuSeed?: () => Promise; + readSparkMnemonic?: () => Promise; +}; + +/** + * Memoizes an async derivation for the life of a session. A derivation started + * before a session ends belongs to that ended session: it captures the session + * scope's abort signal when it begins, and once that signal is aborted its + * result neither populates the cache nor releases the in-flight slot the next + * session owns. A rejection is not cached, so a retry can recover. + */ +function createMemo(fetcher: () => Promise, getScope: () => AbortSignal) { + let cached: { value: T } | undefined; + let inFlight: Promise | undefined; + + return { + clear: () => { + cached = undefined; + inFlight = undefined; + }, + get: (): Promise => { + if (cached) { + return Promise.resolve(cached.value); + } + if (!inFlight) { + const scope = getScope(); + inFlight = (async () => { + try { + const value = await fetcher(); + if (!scope.aborted) { + cached = { value }; + } + return value; + } finally { + if (!scope.aborted) { + inFlight = undefined; + } + } + })(); + } + return inFlight; + }, + }; +} + +const readEncryptionPrivateKey = (): Promise => + getPrivateKeyBytes({ + private_key_derivation_path: encryptionKeyDerivationPath, + }).then((response) => hexToBytes(response.private_key)); + +const readEncryptionPublicKey = (): Promise => + getPublicKey('schnorr', { + private_key_derivation_path: encryptionKeyDerivationPath, + }).then((response) => response.public_key); + +export function createSessionKeys(deps: SessionKeysDeps = {}): SessionKeys { + // Aborted and replaced on every session end (see reset). A derivation in + // flight when the session ends holds the signal it started under; once that + // signal is aborted its result belongs to a session that no longer exists + // and must not be cached into the next one. + let sessionScope = new AbortController(); + const getScope = () => sessionScope.signal; + + const encryptionPrivateKey = createMemo( + deps.readEncryptionPrivateKey ?? readEncryptionPrivateKey, + getScope, + ); + const encryptionPublicKey = createMemo( + deps.readEncryptionPublicKey ?? readEncryptionPublicKey, + getScope, + ); + const cashuSeed = createMemo(deps.readCashuSeed ?? getCashuSeed, getScope); + const sparkMnemonic = createMemo( + deps.readSparkMnemonic ?? getSparkMnemonic, + getScope, + ); + const cashuLockingXpub = createMemo( + async () => + deriveCashuXpub( + await cashuSeed.get(), + BASE_CASHU_LOCKING_DERIVATION_PATH, + ), + getScope, + ); + const sparkIdentityPublicKey = createMemo( + async () => + // Network is fixed to mainnet here; per-account network selection is + // not yet wired to config.spark.network. + getSparkIdentityPublicKeyFromMnemonic( + await sparkMnemonic.get(), + 'mainnet', + ), + getScope, + ); + + return { + getEncryption: async () => + getEncryption( + await encryptionPrivateKey.get(), + await encryptionPublicKey.get(), + ), + getEncryptionPublicKey: encryptionPublicKey.get, + getCashuSeed: cashuSeed.get, + getSparkMnemonic: sparkMnemonic.get, + getCashuLockingXpub: cashuLockingXpub.get, + getSparkIdentityPublicKey: sparkIdentityPublicKey.get, + reset: () => { + sessionScope.abort(); + sessionScope = new AbortController(); + encryptionPrivateKey.clear(); + encryptionPublicKey.clear(); + cashuSeed.clear(); + sparkMnemonic.clear(); + cashuLockingXpub.clear(); + sparkIdentityPublicKey.clear(); + }, + }; +} diff --git a/packages/wallet-sdk/temporary.ts b/packages/wallet-sdk/temporary.ts index e9c638cbe..5de3c52cc 100644 --- a/packages/wallet-sdk/temporary.ts +++ b/packages/wallet-sdk/temporary.ts @@ -104,6 +104,15 @@ export { export { CashuProofSchema, toProof } from './domain/accounts/cashu-account'; export { AccountRepository } from './domain/accounts/account-repository'; export { AccountService } from './domain/accounts/account-service'; +// Accounts-slice bridge (step 6): the internal-repo accessor hands unmigrated +// receive/send flows and realtime row mapping the live instance's domain +// accounts repository, and the session-keys accessor feeds the host's key +// queries the unmigrated flows still read. Removed at step 18 when those flows +// read from the SDK. +export { + getInternalAccountRepository, + getInternalSessionKeys, +} from './domain/sdk/sdk'; export { ReadUserDefaultAccountRepository, ReadUserRepository,