Skip to content

Commit

Permalink
Merge pull request #2752 from dusk-network/feature-2750
Browse files Browse the repository at this point in the history
web-wallet: Add a check for spent notes during the sync
  • Loading branch information
ascartabelli authored Oct 24, 2024
2 parents 45944d1 + e2dea6e commit f1dbf16
Show file tree
Hide file tree
Showing 6 changed files with 220 additions and 1 deletion.
36 changes: 35 additions & 1 deletion web-wallet/src/lib/stores/walletStore.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { get, writable } from "svelte/store";
import { setKey } from "lamb";
import { map, setKey } from "lamb";
import {
Bookkeeper,
Bookmark,
Expand All @@ -8,6 +8,7 @@ import {
import * as b58 from "$lib/vendor/w3sper.js/src/b58";

import walletCache from "$lib/wallet-cache";
import { nullifiersDifference } from "$lib/wallet";

import { transactions } from "$lib/mock-data";

Expand Down Expand Up @@ -223,6 +224,39 @@ async function sync(fromBlock) {

// update the cache with the spent nullifiers info
await walletCache.spendNotes(spentNullifiers);

// gather all spent nullifiers in the cache
const currentSpentNullifiers =
await walletCache.getUnspentNotesNullifiers();

/**
* Retrieving the nullifiers that are really spent given our
* list of spent nullifiers.
* We make this check because a block can be rejected and
* we may end up having some notes marked as spent in the
* cache, while they really aren't.
*
* Currently `w3sper.js` returns an array of `ArrayBuffer`s
* instead of one of `Uint8Array`s.
* @type {ArrayBuffer[]}
*/
const reallySpentNullifiers = await addressSyncer.spent(
currentSpentNullifiers
);

/**
* As the previous `addressSyncer.spent` call returns a subset of
* our spent nullifiers, we can skip this operation if the lengths
* are the same.
*/
if (reallySpentNullifiers.length !== currentSpentNullifiers.length) {
const nullifiersToUnspend = nullifiersDifference(
currentSpentNullifiers,
map(reallySpentNullifiers, (buf) => new Uint8Array(buf))
);

await walletCache.unspendNotes(nullifiersToUnspend);
}
})
.then(() => {
if (syncController?.signal.aborted) {
Expand Down
105 changes: 105 additions & 0 deletions web-wallet/src/lib/wallet-cache/__tests__/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,25 @@ describe("Wallet cache", () => {
);
});

it("should leave the database as is if the received nullifiers aren't in the unspent list", async () => {
const currentPendingInfo = await walletCache.getPendingNotesInfo();
const currentSpentNotes = await walletCache.getSpentNotes();
const currentUnspentNotes = await walletCache.getUnspentNotes();
const nonExistingNullifiers = pluckFrom(currentSpentNotes, "nullifier");

await walletCache.spendNotes(nonExistingNullifiers);

await expect(walletCache.getPendingNotesInfo()).resolves.toStrictEqual(
currentPendingInfo
);
await expect(walletCache.getSpentNotes()).resolves.toStrictEqual(
currentSpentNotes
);
await expect(walletCache.getUnspentNotes()).resolves.toStrictEqual(
currentUnspentNotes
);
});

it("should leave the database as is if an error occurs during the spend procedure", async () => {
const currentPendingInfo = await walletCache.getPendingNotesInfo();
const currentSpentNotes = await walletCache.getSpentNotes();
Expand All @@ -413,6 +432,92 @@ describe("Wallet cache", () => {
});
});

describe("Unspending notes", () => {
it("should expose a method to move a group of notes from the spent to the unspent table", async () => {
const currentPendingInfo = await walletCache.getPendingNotesInfo();
const [notesToUnspend, expectedSpentNotes] = await walletCache
.getSpentNotes()
.then(sortByNullifier)
.then(collect([take(2), drop(2)]));
const expectedUnspentNotes = await walletCache
.getUnspentNotes()
.then((notes) => notes.concat(notesToUnspend))
.then(sortByNullifier);

// checks to ensure we have enough meaningful data for the test
expect(notesToUnspend.length).toBeGreaterThan(0);
expect(expectedSpentNotes.length).toBeGreaterThan(0);

await walletCache.unspendNotes(pluckFrom(notesToUnspend, "nullifier"));

await expect(walletCache.getPendingNotesInfo()).resolves.toStrictEqual(
currentPendingInfo
);
await expect(
walletCache.getUnspentNotes().then(sortByNullifier)
).resolves.toStrictEqual(expectedUnspentNotes);
await expect(
walletCache.getSpentNotes().then(sortByNullifier)
).resolves.toStrictEqual(expectedSpentNotes);
});

it("should leave the database as is if the array of nullifiers to unspend is empty", async () => {
const currentPendingInfo = await walletCache.getPendingNotesInfo();
const currentSpentNotes = await walletCache.getSpentNotes();
const currentUnspentNotes = await walletCache.getUnspentNotes();

await walletCache.unspendNotes([]);

await expect(walletCache.getPendingNotesInfo()).resolves.toStrictEqual(
currentPendingInfo
);
await expect(walletCache.getSpentNotes()).resolves.toStrictEqual(
currentSpentNotes
);
await expect(walletCache.getUnspentNotes()).resolves.toStrictEqual(
currentUnspentNotes
);
});

it("should leave the database as is if the received nullifiers aren't in the spent list", async () => {
const currentPendingInfo = await walletCache.getPendingNotesInfo();
const currentSpentNotes = await walletCache.getSpentNotes();
const currentUnspentNotes = await walletCache.getUnspentNotes();
const nonExistingNullifiers = pluckFrom(currentUnspentNotes, "nullifier");

await walletCache.unspendNotes(nonExistingNullifiers);

await expect(walletCache.getPendingNotesInfo()).resolves.toStrictEqual(
currentPendingInfo
);
await expect(walletCache.getSpentNotes()).resolves.toStrictEqual(
currentSpentNotes
);
await expect(walletCache.getUnspentNotes()).resolves.toStrictEqual(
currentUnspentNotes
);
});

it("should leave the database as is if an error occurs during the unspend procedure", async () => {
const currentPendingInfo = await walletCache.getPendingNotesInfo();
const currentSpentNotes = await walletCache.getSpentNotes();
const currentUnspentNotes = await walletCache.getUnspentNotes();

// @ts-expect-error We are passing an invalid value on purpose
await walletCache.unspendNotes(() => {});

await expect(walletCache.getPendingNotesInfo()).resolves.toStrictEqual(
currentPendingInfo
);
await expect(walletCache.getSpentNotes()).resolves.toStrictEqual(
currentSpentNotes
);
await expect(walletCache.getUnspentNotes()).resolves.toStrictEqual(
currentUnspentNotes
);
});
});

describe("Treasury", async () => {
/** @type {string} */
let addressWithPendingNotes;
Expand Down
22 changes: 22 additions & 0 deletions web-wallet/src/lib/wallet-cache/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,28 @@ class WalletCache {
.finally(() => this.#db.close());
}

/**
* @param {Uint8Array[]} nullifiers
* @returns {Promise<void>}
*/
async unspendNotes(nullifiers) {
return this.#db
.open()
.then(async (db) =>
db.transaction("rw", ["spentNotes", "unspentNotes"], async () => {
const notesToUnspend = await db
.table("spentNotes")
.where("nullifier")
.anyOf(nullifiers)
.toArray();

await this.#db.table("spentNotes").bulkDelete(nullifiers);
await this.#db.table("unspentNotes").bulkAdd(notesToUnspend);
})
)
.finally(() => this.#db.close());
}

/**
* @param {Array<Map<Uint8Array, Uint8Array>>} syncerNotes
* @param {Array<import("$lib/vendor/w3sper.js/src/mod").Profile>} profiles
Expand Down
25 changes: 25 additions & 0 deletions web-wallet/src/lib/wallet/__tests__/nullifiersDifference.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { pluck } from "lamb";

import { cacheUnspentNotes } from "$lib/mock-data";

import { nullifiersDifference } from "..";

/** @type {(source: WalletCacheNote[]) => Uint8Array[]} */
const getNullifiers = pluck("nullifier");

describe("nullifiersDifference", () => {
it("should return the array of unique nullifiers contained only in the first of the two given sets of nullifiers", () => {
const a = getNullifiers(cacheUnspentNotes);
const b = getNullifiers(cacheUnspentNotes.slice(0, a.length - 2));

// ensure we have meaningful data for the test
expect(a.length).toBeGreaterThan(0);
expect(b.length).toBeGreaterThan(1);

expect(nullifiersDifference(a, b)).toStrictEqual(a.slice(-2));
expect(nullifiersDifference(b, a)).toStrictEqual([]);
expect(nullifiersDifference(a, [])).toStrictEqual(a);
expect(nullifiersDifference([], b)).toStrictEqual([]);
});
});
1 change: 1 addition & 0 deletions web-wallet/src/lib/wallet/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export { default as encryptMnemonic } from "./encryptMnemonic";
export { default as getSeedFromMnemonic } from "./getSeedFromMnemonic";
export { default as initializeWallet } from "./initializeWallet";
export { default as notesArrayToMap } from "./notesArrayToMap";
export { default as nullifiersDifference } from "./nullifiersDifference";
export { default as profileGeneratorFrom } from "./profileGeneratorFrom";
export { default as refreshLocalStoragePasswordInfo } from "./refreshLocalStoragePasswordInfo";
32 changes: 32 additions & 0 deletions web-wallet/src/lib/wallet/nullifiersDifference.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { mapWith } from "lamb";

const nullifiersToString = mapWith(String);

/**
* Returns the array of unique nullifiers contained only
* in the first of the two given nullifiers arrays.
*
* @see {@link https://en.wikipedia.org/wiki/Complement_(set_theory)#Relative_complement}
*
* @param {Uint8Array[]} a
* @param {Uint8Array[]} b
* @returns {Uint8Array[]}
*/
function nullifiersDifference(a, b) {
if (a.length === 0 || b.length === 0) {
return a;
}

const result = [];
const lookup = new Set(nullifiersToString(b));

for (const entry of a) {
if (!lookup.has(entry.toString())) {
result.push(entry);
}
}

return result;
}

export default nullifiersDifference;

0 comments on commit f1dbf16

Please sign in to comment.