Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web-wallet-e2e/e2e/fixtures/open-secret/fixture.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
1 change: 1 addition & 0 deletions apps/web-wallet-e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"devDependencies": {
"@agicash/opensecret": "catalog:",
"@agicash/utils": "workspace:*",
"@playwright/test": "1.49.1",
"@stablelib/base64": "catalog:",
"@stablelib/chacha20poly1305": "catalog:",
Expand Down
6 changes: 6 additions & 0 deletions apps/web-wallet/app/entry.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { loadFeatureFlags } from './features/shared/feature-flags';
// later slice constructs the SDK explicitly at boot and moves feature flags
// onto the instance, dropping this ordering dependency (PR #1166).
import './features/shared/sdk.client';
import { registerSessionStarted } from './features/user/session-started';
import { registerMoneyDevToolsFormatter } from './lib/money-devtools-formatter';
import { getTracesSampleRate, sanitizeUrl } from './tracing-utils';

Expand Down Expand Up @@ -48,6 +49,11 @@ ensureBreezWasm().catch(() => {
configureFeatureFlags(agicashDbClient);
void loadFeatureFlags();

// Seed the user + accounts caches from the SDK's auth.session-started event once
// provisioning settles the identity. Registered before the router so the first
// protected middleware reads already-seeded caches.
registerSessionStarted();

const sentryDsn = import.meta.env.VITE_SENTRY_DSN ?? '';
if (!sentryDsn) {
throw new Error('VITE_SENTRY_DSN is not set');
Expand Down
50 changes: 20 additions & 30 deletions apps/web-wallet/app/features/accounts/account-hooks.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
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,
sparkDebugLog,
} from '@agicash/wallet-sdk/temporary';
Expand All @@ -26,9 +24,9 @@ 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';
Expand Down Expand Up @@ -117,8 +115,8 @@ export function useAccountsCache() {
* Hook that returns an account change handlers.
*/
export function useAccountChangeHandlers() {
const accountRepository = useAccountRepository();
const accountCache = useAccountsCache();
const accountRepository = useAccountRepository();

return [
{
Expand All @@ -138,13 +136,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
Expand Down Expand Up @@ -253,18 +248,17 @@ export function useAccounts<
select?: UseAccountsSelect<T, P>,
): UseSuspenseQueryResult<ExtendedAccount<T>[]> {
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)) {
Expand Down Expand Up @@ -330,14 +324,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;
},
Expand Down Expand Up @@ -468,14 +461,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<typeof accountService.addCashuAccount>[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.
Expand Down

This file was deleted.

1 change: 0 additions & 1 deletion apps/web-wallet/app/features/gift-cards/add-gift-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ function useAddGiftCard() {
name,
currency,
mintUrl: url,
type: 'cashu',
purpose: 'gift-card',
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type LongTimeout,
clearLongTimeout,
setLongTimeout,
withRetry,
} from '@agicash/utils';
import type {
CashuAccount,
Expand Down Expand Up @@ -37,16 +38,15 @@ import {
useQueryClient,
} from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAccountRepository } from '~/features/accounts/account-repository-hooks';
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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import {
useQueryClient,
} from '@tanstack/react-query';
import { useMemo } from 'react';
import { useAccountRepository } from '~/features/accounts/account-repository-hooks';
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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ export function AddMintForm() {
name: data.name,
currency: ACCOUNT_CURRENCY,
mintUrl: data.mintUrl,
type: 'cashu',
purpose,
});
toast({
Expand Down
7 changes: 4 additions & 3 deletions apps/web-wallet/app/features/shared/cashu-query-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import {
getMintInfo,
} from '@agicash/wallet-sdk/temporary';
import { type QueryClient, queryOptions } from '@tanstack/react-query';
import { derivedKeyQueryPrefix } from './session-key-queries';

export const seedQueryOptions = () =>
queryOptions({
queryKey: ['cashu-seed'],
queryKey: [derivedKeyQueryPrefix, 'cashu-seed'],
queryFn: () => getCashuSeed(),
staleTime: Number.POSITIVE_INFINITY,
});
Expand All @@ -20,7 +21,7 @@ export const xpubQueryOptions = ({
derivationPath,
}: { queryClient: QueryClient; derivationPath?: string }) =>
queryOptions({
queryKey: ['cashu-xpub', derivationPath],
queryKey: [derivedKeyQueryPrefix, 'cashu-xpub', derivationPath],
queryFn: async () =>
deriveCashuXpub(
await queryClient.fetchQuery(seedQueryOptions()),
Expand All @@ -33,7 +34,7 @@ const privateKeyQueryOptions = ({
derivationPath,
}: { derivationPath?: string } = {}) =>
queryOptions({
queryKey: ['cashu-private-key', derivationPath],
queryKey: [derivedKeyQueryPrefix, 'cashu-private-key', derivationPath],
queryFn: () => getCashuPrivateKey(derivationPath),
staleTime: Number.POSITIVE_INFINITY,
});
Expand Down
74 changes: 34 additions & 40 deletions apps/web-wallet/app/features/shared/encryption-hooks.ts
Comment thread
pmilic021 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,43 +1,42 @@
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 {
decryptBatchWithPrivateKey,
decryptWithPrivateKey,
encryptBatchToPublicKey,
encryptToPublicKey,
readEncryptionPrivateKey,
readEncryptionPublicKey,
} from '@agicash/wallet-sdk/temporary';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { derivedKeyQueryPrefix } from './session-key-queries';

// 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: [derivedKeyQueryPrefix, 'encryption'],
// Derives then wraps so the raw private-key bytes are never stored in the
// query cache — only the encrypt/decrypt closures that capture them are.
// TEMPORARY: duplicates the SDK session-key facade's construction
// (packages/wallet-sdk/domain/sdk/session-keys.ts builds the same object
// literal from these primitives). Deleted at step 18 when receive/send/claim
// migrate into the SDK and this query is removed.
queryFn: async (): Promise<Encryption> => {
const privateKey = await readEncryptionPrivateKey();
const publicKeyHex = await readEncryptionPublicKey();
return {
encrypt: async <T = unknown>(data: T) =>
encryptToPublicKey(data, publicKeyHex),
decrypt: async <T = unknown>(data: string) =>
decryptWithPrivateKey<T>(data, privateKey),
encryptBatch: async <T extends readonly unknown[]>(data: T) =>
encryptBatchToPublicKey(data, publicKeyHex),
decryptBatch: async <T extends readonly unknown[]>(
data: readonly [...{ [K in keyof T]: string }],
) => decryptBatchWithPrivateKey<T>(data, privateKey),
};
},
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.
Expand All @@ -48,11 +47,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;
};
56 changes: 56 additions & 0 deletions apps/web-wallet/app/features/shared/session-key-queries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'bun:test';
import { QueryClient } from '@tanstack/react-query';
import { seedQueryOptions, xpubQueryOptions } from './cashu-query-options';
import { encryptionQueryOptions } from './encryption-hooks';
import {
derivedKeyQueryPrefix,
evictDerivedKeyQueries,
} from './session-key-queries';
import { sparkMnemonicQueryOptions } from './spark-query-options';

describe('evictDerivedKeyQueries', () => {
it('drops every derived-key query under the shared prefix (incl. derivation-path variants) in one removeQueries, and leaves others', () => {
const queryClient = new QueryClient();
// The five derived-key queries, each under the shared prefix, mirroring the
// shapes the query defs produce (two carry a derivation-path segment).
const derivedKeys = [
[derivedKeyQueryPrefix, 'encryption'],
[derivedKeyQueryPrefix, 'cashu-seed'],
[derivedKeyQueryPrefix, 'cashu-xpub', "m/0'"],
[derivedKeyQueryPrefix, 'cashu-private-key', "m/0'"],
[derivedKeyQueryPrefix, 'spark-mnemonic'],
];
for (const queryKey of derivedKeys) {
queryClient.setQueryData(queryKey, 'previous-user');
}
// A non-derived query must survive the prefix eviction.
queryClient.setQueryData(['auth-state'], 'keep');

evictDerivedKeyQueries(queryClient);

for (const queryKey of derivedKeys) {
expect(queryClient.getQueryData(queryKey)).toBeUndefined();
}
expect(queryClient.getQueryData<string>(['auth-state'])).toBe('keep');
});
});

describe('derived-key query defs', () => {
it('key every derived-key query under the shared prefix, so evictDerivedKeyQueries drops them', () => {
// Asserts the defs themselves adopt the prefix: a def regressing to a bare
// key — a silent cache-key mismatch typecheck cannot catch — fails here, not
// only in the grep gate. Covers the cashu-xpub derivation-path variant both
// with and without a path.
const queryClient = new QueryClient();
const derivedKeyDefs = [
encryptionQueryOptions().queryKey,
seedQueryOptions().queryKey,
xpubQueryOptions({ queryClient, derivationPath: "m/0'" }).queryKey,
xpubQueryOptions({ queryClient }).queryKey,
sparkMnemonicQueryOptions().queryKey,
];
for (const queryKey of derivedKeyDefs) {
expect(queryKey[0]).toBe(derivedKeyQueryPrefix);
}
});
});
Loading
Loading