diff --git a/src/frontend/src/btc/schedulers/btc-wallet.scheduler.ts b/src/frontend/src/btc/schedulers/btc-wallet.scheduler.ts index 3a27c073e2..8842db60dc 100644 --- a/src/frontend/src/btc/schedulers/btc-wallet.scheduler.ts +++ b/src/frontend/src/btc/schedulers/btc-wallet.scheduler.ts @@ -187,6 +187,7 @@ export class BtcWalletScheduler implements Scheduler const newBalance = isNullish(this.store.balance) || this.store.balance.data !== balance.data || + // TODO, align with sol-wallet.scheduler.ts, crash if certified changes (!this.store.balance.certified && balance.certified); const newTransactions = uncertifiedTransactions.length > 0; diff --git a/src/frontend/src/sol/schedulers/sol-wallet.scheduler.ts b/src/frontend/src/sol/schedulers/sol-wallet.scheduler.ts new file mode 100644 index 0000000000..6b418c83aa --- /dev/null +++ b/src/frontend/src/sol/schedulers/sol-wallet.scheduler.ts @@ -0,0 +1,116 @@ +import { WALLET_TIMER_INTERVAL_MILLIS } from '$lib/constants/app.constants'; +import { SchedulerTimer, type Scheduler, type SchedulerJobData } from '$lib/schedulers/scheduler'; +import type { SolAddress } from '$lib/types/address'; +import type { + PostMessageDataRequestSol, + PostMessageDataResponseError +} from '$lib/types/post-message'; +import type { CertifiedData } from '$lib/types/store'; +import { loadSolLamportsBalance } from '$sol/api/solana.api'; +import type { SolanaNetworkType } from '$sol/types/network'; +import type { SolPostMessageDataResponseWallet } from '$sol/types/sol-post-message'; +import { assertNonNullish, isNullish } from '@dfinity/utils'; + +interface LoadSolWalletParams { + solanaNetwork: SolanaNetworkType; + address: SolAddress; +} + +interface SolWalletStore { + balance: CertifiedData | undefined; +} + +interface SolWalletData { + balance: CertifiedData; +} + +export class SolWalletScheduler implements Scheduler { + private timer = new SchedulerTimer('syncSolWalletStatus'); + + private store: SolWalletStore = { + balance: undefined + }; + + stop() { + this.timer.stop(); + } + + async start(data: PostMessageDataRequestSol | undefined) { + await this.timer.start({ + interval: WALLET_TIMER_INTERVAL_MILLIS, + job: this.syncWallet, + data + }); + } + + async trigger(data: PostMessageDataRequestSol | undefined) { + await this.timer.trigger({ + job: this.syncWallet, + data + }); + } + + private loadBalance = async ({ + address, + solanaNetwork + }: LoadSolWalletParams): Promise> => ({ + data: await loadSolLamportsBalance({ network: solanaNetwork, address }), + certified: false + }); + + private syncWallet = async ({ data }: SchedulerJobData) => { + assertNonNullish(data, 'No data provided to get Solana balance.'); + + try { + const balance = await this.loadBalance({ + address: data.address.data, + solanaNetwork: data.solanaNetwork + }); + + //todo implement loading transactions + + this.syncWalletData({ response: { balance } }); + } catch (error: unknown) { + this.postMessageWalletError({ error }); + } + }; + + private syncWalletData = ({ response: { balance } }: { response: SolWalletData }) => { + if (!this.store.balance?.certified && balance.certified) { + throw new Error('Balance certification status cannot change from uncertified to certified'); + } + + const newBalance = isNullish(this.store.balance) || this.store.balance.data !== balance.data; + + if (!newBalance) { + return; + } + + this.store = { + ...this.store, + balance + }; + + this.postMessageWallet({ + wallet: { + balance + } + }); + }; + + private postMessageWallet(data: SolPostMessageDataResponseWallet) { + this.timer.postMsg({ + msg: 'syncSolWallet', + data + }); + } + + protected postMessageWalletError({ error }: { error: unknown }) { + this.timer.postMsg({ + msg: 'syncSolWalletError', + data: { + error + } + }); + } +} diff --git a/src/frontend/src/sol/schema/sol-post-message.schema.ts b/src/frontend/src/sol/schema/sol-post-message.schema.ts index ccc686c63a..1617e0fc80 100644 --- a/src/frontend/src/sol/schema/sol-post-message.schema.ts +++ b/src/frontend/src/sol/schema/sol-post-message.schema.ts @@ -1,13 +1,9 @@ -import { - JsonTransactionsTextSchema, - PostMessageDataResponseSchema -} from '$lib/schema/post-message.schema'; +import { PostMessageDataResponseSchema } from '$lib/schema/post-message.schema'; import type { CertifiedData } from '$lib/types/store'; import { z } from 'zod'; const SolPostMessageWalletDataSchema = z.object({ - balance: z.custom>(), - newTransactions: JsonTransactionsTextSchema + balance: z.custom>() }); export const SolPostMessageDataResponseWalletSchema = PostMessageDataResponseSchema.extend({ diff --git a/src/frontend/src/sol/services/worker.sol-wallet.services.ts b/src/frontend/src/sol/services/worker.sol-wallet.services.ts new file mode 100644 index 0000000000..700f9f271c --- /dev/null +++ b/src/frontend/src/sol/services/worker.sol-wallet.services.ts @@ -0,0 +1,95 @@ +import { + solAddressDevnetStore, + solAddressLocalnetStore, + solAddressMainnetStore, + solAddressTestnetStore +} from '$lib/stores/address.store'; +import type { WalletWorker } from '$lib/types/listener'; +import type { + PostMessage, + PostMessageDataRequestSol, + PostMessageDataResponseError +} from '$lib/types/post-message'; +import type { Token } from '$lib/types/token'; +import { + isNetworkIdSOLDevnet, + isNetworkIdSOLLocal, + isNetworkIdSOLTestnet +} from '$lib/utils/network.utils'; +import { syncWallet, syncWalletError } from '$sol/services/sol-listener.services'; +import type { SolPostMessageDataResponseWallet } from '$sol/types/sol-post-message'; +import { mapNetworkIdToNetwork } from '$sol/utils/network.utils'; +import { assertNonNullish } from '@dfinity/utils'; +import { get } from 'svelte/store'; + +export const initSolWalletWorker = async ({ token }: { token: Token }): Promise => { + const { + id: tokenId, + network: { id: networkId } + } = token; + + const WalletWorker = await import('$sol/workers/sol-wallet.worker?worker'); + const worker: Worker = new WalletWorker.default(); + + const isTestnetNetwork = isNetworkIdSOLTestnet(networkId); + const isDevnetNetwork = isNetworkIdSOLDevnet(networkId); + const isLocalNetwork = isNetworkIdSOLLocal(networkId); + + worker.onmessage = ({ data }: MessageEvent>) => { + const { msg } = data; + + switch (msg) { + case 'syncSolWallet': + syncWallet({ + tokenId, + data: data.data as SolPostMessageDataResponseWallet + }); + return; + + case 'syncSolWalletError': + syncWalletError({ + tokenId, + error: (data.data as PostMessageDataResponseError).error, + hideToast: isTestnetNetwork || isDevnetNetwork || isLocalNetwork + }); + return; + } + }; + + // TODO: stop/start the worker on address change (same as for worker.btc-wallet.services.ts) + const address = get( + isTestnetNetwork + ? solAddressTestnetStore + : isDevnetNetwork + ? solAddressDevnetStore + : isLocalNetwork + ? solAddressLocalnetStore + : solAddressMainnetStore + ); + assertNonNullish(address, 'No Solana address provided to start Solana wallet worker.'); + + const network = mapNetworkIdToNetwork(token.network.id); + assertNonNullish(network, 'No Solana network provided to start Solana wallet worker.'); + + const data: PostMessageDataRequestSol = { address, solanaNetwork: network }; + + return { + start: () => { + worker.postMessage({ + msg: 'startSolWalletTimer', + data + }); + }, + stop: () => { + worker.postMessage({ + msg: 'stopSolWalletTimer' + }); + }, + trigger: () => { + worker.postMessage({ + msg: 'triggerSolWalletTimer', + data + }); + } + }; +}; diff --git a/src/frontend/src/sol/workers/sol-wallet.worker.ts b/src/frontend/src/sol/workers/sol-wallet.worker.ts new file mode 100644 index 0000000000..f0a2d9ae8d --- /dev/null +++ b/src/frontend/src/sol/workers/sol-wallet.worker.ts @@ -0,0 +1,20 @@ +import type { PostMessage, PostMessageDataRequestSol } from '$lib/types/post-message'; +import { SolWalletScheduler } from '$sol/schedulers/sol-wallet.scheduler'; + +const scheduler: SolWalletScheduler = new SolWalletScheduler(); + +onmessage = async ({ data: dataMsg }: MessageEvent>) => { + const { msg, data } = dataMsg; + + switch (msg) { + case 'stopSolWalletTimer': + scheduler.stop(); + return; + case 'startSolWalletTimer': + await scheduler.start(data); + return; + case 'triggerSolWalletTimer': + await scheduler.trigger(data); + return; + } +}; diff --git a/src/frontend/src/tests/sol/schedulers/sol-wallet.scheduler.spec.ts b/src/frontend/src/tests/sol/schedulers/sol-wallet.scheduler.spec.ts new file mode 100644 index 0000000000..9e0f0d4a42 --- /dev/null +++ b/src/frontend/src/tests/sol/schedulers/sol-wallet.scheduler.spec.ts @@ -0,0 +1,177 @@ +import { WALLET_TIMER_INTERVAL_MILLIS } from '$lib/constants/app.constants'; +import type { PostMessageDataRequestSol } from '$lib/types/post-message'; +import * as authUtils from '$lib/utils/auth.utils'; +import * as solanaApi from '$sol/api/solana.api'; +import { SolWalletScheduler } from '$sol/schedulers/sol-wallet.scheduler'; +import { SolanaNetworks } from '$sol/types/network'; +import { mockIdentity } from '$tests/mocks/identity.mock'; +import { type MockInstance } from 'vitest'; + +describe('sol-wallet.scheduler', () => { + let spyLoadBalance: MockInstance; + + const mockBalance = 100n; + + const mockPostMessageStatusInProgress = { + msg: 'syncSolWalletStatus', + data: { + state: 'in_progress' + } + }; + + const mockPostMessageStatusIdle = { + msg: 'syncSolWalletStatus', + data: { + state: 'idle' + } + }; + + const mockPostMessage = ({ certified }: { certified: boolean }) => ({ + msg: 'syncSolWallet', + data: { + wallet: { + balance: { + certified, + data: mockBalance + } + } + } + }); + + const postMessageMock = vi.fn(); + + let originalPostmessage: unknown; + + beforeAll(() => { + originalPostmessage = window.postMessage; + window.postMessage = postMessageMock; + }); + + afterAll(() => { + // @ts-expect-error redo original + window.postMessage = originalPostmessage; + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + spyLoadBalance = vi.spyOn(solanaApi, 'loadSolLamportsBalance').mockResolvedValue(mockBalance); + + vi.spyOn(authUtils, 'loadIdentity').mockResolvedValue(mockIdentity); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const testWorker = ({ + startData = undefined + }: { + startData?: PostMessageDataRequestSol | undefined; + }) => { + const scheduler: SolWalletScheduler = new SolWalletScheduler(); + + const mockPostMessageCertified = mockPostMessage({ + certified: false + }); + + afterEach(() => { + // reset internal store with balance + scheduler['store'] = { + balance: undefined + }; + + scheduler.stop(); + }); + + it('should trigger postMessage with correct data', async () => { + await scheduler.start(startData); + + expect(postMessageMock).toHaveBeenCalledTimes(3); + expect(postMessageMock).toHaveBeenNthCalledWith(1, mockPostMessageStatusInProgress); + expect(postMessageMock).toHaveBeenCalledWith(mockPostMessageCertified); + expect(postMessageMock).toHaveBeenNthCalledWith(3, mockPostMessageStatusIdle); + + await vi.advanceTimersByTimeAsync(WALLET_TIMER_INTERVAL_MILLIS); + + expect(postMessageMock).toHaveBeenCalledTimes(5); + expect(postMessageMock).toHaveBeenNthCalledWith(4, mockPostMessageStatusInProgress); + expect(postMessageMock).toHaveBeenNthCalledWith(5, mockPostMessageStatusIdle); + + await vi.advanceTimersByTimeAsync(WALLET_TIMER_INTERVAL_MILLIS); + + expect(postMessageMock).toHaveBeenCalledTimes(7); + expect(postMessageMock).toHaveBeenNthCalledWith(6, mockPostMessageStatusInProgress); + expect(postMessageMock).toHaveBeenNthCalledWith(7, mockPostMessageStatusIdle); + }); + + it('should start the scheduler with an interval', async () => { + await scheduler.start(startData); + + expect(scheduler['timer']['timer']).toBeDefined(); + }); + + it('should trigger the scheduler manually', async () => { + await scheduler.trigger(startData); + + expect(spyLoadBalance).toHaveBeenCalledTimes(1); + }); + + it('should stop the scheduler', () => { + scheduler.stop(); + expect(scheduler['timer']['timer']).toBeUndefined(); + }); + + it('should trigger syncWallet periodically', async () => { + await scheduler.start(startData); + + expect(spyLoadBalance).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(WALLET_TIMER_INTERVAL_MILLIS); + + expect(spyLoadBalance).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(WALLET_TIMER_INTERVAL_MILLIS); + + expect(spyLoadBalance).toHaveBeenCalledTimes(3); + }); + + it('should postMessage with status of the worker', async () => { + await scheduler.start(startData); + + expect(postMessageMock).toHaveBeenCalledWith(mockPostMessageStatusInProgress); + expect(postMessageMock).toHaveBeenCalledWith(mockPostMessageStatusIdle); + }); + + it('should trigger postMessage with error', async () => { + const err = new Error('test'); + spyLoadBalance.mockRejectedValue(err); + + await scheduler.start(startData); + + // idle and in_progress + // error + expect(postMessageMock).toHaveBeenCalledTimes(3); + + expect(postMessageMock).toHaveBeenCalledWith({ + msg: 'syncSolWalletError', + data: { + error: err + } + }); + }); + }; + + describe('sol-wallet worker should work', () => { + const startData = { + address: { + certified: false, + data: 'mock-sol-address' + }, + solanaNetwork: SolanaNetworks.mainnet + }; + + testWorker({ startData }); + }); +}); diff --git a/src/frontend/src/tests/sol/services/sol-listener.services.spec.ts b/src/frontend/src/tests/sol/services/sol-listener.services.spec.ts index 632aa196ca..dc85c786ad 100644 --- a/src/frontend/src/tests/sol/services/sol-listener.services.spec.ts +++ b/src/frontend/src/tests/sol/services/sol-listener.services.spec.ts @@ -19,8 +19,7 @@ describe('sol-listener', () => { balance: { certified: true, data: balance - }, - newTransactions: '' + } } });