diff --git a/web-wallet/CHANGELOG.md b/web-wallet/CHANGELOG.md index 43e4a7468..5a2090add 100644 --- a/web-wallet/CHANGELOG.md +++ b/web-wallet/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add notice for stake maturity [#2981] +- Add capability to maintain cache consistency in case of rejected blocks [#3156] ### Changed @@ -431,6 +432,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#3099]: https://github.com/dusk-network/rusk/issues/3099 [#3113]: https://github.com/dusk-network/rusk/issues/3113 [#3129]: https://github.com/dusk-network/rusk/issues/3129 +[#3156]: https://github.com/dusk-network/rusk/issues/3156 [#3160]: https://github.com/dusk-network/rusk/issues/3160 diff --git a/web-wallet/package-lock.json b/web-wallet/package-lock.json index 91b301fab..39b1060c2 100644 --- a/web-wallet/package-lock.json +++ b/web-wallet/package-lock.json @@ -42,7 +42,7 @@ "fake-indexeddb": "6.0.0", "jsdom": "24.1.0", "jsdom-worker": "0.3.0", - "lamb-types": "0.61.11", + "lamb-types": "0.61.12", "postcss-nested": "6.0.1", "prettier": "3.3.2", "prettier-plugin-svelte": "3.2.5", @@ -13964,9 +13964,9 @@ } }, "node_modules/lamb-types": { - "version": "0.61.11", - "resolved": "https://registry.npmjs.org/lamb-types/-/lamb-types-0.61.11.tgz", - "integrity": "sha512-C8SPeNgpMfIxrqUz18+/fcSo7e/r8zUHyinuP9DHgA9/yZQ4yFRvtmyUro6b4SAPCFML2Ja5hbY4IsFPLnLZ/Q==", + "version": "0.61.12", + "resolved": "https://registry.npmjs.org/lamb-types/-/lamb-types-0.61.12.tgz", + "integrity": "sha512-qy3ivxgwGnTBkGkfQ3QiWRCoOdL7RPVEP3gMz2Zo2NKVFSAPazVoO0UWSbrV31lk68YqP999PX09NtKz7IKneg==", "dev": true, "license": "MIT" }, diff --git a/web-wallet/package.json b/web-wallet/package.json index fea24a716..00b8c9a3e 100644 --- a/web-wallet/package.json +++ b/web-wallet/package.json @@ -68,7 +68,7 @@ "fake-indexeddb": "6.0.0", "jsdom": "24.1.0", "jsdom-worker": "0.3.0", - "lamb-types": "0.61.11", + "lamb-types": "0.61.12", "postcss-nested": "6.0.1", "prettier": "3.3.2", "prettier-plugin-svelte": "3.2.5", diff --git a/web-wallet/src/__mocks__/AddressSyncer.js b/web-wallet/src/__mocks__/AddressSyncer.js index 5f8566805..30f4dd000 100644 --- a/web-wallet/src/__mocks__/AddressSyncer.js +++ b/web-wallet/src/__mocks__/AddressSyncer.js @@ -67,7 +67,7 @@ class AddressSyncerMock extends AddressSyncer { return; } - /** @type {WalletCacheSyncInfo} */ + /** @type {{ blockHeight: bigint, bookmark: bigint }} */ const syncInfo = { blockHeight: 50n * BigInt(currentChunk), bookmark: 100n * BigInt(currentChunk), diff --git a/web-wallet/src/lib/mock-data/cache-sync-info.js b/web-wallet/src/lib/mock-data/cache-sync-info.js index 0cb4e9cec..3ba1e4630 100644 --- a/web-wallet/src/lib/mock-data/cache-sync-info.js +++ b/web-wallet/src/lib/mock-data/cache-sync-info.js @@ -1,2 +1,11 @@ /** @type {WalletCacheSyncInfo[]} */ -export default [{ blockHeight: 12486n, bookmark: 35n }]; +export default [ + { + block: { + hash: "0c23c8e3a6532f8b3d1f409e156123d793e17f4377544a1c3bfd12c0be30cd6f", + height: 12486n, + }, + bookmark: 35n, + lastFinalizedBlockHeight: 10435n, + }, +]; diff --git a/web-wallet/src/lib/stores/__tests__/networkStore.spec.js b/web-wallet/src/lib/stores/__tests__/networkStore.spec.js index 6342aabde..d4e12d23e 100644 --- a/web-wallet/src/lib/stores/__tests__/networkStore.spec.js +++ b/web-wallet/src/lib/stores/__tests__/networkStore.spec.js @@ -1,4 +1,12 @@ -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { get } from "svelte/store"; import { @@ -14,16 +22,19 @@ describe("Network store", async () => { const blockHeightSpy = vi .spyOn(Network.prototype, "blockHeight", "get") .mockResolvedValue(blockHeight); + const networkQuerySpy = vi.spyOn(Network.prototype, "query"); afterEach(() => { connectSpy.mockClear(); disconnectSpy.mockClear(); + networkQuerySpy.mockClear(); }); afterAll(() => { connectSpy.mockRestore(); disconnectSpy.mockRestore(); blockHeightSpy.mockRestore(); + networkQuerySpy.mockRestore(); }); it("should build the network with the correct URL and expose a name for it", async () => { @@ -69,77 +80,126 @@ describe("Network store", async () => { expect(network).toBeInstanceOf(Network); }); - it("should expose a method to disconnect from the network and update the store's connection status", async () => { - const store = (await import("..")).networkStore; + describe("Connection and disconnection", () => { + it("should expose a method to disconnect from the network and update the store's connection status", async () => { + const store = (await import("..")).networkStore; - await store.connect(); + await store.connect(); - expect(get(store).connected).toBe(true); + expect(get(store).connected).toBe(true); - await store.disconnect(); + await store.disconnect(); - expect(disconnectSpy).toHaveBeenCalledTimes(1); - expect(get(store).connected).toBe(false); - }); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + expect(get(store).connected).toBe(false); + }); - it("should not try to connect again to the network if it's already connected", async () => { - const store = (await import("..")).networkStore; + it("should not try to connect again to the network if it's already connected", async () => { + const store = (await import("..")).networkStore; - const network = await store.connect(); + const network = await store.connect(); - expect(connectSpy).toHaveBeenCalledTimes(1); - expect(get(store).connected).toBe(true); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(get(store).connected).toBe(true); - connectSpy.mockClear(); + connectSpy.mockClear(); - const network2 = await store.connect(); + const network2 = await store.connect(); - expect(network2).toBe(network); - expect(connectSpy).not.toHaveBeenCalled(); - expect(get(store).connected).toBe(true); + expect(network2).toBe(network); + expect(connectSpy).not.toHaveBeenCalled(); + expect(get(store).connected).toBe(true); + }); }); - it("should expose a service method to retrieve the current block height", async () => { - const store = (await import("..")).networkStore; + describe("Service methods", () => { + /** @type {NetworkStore} */ + let store; - await expect(store.getCurrentBlockHeight()).resolves.toBe(blockHeight); - }); + beforeEach(async () => { + store = (await import("..")).networkStore; - it("should expose a service method to retrieve a `AccountSyncer` for the network", async () => { - const store = (await import("..")).networkStore; + // we check that every service method takes + // care of connecting to the network when necessary + await store.disconnect(); - await store.disconnect(); - expect(get(store).connected).toBe(false); + expect(get(store).connected).toBe(false); + }); - connectSpy.mockClear(); + it("should expose a service method to check if a block with the given height and hash exists on the network", async () => { + networkQuerySpy + .mockResolvedValueOnce({ checkBlock: true }) + .mockResolvedValueOnce({ checkBlock: false }); - const syncer = await store.getAccountSyncer(); + await expect(store.checkBlock(12n, "some-hash")).resolves.toBe(true); + await expect(store.checkBlock(12n, "some-hash")).resolves.toBe(false); + }); - expect(connectSpy).toHaveBeenCalledTimes(1); - expect(syncer).toBeInstanceOf(AccountSyncer); + it("should expose a service method to retrieve a `AccountSyncer` for the network", async () => { + connectSpy.mockClear(); - // check that the cached network is used - await store.getAccountSyncer(); - expect(connectSpy).toHaveBeenCalledTimes(1); - expect(syncer).toBeInstanceOf(AccountSyncer); - }); + const syncer = await store.getAccountSyncer(); - it("should expose a service method to retrieve a `AddressSyncer` for the network", async () => { - const store = (await import("..")).networkStore; + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(syncer).toBeInstanceOf(AccountSyncer); - await store.disconnect(); - expect(get(store).connected).toBe(false); + // check that the cached network is used + await store.getAccountSyncer(); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(syncer).toBeInstanceOf(AccountSyncer); + }); - connectSpy.mockClear(); + it("should expose a service method to retrieve a `AddressSyncer` for the network", async () => { + connectSpy.mockClear(); - const syncer = await store.getAddressSyncer(); + const syncer = await store.getAddressSyncer(); - expect(connectSpy).toHaveBeenCalledTimes(1); - expect(syncer).toBeInstanceOf(AddressSyncer); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(syncer).toBeInstanceOf(AddressSyncer); - // check that the cached network is used - await store.getAddressSyncer(); - expect(connectSpy).toHaveBeenCalledTimes(1); - expect(syncer).toBeInstanceOf(AddressSyncer); + // check that the cached network is used + await store.getAddressSyncer(); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(syncer).toBeInstanceOf(AddressSyncer); + }); + + it("should expose a method to retrieve a block hash by its height and return an empty string if the block is not found", async () => { + const expectedHash = "some-block-hash"; + + networkQuerySpy.mockResolvedValueOnce({ + block: { header: { hash: expectedHash } }, + }); + + await expect(store.getBlockHashByHeight(123n)).resolves.toStrictEqual( + expectedHash + ); + + networkQuerySpy.mockResolvedValueOnce({ block: null }); + + await expect(store.getBlockHashByHeight(123n)).resolves.toBe(""); + }); + + it("should expose a service method to retrieve the current block height", async () => { + await expect(store.getCurrentBlockHeight()).resolves.toBe(blockHeight); + }); + + it("should expose a method to retrieve the last finalized block height and return `0n` if the block is not found", async () => { + const height = 123; + + networkQuerySpy.mockResolvedValueOnce({ + lastBlockPair: { + // eslint-disable-next-line camelcase + json: { last_finalized_block: [height, "some-block-hash"] }, + }, + }); + + await expect(store.getLastFinalizedBlockHeight()).resolves.toStrictEqual( + BigInt(height) + ); + + networkQuerySpy.mockResolvedValueOnce({ lastBlockPair: null }); + + await expect(store.getLastFinalizedBlockHeight()).resolves.toBe(0n); + }); }); }); diff --git a/web-wallet/src/lib/stores/__tests__/walletStore.spec.js b/web-wallet/src/lib/stores/__tests__/walletStore.spec.js index 2bb2787c5..7feb16125 100644 --- a/web-wallet/src/lib/stores/__tests__/walletStore.spec.js +++ b/web-wallet/src/lib/stores/__tests__/walletStore.spec.js @@ -22,7 +22,7 @@ import walletCache from "$lib/wallet-cache"; import WalletTreasury from "$lib/wallet-treasury"; import { getSeedFromMnemonic } from "$lib/wallet"; -import { walletStore } from ".."; +import { networkStore, walletStore } from ".."; describe("Wallet store", async () => { vi.useFakeTimers(); @@ -94,10 +94,15 @@ describe("Wallet store", async () => { const setCachedStakeInfoSpy = vi .spyOn(walletCache, "setStakeInfo") .mockResolvedValue(undefined); - const setLastBlockHeightSpy = vi.spyOn(walletCache, "setLastBlockHeight"); const setProfilesSpy = vi.spyOn(WalletTreasury.prototype, "setProfiles"); const treasuryUpdateSpy = vi.spyOn(WalletTreasury.prototype, "update"); + vi.spyOn(networkStore, "checkBlock").mockResolvedValue(true); + vi.spyOn(networkStore, "getBlockHashByHeight").mockResolvedValue( + "some-block-hash" + ); + vi.spyOn(networkStore, "getLastFinalizedBlockHeight").mockResolvedValue(121n); + const seed = getSeedFromMnemonic(generateMnemonic()); const profileGenerator = new ProfileGenerator(async () => seed); const defaultProfile = await profileGenerator.default; @@ -192,16 +197,11 @@ describe("Wallet store", async () => { treasuryUpdateSpy.mock.invocationCallOrder[0] ); expect(treasuryUpdateSpy).toHaveBeenCalledTimes(1); - expect(setLastBlockHeightSpy).toHaveBeenCalledTimes(1); expect(balanceSpy).toHaveBeenCalledTimes(2); expect(balanceSpy).toHaveBeenNthCalledWith(1, defaultProfile.address); expect(balanceSpy).toHaveBeenNthCalledWith(2, defaultProfile.account); expect(stakeInfoSpy).toHaveBeenCalledTimes(1); expect(stakeInfoSpy).toHaveBeenCalledWith(defaultProfile.account); - expect(setLastBlockHeightSpy).toHaveBeenCalledWith(expect.any(BigInt)); - expect(setLastBlockHeightSpy.mock.invocationCallOrder[0]).toBeGreaterThan( - treasuryUpdateSpy.mock.invocationCallOrder[0] - ); expect(balanceSpy.mock.invocationCallOrder[0]).toBeGreaterThan( treasuryUpdateSpy.mock.invocationCallOrder[0] ); @@ -227,6 +227,7 @@ describe("Wallet store", async () => { expect(setCachedStakeInfoSpy.mock.invocationCallOrder[0]).toBeGreaterThan( stakeInfoSpy.mock.invocationCallOrder[0] ); + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); expect(setTimeoutSpy).toHaveBeenCalledTimes(1); expect(clearTimeoutSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -276,8 +277,8 @@ describe("Wallet store", async () => { walletStore.abortSync(); - expect(abortControllerSpy).toHaveBeenCalledTimes(1); expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); + expect(abortControllerSpy).toHaveBeenCalledTimes(1); await vi.runAllTimersAsync(); @@ -564,15 +565,12 @@ describe("Wallet store", async () => { treasuryUpdateSpy.mockClear(); balanceSpy.mockClear(); + cacheClearSpy.mockClear(); stakeInfoSpy.mockClear(); setCachedBalanceSpy.mockClear(); setCachedStakeInfoSpy.mockClear(); }); - afterEach(async () => { - cacheClearSpy.mockClear(); - }); - afterAll(() => { cacheClearSpy.mockRestore(); }); diff --git a/web-wallet/src/lib/stores/networkStore.js b/web-wallet/src/lib/stores/networkStore.js index 6463ed20c..0dea177e4 100644 --- a/web-wallet/src/lib/stores/networkStore.js +++ b/web-wallet/src/lib/stores/networkStore.js @@ -1,5 +1,7 @@ import { writable } from "svelte/store"; import { browser } from "$app/environment"; +import { always, condition, getKey, getPath, isUndefined, when } from "lamb"; + import { AccountSyncer, AddressSyncer, @@ -35,15 +37,24 @@ const initialState = { const networkStore = writable(initialState); const { set, subscribe } = networkStore; +/** + * Checks if a block with the given height and hash + * exists on the network. + * + * @type {NetworkStoreServices["checkBlock"]} + */ +const checkBlock = (height, hash) => + network + .connect() + .then(() => network.query(`checkBlock(height: ${height}, hash: "${hash}")`)) + .then(getKey("checkBlock")); + /** @type {NetworkStoreServices["connect"]} */ const connect = async () => (network.connected ? network : network.connect()); /** @type {NetworkStoreServices["disconnect"]} */ const disconnect = () => network.disconnect(); -/** @type {NetworkStoreServices["getCurrentBlockHeight"]} */ -const getCurrentBlockHeight = () => network.blockHeight; - /** @type {() => Promise} */ const getAccountSyncer = () => connect().then(() => new AccountSyncer(network)); @@ -51,6 +62,25 @@ const getAccountSyncer = () => connect().then(() => new AccountSyncer(network)); const getAddressSyncer = (options) => connect().then(() => new AddressSyncer(network, options)); +/** @type {NetworkStoreServices["getBlockHashByHeight"]} */ +const getBlockHashByHeight = (height) => + network + .connect() + .then(() => network.query(`block(height: ${height}) { header { hash } }`)) + .then(getPath("block.header.hash")) + .then(when(isUndefined, always(""))); + +/** @type {NetworkStoreServices["getCurrentBlockHeight"]} */ +const getCurrentBlockHeight = () => network.blockHeight; + +/** @type {NetworkStoreServices["getLastFinalizedBlockHeight"]} */ +const getLastFinalizedBlockHeight = () => + network + .connect() + .then(() => network.query("lastBlockPair { json }")) + .then(getPath("lastBlockPair.json.last_finalized_block.0")) + .then(condition(isUndefined, always(0n), BigInt)); + /** @type {NetworkStoreServices["init"]} */ async function init() { const info = await network.node.info; @@ -63,11 +93,14 @@ async function init() { /** @type {NetworkStore} */ export default { + checkBlock, connect, disconnect, getAccountSyncer, getAddressSyncer, + getBlockHashByHeight, getCurrentBlockHeight, + getLastFinalizedBlockHeight, init, subscribe, }; diff --git a/web-wallet/src/lib/stores/stores.d.ts b/web-wallet/src/lib/stores/stores.d.ts index d8275654a..6b5bf83c8 100644 --- a/web-wallet/src/lib/stores/stores.d.ts +++ b/web-wallet/src/lib/stores/stores.d.ts @@ -51,6 +51,7 @@ type NetworkSyncerOptions = { }; type NetworkStoreServices = { + checkBlock: (height: bigint, hash: string) => Promise; connect: () => Promise; disconnect: () => Promise; getAccountSyncer: ( @@ -59,7 +60,9 @@ type NetworkStoreServices = { getAddressSyncer: ( options?: NetworkSyncerOptions ) => Promise; + getBlockHashByHeight: (height: bigint) => Promise; getCurrentBlockHeight: () => Promise; + getLastFinalizedBlockHeight: () => Promise; init: () => Promise; }; diff --git a/web-wallet/src/lib/stores/walletStore.js b/web-wallet/src/lib/stores/walletStore.js index 3424abc46..52d2c0197 100644 --- a/web-wallet/src/lib/stores/walletStore.js +++ b/web-wallet/src/lib/stores/walletStore.js @@ -272,6 +272,7 @@ const stake = async (amount, gas) => .then(passThruWithEffects(observeTxRemoval)); /** @type {WalletStoreServices["sync"]} */ +// eslint-disable-next-line max-statements async function sync(fromBlock) { const store = get(walletStore); @@ -295,21 +296,42 @@ async function sync(fromBlock) { syncController = new AbortController(); - const walletCacheSyncInfo = await walletCache.getSyncInfo(); + const { block, bookmark, lastFinalizedBlockHeight } = + await walletCache.getSyncInfo(); + + /** @type {bigint | Bookmark} */ + let from; /* * Unless the user wants to sync from a specific block height, - * we restart from the last stored bookmark. + * we try to restart from the last stored bookmark. + * Before doing that we compare the block hash we have in cache + * with the hash at the same block height on the network: if + * they don't match then a block has been rejected, we can't + * use our bookmark, and our only safe option is to restart + * from the last finalized block we have cached. */ - const from = fromBlock ?? Bookmark.from(walletCacheSyncInfo.bookmark); - - let lastBlockHeight = 0n; + if (fromBlock) { + from = fromBlock; + } else { + const isLocalCacheValid = await networkStore + .checkBlock(block.height, block.hash) + .catch(() => false); + + from = isLocalCacheValid + ? Bookmark.from(bookmark) + : lastFinalizedBlockHeight; + } + + if (from === 0n) { + await walletCache.clear(); + } update((currentStore) => ({ ...currentStore, syncStatus: { ...currentStore.syncStatus, - from: fromBlock ?? walletCacheSyncInfo.blockHeight, + from: from instanceof Bookmark ? block.height : from, }, })); @@ -325,14 +347,9 @@ async function sync(fromBlock) { progress: detail.progress, }, })); - - lastBlockHeight = detail.blocks.last; }; await treasury.update(from, syncIterationListener, signal); - - // updating the last block height in the cache sync info - await walletCache.setLastBlockHeight(lastBlockHeight); }) .then(() => { if (syncController?.signal.aborted) { diff --git a/web-wallet/src/lib/test-helpers/getCacheDatabase.js b/web-wallet/src/lib/test-helpers/getCacheDatabase.js index 498d05066..b0c70b975 100644 --- a/web-wallet/src/lib/test-helpers/getCacheDatabase.js +++ b/web-wallet/src/lib/test-helpers/getCacheDatabase.js @@ -4,7 +4,7 @@ import { Dexie } from "dexie"; function getCacheDatabase() { const db = new Dexie("@dusk-network/wallet-cache"); - db.version(2).stores({ + db.version(3).stores({ balancesInfo: "address", pendingNotesInfo: "nullifier,txId", spentNotes: "nullifier,address", diff --git a/web-wallet/src/lib/wallet-cache/__tests__/index.spec.js b/web-wallet/src/lib/wallet-cache/__tests__/index.spec.js index e1f2ff84a..a77857618 100644 --- a/web-wallet/src/lib/wallet-cache/__tests__/index.spec.js +++ b/web-wallet/src/lib/wallet-cache/__tests__/index.spec.js @@ -6,7 +6,6 @@ import { drop, filterWith, getKey, - mapValues, mapWith, partitionWith, pluck, @@ -202,7 +201,7 @@ describe("Wallet cache", () => { }); }); - it("should expose a method to retrieve the sync info, which returns `{ blockHeight: 0n, bookmark: 0n }` if there is no info stored", async () => { + it("should expose a method to retrieve the sync info, which returns empty info if there is no info stored", async () => { await expect(walletCache.getSyncInfo()).resolves.toStrictEqual( cacheSyncInfo[0] ); @@ -210,8 +209,12 @@ describe("Wallet cache", () => { await walletCache.clear(); await expect(walletCache.getSyncInfo()).resolves.toStrictEqual({ - blockHeight: 0n, + block: { + hash: "", + height: 0n, + }, bookmark: 0n, + lastFinalizedBlockHeight: 0n, }); }); @@ -282,6 +285,9 @@ describe("Wallet cache", () => { /** @type {WalletCacheSyncInfo} */ let currentSyncInfo; + /** @type {NotesSyncInfo} */ + let newNotesSyncInfo; + /** @type {WalletCacheSyncInfo} */ let newSyncInfo; @@ -300,10 +306,21 @@ describe("Wallet cache", () => { beforeEach(async () => { currentSyncInfo = await walletCache.getSyncInfo(); - expect(currentSyncInfo.blockHeight).toBeGreaterThan(0n); + expect(currentSyncInfo.block.height).toBeGreaterThan(0n); expect(currentSyncInfo.bookmark).toBeGreaterThan(0n); - newSyncInfo = mapValues(currentSyncInfo, add(999n)); + newNotesSyncInfo = { + block: { + hash: "", + height: currentSyncInfo.block.height + 99n, + }, + bookmark: currentSyncInfo.bookmark + 90n, + }; + + newSyncInfo = { + ...newNotesSyncInfo, + lastFinalizedBlockHeight: currentSyncInfo.lastFinalizedBlockHeight, + }; }); it("should expose a method to add new notes to the unspent list", async () => { @@ -335,7 +352,7 @@ describe("Wallet cache", () => { unspentNotesToAdd ); - await walletCache.addUnspentNotes(newNotes, newSyncInfo); + await walletCache.addUnspentNotes(newNotes, newNotesSyncInfo); await expect( walletCache.getUnspentNotes().then(sortByNullifier) @@ -350,7 +367,7 @@ describe("Wallet cache", () => { const newNotes = cacheSpentNotes.concat({}); await expect( - walletCache.addUnspentNotes(newNotes, newSyncInfo) + walletCache.addUnspentNotes(newNotes, newNotesSyncInfo) ).rejects.toBeInstanceOf(Error); expect(sortByNullifier(cacheUnspentNotes)).toStrictEqual( @@ -615,29 +632,6 @@ describe("Wallet cache", () => { ).resolves.toStrictEqual(modifiedBalance); }); - it("should expose a method to update the last block height", async () => { - const currentSyncInfo = await walletCache.getSyncInfo(); - const newBlockHeight = currentSyncInfo.blockHeight * 2n; - - await walletCache.setLastBlockHeight(newBlockHeight); - - await expect(walletCache.getSyncInfo()).resolves.toStrictEqual({ - ...currentSyncInfo, - blockHeight: newBlockHeight, - }); - }); - - it("should leave the last block height as it is if an error occurs while writing the new value", async () => { - const currentSyncInfo = await walletCache.getSyncInfo(); - - // @ts-expect-error We are passing an invalid value on purpose - await expect(walletCache.setLastBlockHeight(() => {})).rejects.toThrow(); - - await expect(walletCache.getSyncInfo()).resolves.toStrictEqual( - currentSyncInfo - ); - }); - it("should expose a method to set a note as pending", async () => { const existingPendingNullifiersAsStrings = await walletCache .getPendingNotesInfo() @@ -722,6 +716,25 @@ describe("Wallet cache", () => { ).resolves.toStrictEqual(modifiedStakeInfo); }); + it("should expose a method to replace the current stored sync info", async () => { + const currentSyncInfo = await walletCache.getSyncInfo(); + const newSyncInfo = { + block: { + hash: "some-new-hash", + height: currentSyncInfo.block.height + 35n, + }, + bookmark: currentSyncInfo.bookmark + 10n, + lastFinalizedBlockHeight: + currentSyncInfo.lastFinalizedBlockHeight + 25n, + }; + + await walletCache.setSyncInfo(newSyncInfo); + + await expect(walletCache.getSyncInfo()).resolves.toStrictEqual( + newSyncInfo + ); + }); + it("should expose a method to convert notes in the w3sper map format into the one used by the cache", () => { const addresses = uniques(pluckFrom(cacheUnspentNotes, "address")); const fakeProfiles = addresses.map((address) => ({ diff --git a/web-wallet/src/lib/wallet-cache/index.js b/web-wallet/src/lib/wallet-cache/index.js index d9eca954a..3d0093d64 100644 --- a/web-wallet/src/lib/wallet-cache/index.js +++ b/web-wallet/src/lib/wallet-cache/index.js @@ -9,7 +9,6 @@ import { mapWith, pairs, pipe, - setKey, skipIf, unless, updateKey, @@ -60,6 +59,30 @@ class WalletCache { /** @type {Dexie} */ #db; + /** @type {WalletCacheBalanceInfo["balance"]} */ + #emptyBalanceInfo = Object.freeze({ + shielded: { spendable: 0n, value: 0n }, + unshielded: { nonce: 0n, value: 0n }, + }); + + /** @type {StakeInfo} */ + #emptyStakeInfo = Object.freeze({ + amount: null, + faults: 0, + hardFaults: 0, + reward: 0n, + }); + + /** @type {WalletCacheSyncInfo} */ + #emptySyncInfo = Object.freeze({ + block: { + hash: "", + height: 0n, + }, + bookmark: 0n, + lastFinalizedBlockHeight: 0n, + }); + /** * @template {WalletCacheTableName} TName * @template {boolean} PK @@ -89,30 +112,54 @@ class WalletCache { constructor() { const db = new Dexie("@dusk-network/wallet-cache", { autoOpen: true }); - db.version(2).stores({ - balancesInfo: "address", - pendingNotesInfo: "nullifier,txId", - spentNotes: "nullifier,address", - stakeInfo: "account", - syncInfo: "++", - unspentNotes: "nullifier,address", - }); + db.version(3) + .stores({ + balancesInfo: "address", + pendingNotesInfo: "nullifier,txId", + spentNotes: "nullifier,address", + stakeInfo: "account", + syncInfo: "++", + unspentNotes: "nullifier,address", + }) + .upgrade((tx) => + tx + .table("syncInfo") + .toCollection() + .modify((old, ref) => { + ref.value = { + ...this.#emptySyncInfo, + block: { + ...this.#emptySyncInfo.block, + height: old.blockHeight, + }, + bookmark: old.bookmark, + lastFinalizedBlockHeight: 0n, + }; + }) + ); this.#db = db; } /** + * While adding notes we clear and re-create the sync info based + * on what we receive in the note stream, but we keep the + * current `lastFinalizedBlockHeight`. + * The sync info there is not complete and needs to be enriched + * at the end of the sync process by calling `setSyncInfo`. + * * @param {WalletCacheNote[]} notes - * @param {WalletCacheSyncInfo} syncInfo + * @param {NotesSyncInfo} notesSyncInfo * @returns {Promise} */ - addUnspentNotes(notes, syncInfo) { + addUnspentNotes(notes, notesSyncInfo) { return this.#db .transaction("rw", ["syncInfo", "unspentNotes"], async () => { + const currentSyncInfo = await this.getSyncInfo(); const syncInfoTable = this.#db.table("syncInfo"); await syncInfoTable.clear(); - await syncInfoTable.add(syncInfo); + await syncInfoTable.add({ ...currentSyncInfo, ...notesSyncInfo }); await this.#db.table("unspentNotes").bulkPut(notes); }) .finally(() => this.#db.close({ disableAutoOpen: false })); @@ -132,12 +179,7 @@ class WalletCache { addresses: [address], }) .then(getPath("0.balance")) - .then( - when(isUndefined, () => ({ - shielded: { spendable: 0n, value: 0n }, - unshielded: { nonce: 0n, value: 0n }, - })) - ); + .then(when(isUndefined, () => this.#emptyBalanceInfo)); } /** @@ -182,12 +224,7 @@ class WalletCache { .then( condition( isUndefined, - () => ({ - amount: null, - faults: 0, - hardFaults: 0, - reward: 0n, - }), + () => this.#emptyStakeInfo, // we reinstate the `total` getter if the // amount is not `null` @@ -210,7 +247,7 @@ class WalletCache { getSyncInfo() { return this.#getEntriesFrom("syncInfo", false) .then(head) - .then(when(isUndefined, () => ({ blockHeight: 0n, bookmark: 0n }))); + .then(when(isUndefined, () => this.#emptySyncInfo)); } /** @@ -272,25 +309,6 @@ class WalletCache { .finally(() => this.#db.close({ disableAutoOpen: false })); } - /** - * @param {bigint} n - * @returns {Promise} - */ - setLastBlockHeight(n) { - return this.getSyncInfo() - .then(setKey("blockHeight", n)) - .then(async (syncInfo) => { - return this.#db - .transaction("rw", "syncInfo", async () => { - const syncInfoTable = this.#db.table("syncInfo"); - - await syncInfoTable.clear(); - await syncInfoTable.add(syncInfo); - }) - .finally(() => this.#db.close({ disableAutoOpen: false })); - }); - } - /** * @param {Uint8Array[]} nullifiers * @param {string} txId @@ -317,6 +335,21 @@ class WalletCache { .finally(() => this.#db.close({ disableAutoOpen: false })); } + /** + * @param {WalletCacheSyncInfo} syncInfo + * @returns {Promise} + */ + setSyncInfo(syncInfo) { + return this.#db + .transaction("rw", "syncInfo", async () => { + const syncInfoTable = this.#db.table("syncInfo"); + + await syncInfoTable.clear(); + await syncInfoTable.add(syncInfo); + }) + .finally(() => this.#db.close({ disableAutoOpen: false })); + } + /** * @param {Uint8Array[]} nullifiers * @returns {Promise} diff --git a/web-wallet/src/lib/wallet-cache/wallet-cache.d.ts b/web-wallet/src/lib/wallet-cache/wallet-cache.d.ts index 222557058..067f9e341 100644 --- a/web-wallet/src/lib/wallet-cache/wallet-cache.d.ts +++ b/web-wallet/src/lib/wallet-cache/wallet-cache.d.ts @@ -1,3 +1,16 @@ +/** + * Sync info coming from the unspent + * notes stream, enriched with the + * block hash. + */ +type NotesSyncInfo = { + block: { + hash: string; + height: bigint; + }; + bookmark: bigint; +}; + type WalletCacheBalanceInfo = { address: string; balance: { @@ -68,10 +81,7 @@ type WalletCacheDbPendingNoteInfo = Omit< nullifier: ArrayBuffer; }; -type WalletCacheSyncInfo = { - blockHeight: bigint; - bookmark: bigint; -}; +type WalletCacheSyncInfo = NotesSyncInfo & { lastFinalizedBlockHeight: bigint }; type WalletCacheTableName = | "balancesInfo" diff --git a/web-wallet/src/lib/wallet-treasury/__tests__/index.spec.js b/web-wallet/src/lib/wallet-treasury/__tests__/index.spec.js index e4d8dca44..dbbe3ca37 100644 --- a/web-wallet/src/lib/wallet-treasury/__tests__/index.spec.js +++ b/web-wallet/src/lib/wallet-treasury/__tests__/index.spec.js @@ -1,4 +1,13 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { mapWith, pluckFrom } from "lamb"; import mockedWalletStore from "../../../__mocks__/mockedWalletStore"; @@ -9,6 +18,7 @@ import { getCacheDatabase, sortNullifiers, } from "$lib/test-helpers"; +import networkStore from "$lib/stores/networkStore"; import WalletTreasury from ".."; @@ -16,6 +26,9 @@ describe("WalletTreasury", () => { /** @type {WalletTreasury} */ let walletTreasury; + const getBlockHashByHeightSpy = vi + .spyOn(networkStore, "getBlockHashByHeight") + .mockResolvedValue("fake-block-hash"); const { profiles } = mockedWalletStore.getMockedStoreValue(); const db = getCacheDatabase(); @@ -30,6 +43,11 @@ describe("WalletTreasury", () => { afterEach(async () => { await getCacheDatabase().delete({ disableAutoOpen: false }); await fillCacheDatabase(); + getBlockHashByHeightSpy.mockClear(); + }); + + afterAll(() => { + getBlockHashByHeightSpy.mockRestore(); }); describe("Treasury interface", () => { diff --git a/web-wallet/src/lib/wallet-treasury/index.js b/web-wallet/src/lib/wallet-treasury/index.js index 55a470274..2817c6090 100644 --- a/web-wallet/src/lib/wallet-treasury/index.js +++ b/web-wallet/src/lib/wallet-treasury/index.js @@ -13,6 +13,28 @@ class WalletTreasury { /** @type {StakeInfo[]} */ #accountStakeInfo = []; + /** + * @param {bigint} lastBlockHeight + * @returns {Promise} + */ + async #getEnrichedSyncInfo(lastBlockHeight) { + const [currentSyncInfo, lastBlockHash, lastFinalizedBlockHeight] = + await Promise.all([ + walletCache.getSyncInfo(), + networkStore.getBlockHashByHeight(lastBlockHeight).catch(() => ""), + networkStore.getLastFinalizedBlockHeight().catch(() => 0n), + ]); + + return { + block: { + hash: lastBlockHash, + height: lastBlockHeight, + }, + bookmark: currentSyncInfo.bookmark, + lastFinalizedBlockHeight, + }; + } + /** @param {Array} profiles */ constructor(profiles = []) { this.#profiles = profiles; @@ -86,9 +108,18 @@ class WalletTreasury { */ // eslint-disable-next-line max-statements async update(from, syncIterationListener, signal) { + let lastBlockHeight = 0n; + + /** @type {(evt: CustomEvent) => void} */ + const lastBlockHeightListener = ({ detail }) => { + lastBlockHeight = detail.blocks.last; + }; const accountSyncer = await networkStore.getAccountSyncer(); const addressSyncer = await networkStore.getAddressSyncer({ signal }); + // @ts-ignore + addressSyncer.addEventListener("synciteration", lastBlockHeightListener); + // @ts-ignore addressSyncer.addEventListener("synciteration", syncIterationListener); @@ -102,10 +133,28 @@ class WalletTreasury { signal, }); - for await (const [notesInfo, syncInfo] of notesStream) { + /** + * For each chunk of data in the stream we enrich the sync + * info with the block hash, that will be used to check that + * our local state is consistent with the remote one. + * This way we can ensure that if a user interrupts the sync + * while it's still in progress we can safely resume it from + * the stored bookmark if no block has been rejected in the + * meantime. + */ + for await (const [notesInfo, streamSyncInfo] of notesStream) { + const notesSyncInfo = { + block: { + hash: await networkStore + .getBlockHashByHeight(streamSyncInfo.blockHeight) + .catch(() => ""), + height: streamSyncInfo.blockHeight, + }, + bookmark: streamSyncInfo.bookmark, + }; await walletCache.addUnspentNotes( walletCache.toCacheNotes(notesInfo, this.#profiles), - syncInfo + notesSyncInfo ); } @@ -159,6 +208,21 @@ class WalletTreasury { await walletCache.unspendNotes(nullifiersToUnspend); } + /** + * We enrich the sync info by retrieving the hash of the last + * processed block and the height of the last finalized block. + * We'll use this information at the start of the sync + * to determine if a block has been rejected, so that we can + * fix our local cache state by syncing from the last finalized + * block height. + */ + await walletCache.setSyncInfo( + await this.#getEnrichedSyncInfo(lastBlockHeight) + ); + + // @ts-ignore + addressSyncer.removeEventListener("synciteration", lastBlockHeightListener); + // @ts-ignore addressSyncer.removeEventListener("synciteration", syncIterationListener); }