From 401c82dfe1397bceb1f6e2cc3fccbed01542a501 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Wed, 9 Sep 2026 11:39:25 +0200 Subject: [PATCH 1/5] fix(frontend): load OISY Trade balances at initialization The hero net worth counts DEX-deposited balances via `oisyTradeUsdValue`, but `loadOisyTrade` only ran from the Trading tab and the OISY Trade page, so a fresh login showed a total that excluded them until one of those surfaces was visited. Add `LoaderOisyTrade` to the app-wide `Loaders` tree, mirroring `LoaderLiquidium` (the Earn data), so pairs, supported tokens, balances and orders load during initialization. Co-Authored-By: Claude Opus 5 --- .../components/loaders/LoaderOisyTrade.svelte | 16 ++++++ .../src/lib/components/loaders/Loaders.svelte | 3 + .../loaders/LoaderOisyTrade.spec.ts | 57 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/frontend/src/lib/components/loaders/LoaderOisyTrade.svelte create mode 100644 src/frontend/src/tests/lib/components/loaders/LoaderOisyTrade.spec.ts diff --git a/src/frontend/src/lib/components/loaders/LoaderOisyTrade.svelte b/src/frontend/src/lib/components/loaders/LoaderOisyTrade.svelte new file mode 100644 index 00000000000..445b9dc49ca --- /dev/null +++ b/src/frontend/src/lib/components/loaders/LoaderOisyTrade.svelte @@ -0,0 +1,16 @@ + diff --git a/src/frontend/src/lib/components/loaders/Loaders.svelte b/src/frontend/src/lib/components/loaders/Loaders.svelte index a4324e638e7..976df119bcd 100644 --- a/src/frontend/src/lib/components/loaders/Loaders.svelte +++ b/src/frontend/src/lib/components/loaders/Loaders.svelte @@ -14,6 +14,7 @@ import LoaderHarvest from '$lib/components/loaders/LoaderHarvest.svelte'; import LoaderLiquidium from '$lib/components/loaders/LoaderLiquidium.svelte'; import LoaderMetamask from '$lib/components/loaders/LoaderMetamask.svelte'; + import LoaderOisyTrade from '$lib/components/loaders/LoaderOisyTrade.svelte'; import LoaderSwapTokens from '$lib/components/loaders/LoaderSwapTokens.svelte'; import LoaderTokens from '$lib/components/loaders/LoaderTokens.svelte'; import LoaderUserProfile from '$lib/components/loaders/LoaderUserProfile.svelte'; @@ -62,6 +63,8 @@ + + {@render children()} diff --git a/src/frontend/src/tests/lib/components/loaders/LoaderOisyTrade.spec.ts b/src/frontend/src/tests/lib/components/loaders/LoaderOisyTrade.spec.ts new file mode 100644 index 00000000000..d121fa8bb89 --- /dev/null +++ b/src/frontend/src/tests/lib/components/loaders/LoaderOisyTrade.spec.ts @@ -0,0 +1,57 @@ +import LoaderOisyTrade from '$lib/components/loaders/LoaderOisyTrade.svelte'; +import { mockAuthStore } from '$tests/mocks/auth.mock'; +import { mockIdentity } from '$tests/mocks/identity.mock'; +import { render, waitFor } from '@testing-library/svelte'; + +const { mockTradingEnabled, mockLoadOisyTrade } = vi.hoisted(() => ({ + mockTradingEnabled: { value: true }, + mockLoadOisyTrade: vi.fn(() => Promise.resolve(undefined)) +})); + +vi.mock('$env/trading', () => ({ + get anyTradingProviderEnabled() { + return mockTradingEnabled.value; + } +})); + +vi.mock('$lib/services/oisy-trade.services', () => ({ + loadOisyTrade: mockLoadOisyTrade +})); + +describe('LoaderOisyTrade', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockTradingEnabled.value = true; + }); + + it('should load the OISY Trade data when an identity is available', async () => { + mockAuthStore(); + + render(LoaderOisyTrade); + + await waitFor(() => { + expect(mockLoadOisyTrade).toHaveBeenCalledExactlyOnceWith({ identity: mockIdentity }); + }); + }); + + // Signed out, `loadOisyTrade` resets the store, so the loader must still call it. + it('should call the loader with a nullish identity when signed out', async () => { + mockAuthStore(null); + + render(LoaderOisyTrade); + + await waitFor(() => { + expect(mockLoadOisyTrade).toHaveBeenCalledExactlyOnceWith({ identity: null }); + }); + }); + + it('should not load anything when no trading provider is enabled', () => { + mockTradingEnabled.value = false; + mockAuthStore(); + + render(LoaderOisyTrade); + + expect(mockLoadOisyTrade).not.toHaveBeenCalled(); + }); +}); From 0bf55066fabc3500b94f920bae31a7d97e29e93a Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Wed, 9 Sep 2026 13:10:14 +0200 Subject: [PATCH 2/5] fix(frontend): drop an OISY Trade load whose identity is no longer current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `loadOisyTrade` captured the identity at call time, awaited four canister calls and then committed to `oisyTradeStore` unconditionally. Making the load app-wide put that on the sign-out path: `LoaderOisyTrade` re-runs its effect with a nullish identity and resets the store, while the in-flight load for the previous account can still resolve afterwards and write its balances back — so the hero kept summing them until the reload committed. Re-read `authIdentity` at the commit point and drop the result when the principal no longer matches. Guarding in the service rather than the loader covers the deposit, withdraw and limit-order reload paths too. Co-Authored-By: Claude Opus 5 --- .../src/lib/services/oisy-trade.services.ts | 13 +++ .../lib/services/oisy-trade.services.spec.ts | 79 ++++++++++++++++--- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/frontend/src/lib/services/oisy-trade.services.ts b/src/frontend/src/lib/services/oisy-trade.services.ts index 0046c3ebc10..81043135bc1 100644 --- a/src/frontend/src/lib/services/oisy-trade.services.ts +++ b/src/frontend/src/lib/services/oisy-trade.services.ts @@ -23,6 +23,7 @@ import { OISY_TRADE_MAX_ORDER_PAGES, OISY_TRADE_ORDERS_PAGE_SIZE } from '$lib/constants/oisy-trade.constants'; +import { authIdentity } from '$lib/derived/auth.derived'; import { ProgressStepsTradingWithdraw } from '$lib/enums/progress-steps'; import { i18n } from '$lib/stores/i18n.store'; import { oisyTradeStore } from '$lib/stores/oisy-trade.store'; @@ -76,6 +77,14 @@ const loadMyOrders = async ({ return orders; }; +// The load is fire-and-forget and app-wide (`LoaderOisyTrade`), so a request +// started for one identity can resolve after a sign-out has already reset the +// store — or after a newer load has written. Re-reading `authIdentity` at the +// commit point and dropping a result whose principal is no longer the current +// one keeps the store from being repopulated with the previous account's data. +const isCurrentIdentity = (identity: NonNullable): boolean => + get(authIdentity)?.getPrincipal().toText() === identity.getPrincipal().toText(); + // Best-effort load of trading pairs, supported tokens and the caller's DEX // balances into `oisyTradeStore`; errors are logged so a transient canister // failure never breaks the Trading tab. Read-only. @@ -97,6 +106,10 @@ export const loadOisyTrade = async ({ identity }: { identity: NullishIdentity }) loadMyOrders({ identity, nullishIdentityErrorMessage }) ]); + if (!isCurrentIdentity(identity)) { + return; + } + oisyTradeStore.set({ pairs, supportedTokens, balances, orders }); } catch (err: unknown) { consoleError(err); diff --git a/src/frontend/src/tests/lib/services/oisy-trade.services.spec.ts b/src/frontend/src/tests/lib/services/oisy-trade.services.spec.ts index dcacb52774b..fff7f3d1493 100644 --- a/src/frontend/src/tests/lib/services/oisy-trade.services.spec.ts +++ b/src/frontend/src/tests/lib/services/oisy-trade.services.spec.ts @@ -18,7 +18,9 @@ import { withdrawFromOisyTrade } from '$lib/services/oisy-trade.services'; import { oisyTradeStore } from '$lib/stores/oisy-trade.store'; -import { mockIdentity } from '$tests/mocks/identity.mock'; +import { mockAuthStore } from '$tests/mocks/auth.mock'; +import { mockIdentity, mockPrincipal2 } from '$tests/mocks/identity.mock'; +import type { Identity } from '@icp-sdk/core/agent'; import { Principal } from '@icp-sdk/core/principal'; import { get } from 'svelte/store'; @@ -37,9 +39,22 @@ describe('oisy-trade.services', () => { const balances = [{ balance: { free: 1n, reserved: ZERO } }] as unknown as UserTokenBalance[]; const orders = [{ id: 'order-1' }] as unknown as UserOrder[]; + const resetStoreValue = { + pairs: undefined, + supportedTokens: undefined, + balances: undefined, + orders: undefined + }; + + // A second signed-in account, to drive the identity-transition guard. + const otherIdentity = { getPrincipal: () => mockPrincipal2 } as unknown as Identity; + beforeEach(() => { vi.clearAllMocks(); oisyTradeStore.reset(); + // The store commit is guarded on the identity still being the current one, + // so the loading identity has to be the signed-in one for a write to land. + mockAuthStore(); vi.mocked(oisyTradeApi.getTradingPairs).mockResolvedValue(pairs); vi.mocked(oisyTradeApi.listSupportedTokens).mockResolvedValue(supportedTokens); vi.mocked(oisyTradeApi.getBalances).mockResolvedValue(balances); @@ -53,12 +68,7 @@ describe('oisy-trade.services', () => { await loadOisyTrade({ identity: null }); - expect(get(oisyTradeStore)).toEqual({ - pairs: undefined, - supportedTokens: undefined, - balances: undefined, - orders: undefined - }); + expect(get(oisyTradeStore)).toEqual(resetStoreValue); expect(oisyTradeApi.getTradingPairs).not.toHaveBeenCalled(); }); @@ -135,12 +145,55 @@ describe('oisy-trade.services', () => { await expect(loadOisyTrade({ identity: mockIdentity })).resolves.toBeUndefined(); - expect(get(oisyTradeStore)).toEqual({ - pairs: undefined, - supportedTokens: undefined, - balances: undefined, - orders: undefined - }); + expect(get(oisyTradeStore)).toEqual(resetStoreValue); + }); + + it('does not repopulate the store when the load resolves after a sign-out', async () => { + let resolveBalances: (value: UserTokenBalance[]) => void = () => undefined; + vi.mocked(oisyTradeApi.getBalances).mockReturnValue( + new Promise((resolve) => { + resolveBalances = resolve; + }) + ); + + // The signed-in load is still in flight when the user signs out. + const pending = loadOisyTrade({ identity: mockIdentity }); + + mockAuthStore(null); + await loadOisyTrade({ identity: null }); + + expect(get(oisyTradeStore)).toEqual(resetStoreValue); + + // The older request resolves last — it must not undo the reset. + resolveBalances(balances); + await pending; + + expect(get(oisyTradeStore)).toEqual(resetStoreValue); + }); + + it('does not overwrite a newer account when the older load resolves last', async () => { + const otherBalances = [ + { balance: { free: 2n, reserved: ZERO } } + ] as unknown as UserTokenBalance[]; + + let resolveBalances: (value: UserTokenBalance[]) => void = () => undefined; + vi.mocked(oisyTradeApi.getBalances).mockReturnValueOnce( + new Promise((resolve) => { + resolveBalances = resolve; + }) + ); + + const pending = loadOisyTrade({ identity: mockIdentity }); + + // The second account signs in and its load completes first. + mockAuthStore(otherIdentity); + vi.mocked(oisyTradeApi.getBalances).mockResolvedValue(otherBalances); + await loadOisyTrade({ identity: otherIdentity }); + + resolveBalances(balances); + await pending; + + expect(get(oisyTradeStore).balances).toEqual(otherBalances); }); }); From 5c2c887b8bad5f96f242f7b60c914c41d657859d Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Wed, 9 Sep 2026 13:18:44 +0200 Subject: [PATCH 3/5] fix(frontend): gate the OISY Trade loader on the provider flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LoaderOisyTrade` guarded on `anyTradingProviderEnabled`, the surface-level aggregate, while everything it does is provider-specific — `loadOisyTrade` talks to the oisy_trade canister. The two are the same value today, but the codebase models the Trading surface staying reachable through another provider with OISY TRADE off, and in that configuration the loader would call the disabled provider's APIs on every initialization. Gate on `OISY_TRADE_ENABLED` instead, matching the split `TradingList` and `OisyTradeProvider` already make, and drive both flags independently in the spec so the mixed-provider configuration is covered. Co-Authored-By: Claude Opus 5 --- .../components/loaders/LoaderOisyTrade.svelte | 9 ++++-- .../loaders/LoaderOisyTrade.spec.ts | 28 +++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/lib/components/loaders/LoaderOisyTrade.svelte b/src/frontend/src/lib/components/loaders/LoaderOisyTrade.svelte index 445b9dc49ca..222d991c9a0 100644 --- a/src/frontend/src/lib/components/loaders/LoaderOisyTrade.svelte +++ b/src/frontend/src/lib/components/loaders/LoaderOisyTrade.svelte @@ -1,13 +1,18 @@ diff --git a/src/frontend/src/lib/services/oisy-trade.services.ts b/src/frontend/src/lib/services/oisy-trade.services.ts index 4d48e6c1ec8..73acf268a37 100644 --- a/src/frontend/src/lib/services/oisy-trade.services.ts +++ b/src/frontend/src/lib/services/oisy-trade.services.ts @@ -132,6 +132,45 @@ export const loadOisyTrade = async ({ identity }: { identity: NullishIdentity }) } }; +// Balances-only load for the app-wide `LoaderOisyTrade`: the hero's net worth is +// the sole consumer outside the Trading surfaces, and `oisyTradeUsdValue` derives +// from the balances joined against `enabledIcTokens` and `exchanges` — nothing +// else the full load fetches is read there. One query instead of four to eight, +// and the total no longer waits on the caller's order history. The write goes +// through `setBalances` so it cannot blank what the Trading tab has loaded, +// mirroring `loadOisyTradeSwapPairs`/`setPairs` for the quote path. +// +// Shares `loadGeneration` with the full load, so whichever started last wins. A +// full load losing to this one would drop its pairs/tokens/orders, but the +// app-wide effect only re-runs on an identity change, and that resets the store +// anyway. Best-effort: errors are logged, never surfaced. +export const loadOisyTradeBalances = async ({ + identity +}: { + identity: NullishIdentity; +}): Promise => { + const generation = ++loadGeneration; + + if (isNullish(identity)) { + oisyTradeStore.reset(); + return; + } + + const nullishIdentityErrorMessage = get(i18n).auth.error.no_internet_identity; + + try { + const balances = await getBalances({ identity, nullishIdentityErrorMessage }); + + if (!isCurrentLoad({ generation, identity })) { + return; + } + + oisyTradeStore.setBalances(balances); + } catch (err: unknown) { + consoleError(err); + } +}; + // Withdraws `amount` (the gross figure entered by the user) from the caller's // free DEX balance back to their wallet. The ledger transfer fee is deducted by // the canister, so the user receives `amount - ledger_fee`. On success the diff --git a/src/frontend/src/lib/stores/oisy-trade.store.ts b/src/frontend/src/lib/stores/oisy-trade.store.ts index 360ece330f1..783ced881d8 100644 --- a/src/frontend/src/lib/stores/oisy-trade.store.ts +++ b/src/frontend/src/lib/stores/oisy-trade.store.ts @@ -1,4 +1,4 @@ -import type { TradingPairInfo } from '$declarations/oisy_trade/oisy_trade.did'; +import type { TradingPairInfo, UserTokenBalance } from '$declarations/oisy_trade/oisy_trade.did'; import type { OisyTradeStoreData } from '$lib/types/oisy-trade'; import { writable, type Readable } from 'svelte/store'; @@ -15,6 +15,16 @@ export interface OisyTradeStore extends Readable { * each other, and the fetch can be skipped when either has already run. */ setPairs: (pairs: TradingPairInfo[]) => void; + /** + * Writes only `balances`, leaving `pairs` / `supportedTokens` / `orders` + * untouched. + * + * Same reasoning as `setPairs`, for the other narrow consumer: the hero's net + * worth needs the DEX balances and nothing else, so the app-wide loader + * (`LoaderOisyTrade`) fetches just those — writing them through `set` would + * blank whatever the Trading surfaces had loaded. + */ + setBalances: (balances: UserTokenBalance[]) => void; reset: () => void; } @@ -31,6 +41,7 @@ const initOisyTradeStore = (): OisyTradeStore => { subscribe, set, setPairs: (pairs: TradingPairInfo[]) => update((state) => ({ ...state, pairs })), + setBalances: (balances: UserTokenBalance[]) => update((state) => ({ ...state, balances })), reset: () => set(defaultStoreValue) }; }; diff --git a/src/frontend/src/tests/lib/components/loaders/LoaderOisyTrade.spec.ts b/src/frontend/src/tests/lib/components/loaders/LoaderOisyTrade.spec.ts index a0552464a1e..bc571e936af 100644 --- a/src/frontend/src/tests/lib/components/loaders/LoaderOisyTrade.spec.ts +++ b/src/frontend/src/tests/lib/components/loaders/LoaderOisyTrade.spec.ts @@ -3,10 +3,10 @@ import { mockAuthStore } from '$tests/mocks/auth.mock'; import { mockIdentity } from '$tests/mocks/identity.mock'; import { render, waitFor } from '@testing-library/svelte'; -const { mockTradingEnabled, mockProviderEnabled, mockLoadOisyTrade } = vi.hoisted(() => ({ +const { mockTradingEnabled, mockProviderEnabled, mockLoadOisyTradeBalances } = vi.hoisted(() => ({ mockTradingEnabled: { value: true }, mockProviderEnabled: { value: true }, - mockLoadOisyTrade: vi.fn(() => Promise.resolve(undefined)) + mockLoadOisyTradeBalances: vi.fn(() => Promise.resolve(undefined)) })); // The two flags are mocked independently, as `OisyTradeProvider.svelte.spec.ts` @@ -26,7 +26,7 @@ vi.mock('$env/oisy-trade', () => ({ })); vi.mock('$lib/services/oisy-trade.services', () => ({ - loadOisyTrade: mockLoadOisyTrade + loadOisyTradeBalances: mockLoadOisyTradeBalances })); describe('LoaderOisyTrade', () => { @@ -37,24 +37,24 @@ describe('LoaderOisyTrade', () => { mockProviderEnabled.value = true; }); - it('should load the OISY Trade data when an identity is available', async () => { + it('should load the OISY Trade balances when an identity is available', async () => { mockAuthStore(); render(LoaderOisyTrade); await waitFor(() => { - expect(mockLoadOisyTrade).toHaveBeenCalledExactlyOnceWith({ identity: mockIdentity }); + expect(mockLoadOisyTradeBalances).toHaveBeenCalledExactlyOnceWith({ identity: mockIdentity }); }); }); - // Signed out, `loadOisyTrade` resets the store, so the loader must still call it. + // Signed out, `loadOisyTradeBalances` resets the store, so the loader must still call it. it('should call the loader with a nullish identity when signed out', async () => { mockAuthStore(null); render(LoaderOisyTrade); await waitFor(() => { - expect(mockLoadOisyTrade).toHaveBeenCalledExactlyOnceWith({ identity: null }); + expect(mockLoadOisyTradeBalances).toHaveBeenCalledExactlyOnceWith({ identity: null }); }); }); @@ -64,7 +64,7 @@ describe('LoaderOisyTrade', () => { render(LoaderOisyTrade); - expect(mockLoadOisyTrade).not.toHaveBeenCalled(); + expect(mockLoadOisyTradeBalances).not.toHaveBeenCalled(); }); it('should not load anything when OISY Trade is off but another provider keeps the surface on', () => { @@ -74,6 +74,6 @@ describe('LoaderOisyTrade', () => { render(LoaderOisyTrade); - expect(mockLoadOisyTrade).not.toHaveBeenCalled(); + expect(mockLoadOisyTradeBalances).not.toHaveBeenCalled(); }); }); diff --git a/src/frontend/src/tests/lib/services/oisy-trade.services.spec.ts b/src/frontend/src/tests/lib/services/oisy-trade.services.spec.ts index 79a96839063..3cd9f3c102d 100644 --- a/src/frontend/src/tests/lib/services/oisy-trade.services.spec.ts +++ b/src/frontend/src/tests/lib/services/oisy-trade.services.spec.ts @@ -15,6 +15,7 @@ import { ProgressStepsTradingWithdraw } from '$lib/enums/progress-steps'; import { cancelLimitOrder, loadOisyTrade, + loadOisyTradeBalances, withdrawFromOisyTrade } from '$lib/services/oisy-trade.services'; import { oisyTradeStore } from '$lib/stores/oisy-trade.store'; @@ -224,6 +225,75 @@ describe('oisy-trade.services', () => { }); }); + describe('loadOisyTradeBalances', () => { + it('fetches only the balances and leaves the other fields untouched', async () => { + await loadOisyTradeBalances({ identity: mockIdentity }); + + expect(get(oisyTradeStore)).toEqual({ + ...resetStoreValue, + balances + }); + expect(oisyTradeApi.getBalances).toHaveBeenCalledOnce(); + expect(oisyTradeApi.getTradingPairs).not.toHaveBeenCalled(); + expect(oisyTradeApi.listSupportedTokens).not.toHaveBeenCalled(); + expect(oisyTradeApi.getMyOrders).not.toHaveBeenCalled(); + }); + + it('does not blank what the full load already wrote', async () => { + await loadOisyTrade({ identity: mockIdentity }); + + const newerBalances = [ + { balance: { free: 9n, reserved: ZERO } } + ] as unknown as UserTokenBalance[]; + vi.mocked(oisyTradeApi.getBalances).mockResolvedValue(newerBalances); + + await loadOisyTradeBalances({ identity: mockIdentity }); + + expect(get(oisyTradeStore)).toEqual({ + pairs, + supportedTokens, + orders, + balances: newerBalances + }); + }); + + it('resets the store and does not call the canister when there is no identity', async () => { + oisyTradeStore.set({ pairs, supportedTokens, balances, orders }); + + await loadOisyTradeBalances({ identity: null }); + + expect(get(oisyTradeStore)).toEqual(resetStoreValue); + expect(oisyTradeApi.getBalances).not.toHaveBeenCalled(); + }); + + it('does not commit when the identity changed while in flight', async () => { + let resolveBalances: (value: UserTokenBalance[]) => void = () => undefined; + vi.mocked(oisyTradeApi.getBalances).mockReturnValue( + new Promise((resolve) => { + resolveBalances = resolve; + }) + ); + + const pending = loadOisyTradeBalances({ identity: mockIdentity }); + + mockAuthStore(null); + await loadOisyTradeBalances({ identity: null }); + + resolveBalances(balances); + await pending; + + expect(get(oisyTradeStore)).toEqual(resetStoreValue); + }); + + it('swallows canister errors and leaves the store unchanged', async () => { + vi.mocked(oisyTradeApi.getBalances).mockRejectedValue(new Error('canister down')); + + await expect(loadOisyTradeBalances({ identity: mockIdentity })).resolves.toBeUndefined(); + + expect(get(oisyTradeStore)).toEqual(resetStoreValue); + }); + }); + describe('withdrawFromOisyTrade', () => { const tokenId: TokenId = { ledger_id: Principal.fromText('ryjl3-tyaaa-aaaaa-aaaba-cai') };