Repository: dusk-network/rusk (language: rust)

Issue:
Update sync and cache to handle cases of rejected blocks
#### Summary

If a block is rejected / rolled back our local info doesn't reflect the chain state anymore.

We need to store the hash of the last synchronized block and the block height of the last finalized block.

When the sync start we compare the hash of the last synchronized block with the remote counterpart and if they diverge we start the sync from the last finalized block height instead.


Discussion / hints:

Fix patch:
diff --git a/web-wallet/CHANGELOG.md b/web-wallet/CHANGELOG.md
index e954d3dc63..12886322d3 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
 
@@ -429,6 +430,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
 
 <!-- VERSIONS -->
diff --git a/web-wallet/package-lock.json b/web-wallet/package-lock.json
index 91b301fab1..39b1060c2d 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 fea24a716a..00b8c9a3e0 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 5f8566805d..30f4dd0007 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 0cb4e9cec7..3ba1e46309 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/networkStore.js b/web-wallet/src/lib/stores/networkStore.js
index 6463ed20c8..0dea177e4e 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<AccountSyncer>} */
 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 d8275654ae..6b5bf83c8b 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<boolean>;
   connect: () => Promise<import("$lib/vendor/w3sper.js/src/mod").Network>;
   disconnect: () => Promise<void>;
   getAccountSyncer: (
@@ -59,7 +60,9 @@ type NetworkStoreServices = {
   getAddressSyncer: (
     options?: NetworkSyncerOptions
   ) => Promise<import("$lib/vendor/w3sper.js/src/mod").AddressSyncer>;
+  getBlockHashByHeight: (height: bigint) => Promise<string>;
   getCurrentBlockHeight: () => Promise<bigint>;
+  getLastFinalizedBlockHeight: () => Promise<bigint>;
   init: () => Promise<void>;
 };
 
diff --git a/web-wallet/src/lib/stores/walletStore.js b/web-wallet/src/lib/stores/walletStore.js
index 3424abc46e..52d2c01970 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/wallet-cache/index.js b/web-wallet/src/lib/wallet-cache/index.js
index d9eca954a2..3d0093d64d 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<void>}
    */
-  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<void>}
-   */
-  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<void>}
+   */
+  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<void>}
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 2225570585..067f9e341a 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/index.js b/web-wallet/src/lib/wallet-treasury/index.js
index 55a4702745..2817c6090f 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<WalletCacheSyncInfo>}
+   */
+  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<import("$lib/vendor/w3sper.js/src/mod").Profile>} 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);
   }


Test patch:
diff --git a/web-wallet/src/lib/stores/__tests__/networkStore.spec.js b/web-wallet/src/lib/stores/__tests__/networkStore.spec.js
index 6342aabde0..d4e12d23e0 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 2bb2787c57..7feb161258 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/test-helpers/getCacheDatabase.js b/web-wallet/src/lib/test-helpers/getCacheDatabase.js
index 498d05066c..b0c70b975b 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 e1f2ff84a0..a778576189 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-treasury/__tests__/index.spec.js b/web-wallet/src/lib/wallet-treasury/__tests__/index.spec.js
index e4d8dca448..dbbe3ca376 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", () => {