From 92ce3f247bdd63c848ee824803ea66bff4cb067c Mon Sep 17 00:00:00 2001 From: Andrea Scartabelli Date: Tue, 26 Mar 2024 09:20:50 +0100 Subject: [PATCH] web-wallet: Update Wallet service to use `dusk-wallet-js` 0.5.0 Resolves #1595 --- web-wallet/__mocks__/Wallet.js | 4 + .../lib/stores/__tests__/walletStore.spec.js | 153 +++++++++++++++++- web-wallet/src/lib/stores/stores.d.ts | 11 +- web-wallet/src/lib/stores/walletStore.js | 80 +++++---- 4 files changed, 208 insertions(+), 40 deletions(-) diff --git a/web-wallet/__mocks__/Wallet.js b/web-wallet/__mocks__/Wallet.js index f465ef3a98..1a670daa1d 100644 --- a/web-wallet/__mocks__/Wallet.js +++ b/web-wallet/__mocks__/Wallet.js @@ -6,6 +6,10 @@ class Wallet { this.wasm = {}; } + static get networkBlockHeight() { + return Promise.resolve(0); + } + gasLimit; gasPrice; seed; diff --git a/web-wallet/src/lib/stores/__tests__/walletStore.spec.js b/web-wallet/src/lib/stores/__tests__/walletStore.spec.js index 698eb2a81d..beafb65b67 100644 --- a/web-wallet/src/lib/stores/__tests__/walletStore.spec.js +++ b/web-wallet/src/lib/stores/__tests__/walletStore.spec.js @@ -17,10 +17,19 @@ import { walletStore } from ".."; vi.useFakeTimers(); +// TODO remove +Object.defineProperty(Wallet, "networkBlockHeight", { + configurable: true, + get: () => 0, +}); + describe("walletStore", async () => { const balance = { maximum: 100, value: 1 }; const wallet = new Wallet([]); + const blockHeightSpy = vi + .spyOn(Wallet, "networkBlockHeight", "get") + .mockResolvedValue(1536); const getBalanceSpy = vi .spyOn(Wallet.prototype, "getBalance") .mockResolvedValue(balance); @@ -74,6 +83,7 @@ describe("walletStore", async () => { }; afterEach(() => { + blockHeightSpy.mockClear(); getBalanceSpy.mockClear(); getPsksSpy.mockClear(); historySpy.mockClear(); @@ -87,6 +97,7 @@ describe("walletStore", async () => { }); afterAll(() => { + blockHeightSpy.mockRestore(); getBalanceSpy.mockRestore(); getPsksSpy.mockRestore(); historySpy.mockRestore(); @@ -135,6 +146,35 @@ describe("walletStore", async () => { expect(get(walletStore)).toStrictEqual(initializedStore); }); + it("should allow to start the sync from a specific block height after initializing the wallet", async () => { + const from = 9999; + + await walletStore.init(wallet, from); + + expect(get(walletStore)).toStrictEqual({ + ...initialState, + addresses: addresses, + currentAddress: addresses[0], + error: null, + initialized: true, + isSyncing: true, + }); + + expect(getPsksSpy).toHaveBeenCalledTimes(1); + expect(getBalanceSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersToNextTimerAsync(); + + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith({ + from, + signal: expect.any(AbortSignal), + }); + expect(getBalanceSpy).toHaveBeenCalledTimes(1); + expect(getBalanceSpy).toHaveBeenCalledWith(addresses[0]); + expect(get(walletStore)).toStrictEqual(initializedStore); + }); + it("should set the sync error in the store if the sync fails", async () => { walletStore.reset(); @@ -293,16 +333,59 @@ describe("walletStore", async () => { expect(getBalanceSpy).toHaveBeenCalledTimes(1); expect(getBalanceSpy).toHaveBeenCalledWith(addresses[0]); expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); await vi.advanceTimersToNextTimerAsync(); expect(get(walletStore)).toStrictEqual(initializedStore); }); + it("should allow to start the sync from a specific block height after clearing and initializing the wallet", async () => { + getPsksSpy.mockClear(); + getBalanceSpy.mockClear(); + syncSpy.mockClear(); + walletStore.reset(); + + const from = 4276; + + await walletStore.clearLocalDataAndInit(wallet, from); + + expect(get(walletStore)).toStrictEqual({ + ...initialState, + addresses: addresses, + currentAddress: addresses[0], + error: null, + initialized: true, + isSyncing: true, + }); + + await vi.advanceTimersToNextTimerAsync(); + + expect(getPsksSpy).toHaveBeenCalledTimes(1); + expect(getBalanceSpy).toHaveBeenCalledTimes(1); + expect(getBalanceSpy).toHaveBeenCalledWith(addresses[0]); + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith({ + from, + signal: expect.any(AbortSignal), + }); + + await vi.advanceTimersToNextTimerAsync(); + + expect(get(walletStore)).toStrictEqual(initializedStore); + }); + + it("should expose a method to retrieve the current block height", async () => { + await walletStore.getCurrentBlockHeight(); + + expect(blockHeightSpy).toHaveBeenCalledTimes(1); + }); + it("should expose a method to retrieve the stake info", async () => { await walletStore.getStakeInfo(); expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); expect(stakeInfoSpy).toHaveBeenCalledTimes(1); expect(stakeInfoSpy).toHaveBeenCalledWith(currentAddress); expect(syncSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -329,6 +412,7 @@ describe("walletStore", async () => { const result = await walletStore.getStakeInfo(); expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); expect(stakeInfoSpy).toHaveBeenCalledTimes(1); expect(stakeInfoSpy).toHaveBeenCalledWith(currentAddress); expect(syncSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -341,6 +425,7 @@ describe("walletStore", async () => { await walletStore.getTransactionsHistory(); expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); expect(historySpy).toHaveBeenCalledTimes(1); expect(historySpy).toHaveBeenCalledWith(currentAddress); expect(syncSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -362,6 +447,7 @@ describe("walletStore", async () => { await walletStore.setCurrentAddress(addresses[1]); expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); expect(get(walletStore).currentAddress).toBe(addresses[1]); expect(setCurrentAddressSpy.mock.invocationCallOrder[0]).toBeLessThan( syncSpy.mock.invocationCallOrder[0] @@ -384,6 +470,12 @@ describe("walletStore", async () => { expect(wallet.gasPrice).toBe(gas.price); expect(syncSpy).toHaveBeenCalledTimes(2); + expect(syncSpy).toHaveBeenNthCalledWith(1, { + signal: expect.any(AbortSignal), + }); + expect(syncSpy).toHaveBeenNthCalledWith(2, { + signal: expect.any(AbortSignal), + }); expect(stakeSpy).toHaveBeenCalledTimes(1); expect(stakeSpy).toHaveBeenCalledWith(currentAddress, 10); expect(syncSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -394,6 +486,25 @@ describe("walletStore", async () => { ); }); + it("should expose a method to manually start a synchronization", async () => { + await walletStore.sync(); + + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); + }); + + it("should allow to start a synchronization from a specific block height", async () => { + const from = 7654; + + await walletStore.sync(from); + + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(syncSpy).toHaveBeenCalledWith({ + from, + signal: expect.any(AbortSignal), + }); + }); + it("should expose a method to allow to transfer an amount of Dusk", async () => { await walletStore.transfer(addresses[1], 10, gas.price, gas.limit); @@ -401,6 +512,12 @@ describe("walletStore", async () => { expect(wallet.gasPrice).toBe(gas.price); expect(syncSpy).toHaveBeenCalledTimes(2); + expect(syncSpy).toHaveBeenNthCalledWith(1, { + signal: expect.any(AbortSignal), + }); + expect(syncSpy).toHaveBeenNthCalledWith(2, { + signal: expect.any(AbortSignal), + }); expect(transferSpy).toHaveBeenCalledTimes(1); expect(transferSpy).toHaveBeenCalledWith( currentAddress, @@ -422,6 +539,12 @@ describe("walletStore", async () => { expect(wallet.gasPrice).toBe(gas.price); expect(syncSpy).toHaveBeenCalledTimes(2); + expect(syncSpy).toHaveBeenNthCalledWith(1, { + signal: expect.any(AbortSignal), + }); + expect(syncSpy).toHaveBeenNthCalledWith(2, { + signal: expect.any(AbortSignal), + }); expect(unstakeSpy).toHaveBeenCalledTimes(1); expect(unstakeSpy).toHaveBeenCalledWith(currentAddress); expect(syncSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -439,6 +562,12 @@ describe("walletStore", async () => { expect(wallet.gasPrice).toBe(gas.price); expect(syncSpy).toHaveBeenCalledTimes(2); + expect(syncSpy).toHaveBeenNthCalledWith(1, { + signal: expect.any(AbortSignal), + }); + expect(syncSpy).toHaveBeenNthCalledWith(2, { + signal: expect.any(AbortSignal), + }); expect(withdrawRewardSpy).toHaveBeenCalledTimes(1); expect(withdrawRewardSpy).toHaveBeenCalledWith(currentAddress); expect(syncSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -452,7 +581,7 @@ describe("walletStore", async () => { describe("State changing failures", () => { /** @typedef {"stake" | "transfer" | "unstake" | "withdrawReward"} Operation */ - /** @type {Record>} */ + /** @type {Record>} */ const operationsMap = { stake: stakeSpy, transfer: transferSpy, @@ -466,7 +595,7 @@ describe("walletStore", async () => { keys(operationsMap).forEach((operation) => { const spy = operationsMap[operation]; - it("should return a resolved promise with the operation result if an operation succeeds", async () => { + it("should return a resolved promise with the operation result if an operation succeeds even if the last sync fails", async () => { await walletStore.init(wallet); await vi.advanceTimersToNextTimerAsync(); @@ -480,6 +609,16 @@ describe("walletStore", async () => { // @ts-ignore it's a mock and we don't care to pass the correct arguments expect(await walletStore[operation]()).toBe(fakeSuccess); expect(get(walletStore).error).toBe(fakeSyncError); + expect(syncSpy).toHaveBeenCalledTimes(3); + expect(syncSpy).toHaveBeenNthCalledWith(1, { + signal: expect.any(AbortSignal), + }); + expect(syncSpy).toHaveBeenNthCalledWith(2, { + signal: expect.any(AbortSignal), + }); + expect(syncSpy).toHaveBeenNthCalledWith(3, { + signal: expect.any(AbortSignal), + }); walletStore.reset(); }); @@ -501,6 +640,16 @@ describe("walletStore", async () => { await vi.advanceTimersToNextTimerAsync(); expect(get(walletStore).error).toBe(fakeSyncError); + expect(syncSpy).toHaveBeenCalledTimes(3); + expect(syncSpy).toHaveBeenNthCalledWith(1, { + signal: expect.any(AbortSignal), + }); + expect(syncSpy).toHaveBeenNthCalledWith(2, { + signal: expect.any(AbortSignal), + }); + expect(syncSpy).toHaveBeenNthCalledWith(3, { + signal: expect.any(AbortSignal), + }); walletStore.reset(); }); diff --git a/web-wallet/src/lib/stores/stores.d.ts b/web-wallet/src/lib/stores/stores.d.ts index cb9d9eeafd..731ae4ed54 100644 --- a/web-wallet/src/lib/stores/stores.d.ts +++ b/web-wallet/src/lib/stores/stores.d.ts @@ -22,14 +22,19 @@ type WalletStoreServices = { clearLocalData: () => Promise; - clearLocalDataAndInit: (wallet: Wallet) => Promise; + clearLocalDataAndInit: ( + wallet: Wallet, + syncFromBlock?: number + ) => Promise; + + getCurrentBlockHeight: () => Promise; getStakeInfo: () => Promise & ReturnType; // The return type apparently is not in a promise here getTransactionsHistory: () => Promise>; - init: (wallet: Wallet) => Promise; + init: (wallet: Wallet, syncFromBlock?: number) => Promise; reset: () => void; @@ -41,7 +46,7 @@ type WalletStoreServices = { gasLimit: number ) => Promise & ReturnType; - sync: () => Promise; + sync: (from?: number) => Promise; transfer: ( to: string, diff --git a/web-wallet/src/lib/stores/walletStore.js b/web-wallet/src/lib/stores/walletStore.js index 0b62b3ff46..fcc87e9167 100644 --- a/web-wallet/src/lib/stores/walletStore.js +++ b/web-wallet/src/lib/stores/walletStore.js @@ -6,7 +6,7 @@ import { getKey, uniquesBy } from "lamb"; */ /** - * @typedef {import("./stores").WalletStoreServices["getTransactionsHistory"]} GetTransactionsHistory + * @typedef {import("./stores").WalletStoreServices} WalletStoreServices */ /** @type {AbortController} */ @@ -55,50 +55,58 @@ const getCurrentAddress = () => get(walletStore).currentAddress; /** @type {(action: (...args: any[]) => Promise) => Promise} */ const syncedAction = (action) => sync().then(action).finally(sync); -const abortSync = () => syncPromise && syncController?.abort(); +async function updateAfterSync() { + const store = get(walletStore); + + // @ts-expect-error + const balance = await walletInstance.getBalance(store.currentAddress); + + set({ + ...store, + balance, + isSyncing: false, + }); +} -/** @type {() => Promise} */ +/** @type {WalletStoreServices["abortSync"]} */ +const abortSync = () => { + syncPromise && syncController?.abort(); +}; + +/** @type {WalletStoreServices["clearLocalData"]} */ const clearLocalData = async () => walletInstance?.reset(); -/** @type {(wallet: Wallet) => Promise} */ -const clearLocalDataAndInit = (wallet) => - wallet.reset().then(() => init(wallet)); +/** @type {WalletStoreServices["clearLocalDataAndInit"]} */ +const clearLocalDataAndInit = (wallet, syncFromBlock) => + wallet.reset().then(() => init(wallet, syncFromBlock)); + +/** @type {WalletStoreServices["getCurrentBlockHeight"]} */ +const getCurrentBlockHeight = async () => + // @ts-expect-error + walletInstance?.constructor.networkBlockHeight; -/** @type {import("./stores").WalletStoreServices["getStakeInfo"]} */ +/** @type {WalletStoreServices["getStakeInfo"]} */ const getStakeInfo = async () => sync() // @ts-expect-error .then(() => walletInstance.stakeInfo(getCurrentAddress())) .then(fixStakeInfo); -/** @type {GetTransactionsHistory} */ - +/** @type {WalletStoreServices["getTransactionsHistory"]} */ const getTransactionsHistory = async () => sync() // @ts-expect-error .then(() => walletInstance.history(getCurrentAddress())) .then(uniquesById); +/** @type {WalletStoreServices["reset"]} */ function reset() { walletInstance = null; set(initialState); } -async function updateAfterSync() { - const store = get(walletStore); - - // @ts-expect-error - const balance = await walletInstance.getBalance(store.currentAddress); - - set({ - ...store, - balance, - isSyncing: false, - }); -} - -/** @param {Wallet} wallet */ -async function init(wallet) { +/** @type {WalletStoreServices["init"]} */ +async function init(wallet, syncFromBlock) { walletInstance = wallet; const addresses = await walletInstance.getPsks(); @@ -110,20 +118,21 @@ async function init(wallet) { currentAddress, initialized: true, }); - sync(); + sync(syncFromBlock); } -/** @type {import("./stores").WalletStoreServices["setCurrentAddress"]} */ +/** @type {WalletStoreServices["setCurrentAddress"]} */ async function setCurrentAddress(address) { const store = get(walletStore); return store.addresses.includes(address) - ? Promise.resolve(set({ ...store, currentAddress: address })).then(sync) + ? Promise.resolve(set({ ...store, currentAddress: address })).then(() => + sync() + ) : Promise.reject(new Error("The received address is not in the list")); } -/** @type {import("./stores").WalletStoreServices["stake"]} */ - +/** @type {WalletStoreServices["stake"]} */ const stake = async (amount, gasPrice, gasLimit) => syncedAction(() => { // @ts-expect-error @@ -136,8 +145,8 @@ const stake = async (amount, gasPrice, gasLimit) => return walletInstance.stake(getCurrentAddress(), amount); }); -/** @type {import("./stores").WalletStoreServices["sync"]} */ -function sync() { +/** @type {WalletStoreServices["sync"]} */ +function sync(from) { if (!walletInstance) { throw new Error("No wallet instance to sync"); } @@ -149,7 +158,7 @@ function sync() { syncController = new AbortController(); syncPromise = walletInstance - .sync({ signal: syncController.signal }) + .sync({ from, signal: syncController.signal }) .then(updateAfterSync, (error) => { set({ ...store, error, isSyncing: false }); }) @@ -161,7 +170,7 @@ function sync() { return syncPromise; } -/** @type {import("./stores").WalletStoreServices["transfer"]} */ +/** @type {WalletStoreServices["transfer"]} */ const transfer = async (to, amount, gasPrice, gasLimit) => syncedAction(() => { // @ts-expect-error @@ -174,7 +183,7 @@ const transfer = async (to, amount, gasPrice, gasLimit) => return walletInstance.transfer(getCurrentAddress(), to, amount); }); -/** @type {import("./stores").WalletStoreServices["unstake"]} */ +/** @type {WalletStoreServices["unstake"]} */ const unstake = async (gasPrice, gasLimit) => syncedAction(() => { // @ts-expect-error @@ -187,7 +196,7 @@ const unstake = async (gasPrice, gasLimit) => return walletInstance.unstake(getCurrentAddress()); }); -/** @type {import("./stores").WalletStoreServices["withdrawReward"]} */ +/** @type {WalletStoreServices["withdrawReward"]} */ const withdrawReward = async (gasPrice, gasLimit) => syncedAction(() => { // @ts-expect-error @@ -205,6 +214,7 @@ export default { abortSync, clearLocalData, clearLocalDataAndInit, + getCurrentBlockHeight, getStakeInfo, getTransactionsHistory, init,