diff --git a/web-wallet/src/__mocks__/AccountSyncer.js b/web-wallet/src/__mocks__/AccountSyncer.js index 5987bf99da..7fe08f4532 100644 --- a/web-wallet/src/__mocks__/AccountSyncer.js +++ b/web-wallet/src/__mocks__/AccountSyncer.js @@ -1,5 +1,7 @@ import { AccountSyncer } from "$lib/vendor/w3sper.js/src/network/syncer/account"; +import { stakeInfo } from "$lib/mock-data"; + class AccountSyncerMock extends AccountSyncer { /** * @param {import("$lib/vendor/w3sper.js/src/mod").Network} network @@ -9,18 +11,23 @@ class AccountSyncerMock extends AccountSyncer { } /** - * * @param {Array<import("$lib/vendor/w3sper.js/src/mod").Profile>} profiles - * @param {Record<string, any>} [options={}] * @returns {Promise<AccountBalance[]>} */ - // eslint-disable-next-line no-unused-vars - async balances(profiles, options = {}) { + async balances(profiles) { return Array(profiles.length).fill({ nonce: 9876n, value: 12_345_000_000_000n, }); } + + /** + * @param {Array<import("$lib/vendor/w3sper.js/src/mod").Profile>} profiles + * @returns {Promise<StakeInfo[]>} + */ + async stakes(profiles) { + return Array(profiles.length).fill(stakeInfo); + } } export default AccountSyncerMock; diff --git a/web-wallet/src/__mocks__/mockedWalletStore.js b/web-wallet/src/__mocks__/mockedWalletStore.js index e7f3baa6ee..ffa621ed58 100644 --- a/web-wallet/src/__mocks__/mockedWalletStore.js +++ b/web-wallet/src/__mocks__/mockedWalletStore.js @@ -1,5 +1,7 @@ import { ProfileGenerator } from "$lib/vendor/w3sper.js/src/profile"; +import { stakeInfo } from "$lib/mock-data"; + import { mockReadableStore } from "$lib/dusk/test-helpers"; const seed = new Uint8Array(64); @@ -20,6 +22,7 @@ const content = { currentProfile, initialized: true, profiles, + stakeInfo, syncStatus: { error: null, from: 0n, diff --git a/web-wallet/src/lib/containers/StakeContract/StakeContract.svelte b/web-wallet/src/lib/containers/StakeContract/StakeContract.svelte index 5e7a2e7bce..e6e6ca6e24 100644 --- a/web-wallet/src/lib/containers/StakeContract/StakeContract.svelte +++ b/web-wallet/src/lib/containers/StakeContract/StakeContract.svelte @@ -14,7 +14,7 @@ import { mdiArrowLeft } from "@mdi/js"; import { Gas } from "$lib/vendor/w3sper.js/src/mod"; - import { createCurrencyFormatter } from "$lib/dusk/currency"; + import { createCurrencyFormatter, luxToDusk } from "$lib/dusk/currency"; import { getLastTransactionHash } from "$lib/transactions"; import { gasStore, @@ -35,6 +35,14 @@ const gasLimits = $gasStore; + /** + * Temporary replacement for the old `walletStore.getStakeInfo` + * function. + * The UI needs to be updated to just use the `stakeInfo` property + * directly. + */ + const getStakeInfo = async () => $walletStore.stakeInfo; + const collectSettings = collect([ pick([ "gasLimit", @@ -47,11 +55,11 @@ getKey("minAllowedStake"), ]); - /** @type {Record<string, (info: WalletStakeInfo) => boolean>} */ + /** @type {Record<string, (info: StakeInfo) => boolean>} */ const disablingConditions = { - stake: (info) => info.has_staked, - unstake: (info) => !info.has_staked, - "withdraw-rewards": (info) => info.reward <= 0, + stake: (info) => !!info.amount, + unstake: (info) => !info.amount || info.amount.total === 0n, + "withdraw-rewards": (info) => info.reward <= 0n, }; /** @type {Record<StakeType, (...args: any[]) => Promise<string>>} */ @@ -83,7 +91,7 @@ * otherwise the descriptor takes precedence. * * @param {ContractOperation[]} operations - * @param {WalletStakeInfo} stakeInfo + * @param {StakeInfo} stakeInfo * @returns {ContractOperation[]} */ const getOperations = (operations, stakeInfo) => @@ -96,27 +104,29 @@ ); /** - * @param {WalletStakeInfo} stakeInfo + * @param {StakeInfo} stakeInfo * @param {bigint} spendable * @returns {ContractStatus[]} */ const getStatuses = (stakeInfo, spendable) => [ { label: "Spendable", - value: duskFormatter(spendable), + value: duskFormatter(luxToDusk(spendable)), }, { label: "Total Locked", - value: duskFormatter(stakeInfo.amount), + value: stakeInfo.amount + ? duskFormatter(luxToDusk(stakeInfo.amount.locked)) + : "N/A", }, { label: "Rewards", - value: duskFormatter(stakeInfo.reward), + value: duskFormatter(luxToDusk(stakeInfo.reward)), }, ]; /** - * @param {WalletStakeInfo} stakeInfo + * @param {StakeInfo} stakeInfo * @returns {(operation: ContractOperation) => ContractOperation} */ const updateOperationDisabledStatus = (stakeInfo) => (operation) => ({ @@ -137,7 +147,7 @@ gap="medium" errorMessage="Failed to retrieve stake info" errorVariant="details" - waitFor={walletStore.getStakeInfo()} + waitFor={getStakeInfo()} > <svelte:fragment slot="pending-content"> {#if !syncStatus.isInProgress && !syncStatus.error} @@ -161,9 +171,9 @@ {minAllowedStake} on:operationChange on:suppressStakingNotice - rewards={stakeInfo.reward} + rewards={luxToDusk(stakeInfo.reward)} spendable={balance.shielded.spendable} - staked={stakeInfo.amount} + staked={stakeInfo.amount ? luxToDusk(stakeInfo.amount.total) : 0} {statuses} {hideStakingNotice} /> diff --git a/web-wallet/src/lib/mock-data/cache-stake-info.js b/web-wallet/src/lib/mock-data/cache-stake-info.js new file mode 100644 index 0000000000..f6e78a4b6a --- /dev/null +++ b/web-wallet/src/lib/mock-data/cache-stake-info.js @@ -0,0 +1,17 @@ +export default [ + { + account: "", + stakeInfo: { + amount: { + eligibility: 0n, + locked: 0n, + total: 1000000000000n, + value: 1000000000000n, + }, + faults: 0, + hardFaults: 0, + nonce: 0n, + reward: 11022842680864n, + }, + }, +]; diff --git a/web-wallet/src/lib/mock-data/index.js b/web-wallet/src/lib/mock-data/index.js index 912881ebc9..ef9a4aa77d 100644 --- a/web-wallet/src/lib/mock-data/index.js +++ b/web-wallet/src/lib/mock-data/index.js @@ -3,6 +3,7 @@ export { default as apiMarketData } from "./api-market-data.json"; export { default as cacheBalances } from "./cache-balances"; export { default as cachePendingNotesInfo } from "./cache-pending-notes-info"; export { default as cacheSpentNotes } from "./cache-spent-notes"; +export { default as cacheStakeInfo } from "./cache-stake-info"; export { default as cacheSyncInfo } from "./cache-sync-info"; export { default as cacheUnspentNotes } from "./cache-unspent-notes"; export { default as stakeInfo } from "./stakeInfo"; diff --git a/web-wallet/src/lib/mock-data/stakeInfo.js b/web-wallet/src/lib/mock-data/stakeInfo.js index f29edd6446..d1eecc439f 100644 --- a/web-wallet/src/lib/mock-data/stakeInfo.js +++ b/web-wallet/src/lib/mock-data/stakeInfo.js @@ -1,9 +1,15 @@ -/* eslint-disable camelcase */ - -/** @type {WalletStakeInfo} */ +/** @type {StakeInfo} */ export default { - amount: 1000, - has_key: true, - has_staked: true, - reward: 500, + amount: { + eligibility: 0n, + locked: 0n, + get total() { + return this.value + this.locked; + }, + value: 1000000000000n, + }, + faults: 0, + hardFaults: 0, + nonce: 0n, + reward: 11022842680864n, }; diff --git a/web-wallet/src/lib/stores/__tests__/walletStore.spec.js b/web-wallet/src/lib/stores/__tests__/walletStore.spec.js index 11222bddae..991afe15ce 100644 --- a/web-wallet/src/lib/stores/__tests__/walletStore.spec.js +++ b/web-wallet/src/lib/stores/__tests__/walletStore.spec.js @@ -16,6 +16,8 @@ import { } from "$lib/vendor/w3sper.js/src/mod"; import { generateMnemonic } from "bip39"; +import { stakeInfo } from "$lib/mock-data"; + import walletCache from "$lib/wallet-cache"; import WalletTreasury from "$lib/wallet-treasury"; import { getSeedFromMnemonic } from "$lib/wallet"; @@ -36,6 +38,20 @@ describe("Wallet store", async () => { value: 4n, }, }; + const cachedStakeInfo = { + amount: { + eligibility: 123n, + locked: 456n, + get total() { + return this.value + this.locked; + }, + value: 100n, + }, + faults: 10, + hardFaults: 2, + nonce: 5n, + reward: 56789n, + }; const shielded = { spendable: 400000000000000n, value: 1026179647718621n, @@ -56,6 +72,9 @@ describe("Wallet store", async () => { ? shielded : unshielded; }); + const stakeInfoSpy = vi + .spyOn(Bookkeeper.prototype, "stakeInfo") + .mockImplementation(async () => stakeInfo); const getCachedBalanceSpy = vi .spyOn(walletCache, "getBalanceInfo") @@ -63,6 +82,12 @@ describe("Wallet store", async () => { const setCachedBalanceSpy = vi .spyOn(walletCache, "setBalanceInfo") .mockResolvedValue(undefined); + const getCachedStakeInfoSpy = vi + .spyOn(walletCache, "getStakeInfo") + .mockResolvedValue(cachedStakeInfo); + 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"); @@ -85,6 +110,13 @@ describe("Wallet store", async () => { currentProfile: null, initialized: false, profiles: [], + stakeInfo: { + amount: null, + faults: 0, + hardFaults: 0, + nonce: 0n, + reward: 0n, + }, syncStatus: { error: null, from: 0n, @@ -100,31 +132,18 @@ describe("Wallet store", async () => { currentProfile: defaultProfile, initialized: true, profiles: [defaultProfile], + stakeInfo, }; beforeEach(async () => { await vi.runOnlyPendingTimersAsync(); vi.clearAllTimers(); - setTimeoutSpy.mockClear(); - clearTimeoutSpy.mockClear(); - abortControllerSpy.mockClear(); - setLastBlockHeightSpy.mockClear(); - setCachedBalanceSpy.mockClear(); - setProfilesSpy.mockClear(); - treasuryUpdateSpy.mockClear(); + vi.clearAllMocks(); }); afterAll(() => { vi.useRealTimers(); - setTimeoutSpy.mockRestore(); - clearTimeoutSpy.mockRestore(); - abortControllerSpy.mockRestore(); - setLastBlockHeightSpy.mockRestore(); - balanceSpy.mockRestore(); - getCachedBalanceSpy.mockRestore(); - setCachedBalanceSpy.mockRestore(); - setProfilesSpy.mockRestore(); - treasuryUpdateSpy.mockRestore(); + vi.restoreAllMocks(); }); describe("Initialization and sync", () => { @@ -137,6 +156,7 @@ describe("Wallet store", async () => { currentProfile: defaultProfile, initialized: true, profiles: [defaultProfile], + stakeInfo: cachedStakeInfo, syncStatus: { error: null, from: 0n, @@ -146,6 +166,15 @@ describe("Wallet store", async () => { }, }); + expect(getCachedBalanceSpy).toHaveBeenCalledTimes(1); + expect(getCachedBalanceSpy).toHaveBeenCalledWith( + defaultProfile.address.toString() + ); + expect(getCachedStakeInfoSpy).toHaveBeenCalledTimes(1); + expect(getCachedStakeInfoSpy).toHaveBeenCalledWith( + defaultProfile.account.toString() + ); + await vi.advanceTimersByTimeAsync(AUTO_SYNC_INTERVAL - 1); expect(get(walletStore)).toStrictEqual(initializedStore); @@ -156,6 +185,11 @@ describe("Wallet store", async () => { ); 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] @@ -163,9 +197,9 @@ describe("Wallet store", async () => { expect(balanceSpy.mock.invocationCallOrder[0]).toBeGreaterThan( treasuryUpdateSpy.mock.invocationCallOrder[0] ); - expect(balanceSpy).toHaveBeenCalledTimes(2); - expect(balanceSpy).toHaveBeenNthCalledWith(1, defaultProfile.address); - expect(balanceSpy).toHaveBeenNthCalledWith(2, defaultProfile.account); + expect(stakeInfoSpy.mock.invocationCallOrder[0]).toBeGreaterThan( + treasuryUpdateSpy.mock.invocationCallOrder[0] + ); expect(setCachedBalanceSpy).toHaveBeenCalledTimes(1); expect(setCachedBalanceSpy).toHaveBeenCalledWith( defaultProfile.address.toString(), @@ -174,9 +208,17 @@ describe("Wallet store", async () => { unshielded: await balanceSpy.mock.results[1].value, } ); + expect(setCachedStakeInfoSpy).toHaveBeenCalledTimes(1); + expect(setCachedStakeInfoSpy).toHaveBeenCalledWith( + defaultProfile.account.toString(), + await stakeInfoSpy.mock.results[0].value + ); expect(setCachedBalanceSpy.mock.invocationCallOrder[0]).toBeGreaterThan( balanceSpy.mock.invocationCallOrder[1] ); + expect(setCachedStakeInfoSpy.mock.invocationCallOrder[0]).toBeGreaterThan( + stakeInfoSpy.mock.invocationCallOrder[0] + ); expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); expect(setTimeoutSpy).toHaveBeenCalledTimes(1); expect(clearTimeoutSpy.mock.invocationCallOrder[0]).toBeLessThan( @@ -291,7 +333,9 @@ describe("Wallet store", async () => { treasuryUpdateSpy.mockClear(); balanceSpy.mockClear(); + stakeInfoSpy.mockClear(); setCachedBalanceSpy.mockClear(); + setCachedStakeInfoSpy.mockClear(); }); afterEach(async () => { @@ -342,6 +386,8 @@ describe("Wallet store", async () => { expect(balanceSpy).toHaveBeenCalledTimes(2); expect(balanceSpy).toHaveBeenNthCalledWith(1, newProfile.address); expect(balanceSpy).toHaveBeenNthCalledWith(2, newProfile.account); + expect(stakeInfoSpy).toHaveBeenCalledTimes(1); + expect(stakeInfoSpy).toHaveBeenCalledWith(newProfile.account); }); it("should expose a method to set the current profile and update the balance afterwards", async () => { @@ -371,6 +417,8 @@ describe("Wallet store", async () => { expect(balanceSpy).toHaveBeenCalledTimes(2); expect(balanceSpy).toHaveBeenNthCalledWith(1, fakeExtraProfile.address); expect(balanceSpy).toHaveBeenNthCalledWith(2, fakeExtraProfile.account); + expect(stakeInfoSpy).toHaveBeenCalledTimes(1); + expect(stakeInfoSpy).toHaveBeenCalledWith(fakeExtraProfile.account); expect(setCachedBalanceSpy).toHaveBeenCalledTimes(1); expect(setCachedBalanceSpy).toHaveBeenCalledWith( fakeExtraProfile.address.toString(), @@ -379,9 +427,17 @@ describe("Wallet store", async () => { unshielded: await balanceSpy.mock.results[1].value, } ); + expect(setCachedStakeInfoSpy).toHaveBeenCalledTimes(1); + expect(setCachedStakeInfoSpy).toHaveBeenCalledWith( + fakeExtraProfile.account.toString(), + await stakeInfoSpy.mock.results[0].value + ); expect(setCachedBalanceSpy.mock.invocationCallOrder[0]).toBeGreaterThan( balanceSpy.mock.invocationCallOrder[1] ); + expect(setCachedStakeInfoSpy.mock.invocationCallOrder[0]).toBeGreaterThan( + stakeInfoSpy.mock.invocationCallOrder[0] + ); }); it("should reject with an error if the profile is not in the known list", async () => { @@ -468,6 +524,9 @@ describe("Wallet store", async () => { // but the balance is not updated yet expect(balanceSpy).not.toHaveBeenCalled(); + // and neither the stake info + expect(stakeInfoSpy).not.toHaveBeenCalled(); + expect(treasuryUpdateSpy.mock.invocationCallOrder[0]).toBeLessThan( executeSpy.mock.invocationCallOrder[0] ); @@ -491,6 +550,8 @@ describe("Wallet store", async () => { 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(balanceSpy.mock.invocationCallOrder[0]).toBeGreaterThan( treasuryUpdateSpy.mock.invocationCallOrder[1] ); @@ -505,9 +566,17 @@ describe("Wallet store", async () => { unshielded: await balanceSpy.mock.results[1].value, } ); + expect(setCachedStakeInfoSpy).toHaveBeenCalledTimes(1); + expect(setCachedStakeInfoSpy).toHaveBeenCalledWith( + defaultProfile.account.toString(), + await stakeInfoSpy.mock.results[0].value + ); expect(setCachedBalanceSpy.mock.invocationCallOrder[0]).toBeGreaterThan( balanceSpy.mock.invocationCallOrder[1] ); + expect(setCachedStakeInfoSpy.mock.invocationCallOrder[0]).toBeGreaterThan( + stakeInfoSpy.mock.invocationCallOrder[0] + ); vi.useFakeTimers(); }); @@ -590,6 +659,9 @@ describe("Wallet store", async () => { // but the balance is not updated yet expect(balanceSpy).not.toHaveBeenCalled(); + // and neither the stake info + expect(stakeInfoSpy).not.toHaveBeenCalled(); + expect(treasuryUpdateSpy.mock.invocationCallOrder[0]).toBeLessThan( executeSpy.mock.invocationCallOrder[0] ); diff --git a/web-wallet/src/lib/stores/stores.d.ts b/web-wallet/src/lib/stores/stores.d.ts index 2f48448fa1..d7d2ccf7ad 100644 --- a/web-wallet/src/lib/stores/stores.d.ts +++ b/web-wallet/src/lib/stores/stores.d.ts @@ -84,6 +84,7 @@ type WalletStoreContent = { currentProfile: import("$lib/vendor/w3sper.js/src/mod").Profile | null; initialized: boolean; profiles: Array<import("$lib/vendor/w3sper.js/src/mod").Profile>; + stakeInfo: StakeInfo; syncStatus: { from: bigint; isInProgress: boolean; @@ -103,8 +104,6 @@ type WalletStoreServices = { syncFromBlock?: bigint ) => Promise<void>; - getStakeInfo: () => Promise<any>; - getTransactionsHistory: () => Promise<any>; init: ( diff --git a/web-wallet/src/lib/stores/walletStore.js b/web-wallet/src/lib/stores/walletStore.js index 26e649294a..7728a9635e 100644 --- a/web-wallet/src/lib/stores/walletStore.js +++ b/web-wallet/src/lib/stores/walletStore.js @@ -39,6 +39,13 @@ const initialState = { currentProfile: null, initialized: false, profiles: [], + stakeInfo: { + amount: null, + faults: 0, + hardFaults: 0, + nonce: 0n, + reward: 0n, + }, syncStatus: { error: null, from: 0n, @@ -66,7 +73,7 @@ const observeTxRemoval = (txInfo) => { .withId(txInfo.hash) .once.removed() .then(() => sync()) - .finally(updateBalance) + .finally(updateStaticInfo) ); }; @@ -133,6 +140,33 @@ const updateBalance = async () => { })); }; +/** @type {() => Promise<void>} */ +const updateStakeInfo = async () => { + const { currentProfile } = get(walletStore); + + if (!currentProfile) { + return; + } + + const stakeInfo = await bookkeeper.stakeInfo(currentProfile.account); + + /** + * We ignore the error as the cached stake info is only + * a nice to have for the user. + */ + await walletCache + .setStakeInfo(currentProfile.account.toString(), stakeInfo) + .catch(() => {}); + + update((currentStore) => ({ + ...currentStore, + stakeInfo, + })); +}; + +const updateStaticInfo = () => + Promise.allSettled([updateBalance(), updateStakeInfo()]); + /** @type {WalletStoreServices["abortSync"]} */ const abortSync = () => { window.clearTimeout(autoSyncId); @@ -153,9 +187,6 @@ const clearLocalDataAndInit = (profileGenerator, syncFromBlock) => return init(profileGenerator, syncFromBlock); }); -/** @type {WalletStoreServices["getStakeInfo"]} */ -const getStakeInfo = async () => ({ amount: 0, reward: 0 }); - /** @type {WalletStoreServices["getTransactionsHistory"]} */ const getTransactionsHistory = async () => transactions; @@ -164,6 +195,9 @@ async function init(profileGenerator, syncFromBlock) { const currentProfile = await profileGenerator.default; const currentAddress = currentProfile.address.toString(); const cachedBalance = await walletCache.getBalanceInfo(currentAddress); + const cachedStakeInfo = await walletCache.getStakeInfo( + currentProfile.account.toString() + ); treasury.setProfiles([currentProfile]); @@ -173,13 +207,14 @@ async function init(profileGenerator, syncFromBlock) { currentProfile, initialized: true, profiles: [currentProfile], + stakeInfo: cachedStakeInfo, }); sync(syncFromBlock) .then(() => { settingsStore.update(setKey("userId", currentAddress)); }) - .finally(updateBalance); + .finally(updateStaticInfo); } /** @type {WalletStoreServices["reset"]} */ @@ -194,7 +229,9 @@ async function setCurrentProfile(profile) { return store.profiles.includes(profile) ? Promise.resolve(set({ ...store, currentProfile: profile })).then( - updateBalance + async () => { + await updateStaticInfo(); + } ) : Promise.reject( new Error("The received profile is not in the known list") @@ -282,7 +319,7 @@ async function sync(fromBlock) { .then(() => { window.clearTimeout(autoSyncId); autoSyncId = window.setTimeout(() => { - sync().finally(updateBalance); + sync().finally(updateStaticInfo); }, AUTO_SYNC_INTERVAL); }) .catch((error) => { @@ -361,7 +398,6 @@ export default { abortSync, clearLocalData, clearLocalDataAndInit, - getStakeInfo, getTransactionsHistory, init, reset, diff --git a/web-wallet/src/lib/test-helpers/__tests__/fillCacheDatabase.spec.js b/web-wallet/src/lib/test-helpers/__tests__/fillCacheDatabase.spec.js index 6193c274bc..d1b95661a9 100644 --- a/web-wallet/src/lib/test-helpers/__tests__/fillCacheDatabase.spec.js +++ b/web-wallet/src/lib/test-helpers/__tests__/fillCacheDatabase.spec.js @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { sortWith } from "lamb"; +import { getKey, skipIn, sortWith } from "lamb"; import { + cacheBalances, cachePendingNotesInfo, cacheSpentNotes, + cacheStakeInfo, cacheSyncInfo, cacheUnspentNotes, } from "$lib/mock-data"; @@ -23,6 +25,9 @@ const sortByDbNullifier = sortWith([ ), ]); +const sortByAccount = sortWith([getKey("account")]); +const sortByAddress = sortWith([getKey("address")]); + /** @type {(entry: WalletCacheNote) => WalletCacheDbNote} */ const toDbNote = (entry) => ({ ...entry, @@ -52,12 +57,26 @@ describe("fillCacheDatabase", () => { await db.open(); + await expect( + db.table("balancesInfo").toArray().then(sortByAddress) + ).resolves.toStrictEqual(sortByAddress(cacheBalances)); await expect( db.table("pendingNotesInfo").toArray().then(sortByDbNullifier) ).resolves.toStrictEqual(expectedPendingNotesInfo); await expect( db.table("spentNotes").toArray().then(sortByDbNullifier) ).resolves.toStrictEqual(expectedSpentNotes); + await expect( + db.table("stakeInfo").toArray().then(sortByAccount) + ).resolves.toStrictEqual( + sortByAccount(cacheStakeInfo).map((entry) => ({ + ...entry, + stakeInfo: { + ...entry.stakeInfo, + amount: skipIn(entry.stakeInfo.amount, ["total"]), + }, + })) + ); await expect(db.table("syncInfo").toArray()).resolves.toStrictEqual( cacheSyncInfo ); diff --git a/web-wallet/src/lib/test-helpers/__tests__/getCacheDatabase.spec.js b/web-wallet/src/lib/test-helpers/__tests__/getCacheDatabase.spec.js index 2deeb64ec7..8f998ffa47 100644 --- a/web-wallet/src/lib/test-helpers/__tests__/getCacheDatabase.spec.js +++ b/web-wallet/src/lib/test-helpers/__tests__/getCacheDatabase.spec.js @@ -16,6 +16,7 @@ describe("getCacheDatabase", () => { "spentNotes", "syncInfo", "unspentNotes", + "stakeInfo", ] `); diff --git a/web-wallet/src/lib/test-helpers/fillCacheDatabase.js b/web-wallet/src/lib/test-helpers/fillCacheDatabase.js index 1eadf1931c..48c941016b 100644 --- a/web-wallet/src/lib/test-helpers/fillCacheDatabase.js +++ b/web-wallet/src/lib/test-helpers/fillCacheDatabase.js @@ -1,10 +1,11 @@ -import { mapWith } from "lamb"; +import { mapWith, skipIn } from "lamb"; import { getCacheDatabase } from "."; import { cacheBalances, cachePendingNotesInfo, cacheSpentNotes, + cacheStakeInfo, cacheSyncInfo, cacheUnspentNotes, } from "$lib/mock-data"; @@ -27,6 +28,23 @@ const fixNotes = mapWith((record) => ({ nullifier: record.nullifier.buffer, })); +/** + * In IndexedDB objects with a getter will be + * written without the getter. + * + * In `fake-indexeddb` apparently the getter is + * written as a normal prop. + * + * Hence we remove it to simulate the real situation. + */ +const fixStakeInfo = mapWith((entry) => ({ + ...entry, + stakeInfo: { + ...entry.stakeInfo, + amount: skipIn(entry.stakeInfo.amount, ["total"]), + }, +})); + /** @type {() => Promise<void>} */ async function fillCacheDatabase() { const db = getCacheDatabase(); @@ -40,6 +58,7 @@ async function fillCacheDatabase() { "balancesInfo", "pendingNotesInfo", "spentNotes", + "stakeInfo", "syncInfo", "unspentNotes", ], @@ -49,6 +68,7 @@ async function fillCacheDatabase() { .table("pendingNotesInfo") .bulkPut(fixPending(cachePendingNotesInfo)); await db.table("spentNotes").bulkPut(fixNotes(cacheSpentNotes)); + await db.table("stakeInfo").bulkPut(fixStakeInfo(cacheStakeInfo)); await db.table("syncInfo").bulkPut(cacheSyncInfo); await db.table("unspentNotes").bulkPut(fixNotes(cacheUnspentNotes)); } diff --git a/web-wallet/src/lib/test-helpers/getCacheDatabase.js b/web-wallet/src/lib/test-helpers/getCacheDatabase.js index c2c4de3cd3..c6791b59f9 100644 --- a/web-wallet/src/lib/test-helpers/getCacheDatabase.js +++ b/web-wallet/src/lib/test-helpers/getCacheDatabase.js @@ -12,6 +12,15 @@ function getCacheDatabase() { unspentNotes: "nullifier,address", }); + db.version(2).stores({ + balancesInfo: "address", + pendingNotesInfo: "nullifier,txId", + spentNotes: "nullifier,address", + stakeInfo: "account", + syncInfo: "++", + unspentNotes: "nullifier,address", + }); + return db; } diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/bookkeeper.js b/web-wallet/src/lib/vendor/w3sper.js/src/bookkeeper.js index 648a4c1a32..0cf7febf51 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/bookkeeper.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/bookkeeper.js @@ -22,10 +22,14 @@ class BookEntry { Object.freeze(this); } - async balance(type) { + balance(type) { return this.bookkeeper.balance(this.profile[type]); } + stakeInfo() { + return this.bookkeeper.stakeInfo(this.profile.account); + } + transfer(amount) { return new Transfer(this).amount(amount); } @@ -60,6 +64,15 @@ export class Bookkeeper { } } + stakeInfo(identifier) { + const type = ProfileGenerator.typeOf(String(identifier)); + if (type !== "account") { + throw new TypeError("Only accounts can stake"); + } + + return this.#treasury.stakeInfo(identifier); + } + async pick(identifier, amount) { const notes = await this.#treasury.address(identifier); const seed = await ProfileGenerator.seedFrom(identifier); diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/encoders/b16.js b/web-wallet/src/lib/vendor/w3sper.js/src/encoders/b16.js new file mode 100644 index 0000000000..ea47f5ee33 --- /dev/null +++ b/web-wallet/src/lib/vendor/w3sper.js/src/encoders/b16.js @@ -0,0 +1,26 @@ +// @ts-nocheck +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +export const encode = (buffer) => + Array.from(buffer) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + +export function decode(string) { + // Check if the string has an even length and contains only valid hex characters + if (string.length % 2 !== 0 || !/^[\da-fA-F]+$/.test(string)) { + return null; + } + + const buffer = new Uint8Array(string.length / 2); + + for (let i = 0; i < string.length; i += 2) { + buffer[i / 2] = parseInt(string.slice(i, i + 2), 16); + } + + return buffer; +} diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/b58.js b/web-wallet/src/lib/vendor/w3sper.js/src/encoders/b58.js similarity index 100% rename from web-wallet/src/lib/vendor/w3sper.js/src/b58.js rename to web-wallet/src/lib/vendor/w3sper.js/src/encoders/b58.js diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/network/gas.js b/web-wallet/src/lib/vendor/w3sper.js/src/gas.js similarity index 100% rename from web-wallet/src/lib/vendor/w3sper.js/src/network/gas.js rename to web-wallet/src/lib/vendor/w3sper.js/src/gas.js diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/network/components/contracts.js b/web-wallet/src/lib/vendor/w3sper.js/src/network/components/contracts.js index 51177c2d43..f2c4ff6689 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/network/components/contracts.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/network/components/contracts.js @@ -35,4 +35,10 @@ export class Contracts { "0100000000000000000000000000000000000000000000000000000000000000" ); } + + get stakeContract() { + return this.withId( + "0200000000000000000000000000000000000000000000000000000000000000" + ); + } } diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/network/components/transactions.js b/web-wallet/src/lib/vendor/w3sper.js/src/network/components/transactions.js index 241a4cf097..039ddf6d4f 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/network/components/transactions.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/network/components/transactions.js @@ -5,11 +5,40 @@ // // Copyright (c) DUSK NETWORK. All rights reserved. -export class Transactions { +import { Gas } from "../../gas.js"; +import { RuesScope } from "../../rues/scope.js"; +import { RuesEvent } from "../../rues/event.js"; +import * as base16 from "../../encoders/b16.js"; + +class TransactionExecutedEvent extends RuesEvent { + constructor(type) { + super(type); + } + + get gasPaid() { + return new Gas({ + limit: this.payload["gas_spent"], + price: this.payload.inner.fee["gas_price"], + }).total; + } + + memo(options = {}) { + const buffer = base16.decode(this.payload.inner.memo); + + if (options.as === "string") { + return new TextDecoder().decode(buffer); + } + + return buffer; + } +} + +export class Transactions extends RuesScope { #scope = null; constructor(rues) { - this.#scope = rues.scope("transactions"); + super("transactions"); + this.#scope = rues.scope(this); } preverify(tx) { @@ -45,4 +74,13 @@ export class Transactions { withId(id) { return this.#scope.withId(id); } + + eventFrom(ruesEvent) { + switch (ruesEvent.origin.topic) { + case "executed": + return TransactionExecutedEvent.from(ruesEvent); + } + + return ruesEvent; + } } diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/network/mod.js b/web-wallet/src/lib/vendor/w3sper.js/src/network/mod.js index d7dd68abed..1f2484e0ed 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/network/mod.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/network/mod.js @@ -12,7 +12,7 @@ import { Node } from "./components/node.js"; import { Blocks } from "./components/blocks.js"; import { Transactions } from "./components/transactions.js"; import { Contracts } from "./components/contracts.js"; -import { Gas } from "./gas.js"; +import { Gas } from "../gas.js"; export { Gas }; export { AddressSyncer } from "./syncer/address.js"; diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/network/syncer/account.js b/web-wallet/src/lib/vendor/w3sper.js/src/network/syncer/account.js index 4702b79d1f..191c316616 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/network/syncer/account.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/network/syncer/account.js @@ -6,8 +6,93 @@ // Copyright (c) DUSK NETWORK. All rights reserved. import * as ProtocolDriver from "../../protocol-driver/mod.js"; -import * as base58 from "../../b58.js"; +import * as base58 from "../../encoders/b58.js"; +/** + * Represents the value staked, locked, and eligibility of a stake. + */ +class StakeAmount { + /** @type {bigint} */ + value = 0n; + /** @type {bigint} */ + locked = 0n; + /** @type {bigint} */ + eligibility = 0n; + + /** + * Returns the total amount of staked value, including locked value. + * + * @returns {bigint} Total staked amount. + */ + get total() { + return this.value + this.locked; + } +} + +/** + * Holds information about a user's stake, including amount, reward, + * and a nonce to prevent repeat attacks. Also tracks faults. + */ +class StakeInfo { + /** @type {StakeAmount|null} */ + amount; + /** @type {bigint} */ + reward; + /** @type {bigint} */ + nonce; + /** @type {number} */ + faults; + /** @type {number} */ + hardFaults; + + constructor() { + this.amount = null; + this.reward = 0n; + this.nonce = 0n; + this.faults = 0; + this.hardFaults = 0; + } + + /** + * Parses a buffer into a {StakeInfo} instance. + * + * @param {ArrayBuffer} buffer - The buffer containing stake data. + * @returns {StakeInfo} The parsed {StakeInfo} instance. + */ + static parse(buffer) { + const view = new DataView(buffer); + const stakeInfo = new StakeInfo(); + const hasStake = view.getUint8(0) === 1; + + if (!hasStake) { + return Object.freeze(stakeInfo); + } + + const hasStakeAmount = view.getUint8(8) === 1; + + if (hasStakeAmount) { + stakeInfo.amount = new StakeAmount(); + stakeInfo.amount.value = view.getBigUint64(16, true); + stakeInfo.amount.locked = view.getBigUint64(24, true); + stakeInfo.amount.eligibility = view.getBigUint64(32, true); + } + + stakeInfo.reward = view.getBigUint64(40, true); + stakeInfo.nonce = view.getBigUint64(48, true); + stakeInfo.faults = view.getUint8(56); + stakeInfo.hardFaults = view.getUint8(57); + + return Object.freeze(stakeInfo); + } +} + +/** + * Converts a resource, either a string or an object with an account, + * into an account buffer if it has a byteLength of 96. + * + * @param {Object|string} resource - The resource to convert. + * @returns {ArrayBuffer|Object|string} The account buffer or the resource. + */ function intoAccount(resource) { if (resource?.account?.valueOf()?.byteLength === 96) { return resource.account; @@ -21,31 +106,82 @@ function intoAccount(resource) { return resource; } +/** + * Converts account profiles into raw representations. + * + * @param {Array<Object>} profiles - Array of profile objects. + * @returns {Promise<Array<Uint8Array>>} The raw account buffers. + */ +const accountsIntoRaw = (profiles) => + ProtocolDriver.accountsIntoRaw(profiles.map(intoAccount)); + +/** + * Parses a buffer to extract account balance information. + * + * @param {ArrayBuffer} buffer - The buffer containing balance data. + * @returns {{ nonce: bigint, value: bigint }} The parsed balance data. + */ +const parseBalance = (buffer) => { + const view = new DataView(buffer); + const nonce = view.getBigUint64(0, true); + const value = view.getBigUint64(8, true); + + return { nonce, value }; +}; + +/** + * Syncs account data by querying the network for balance and stake information. + * + * @extends EventTarget + */ export class AccountSyncer extends EventTarget { + /** @type {Object} */ #network; + /** + * Creates an AccountSyncer instance. + * @param {Object} network - The network interface for accessing accounts. + */ constructor(network) { super(); this.#network = network; } + /** + * Fetches the balances for the given profiles. + * + * @param {Array<Object>} profiles - Array of profile objects. + * @returns {Promise<Array<{ nonce: bigint, value: bigint }>>} Array of balances. + */ async balances(profiles) { - const rawUsers = await ProtocolDriver.accountsIntoRaw( - profiles.map(intoAccount) + const balances = await accountsIntoRaw(profiles).then((rawUsers) => + rawUsers.map((user) => + this.#network.contracts.transferContract.call.account(user) + ) ); - let balances = rawUsers.map((user) => - this.#network.contracts.transferContract.call.account(user) + return Promise.all(balances) + .then((responses) => responses.map((resp) => resp.arrayBuffer())) + .then((buffers) => Promise.all(buffers)) + .then((buffers) => buffers.map(parseBalance)); + } + + /** + * Fetches the stakes for the given profiles. + * + * @param {Array<Object>} profiles - Array of profile objects. + * @returns {Promise<Array<StakeInfo>>} Array of parsed stake information. + */ + async stakes(profiles) { + const stakes = await accountsIntoRaw(profiles).then((rawUsers) => + rawUsers.map((user) => + this.#network.contracts.stakeContract.call.get_stake(user) + ) ); - return await Promise.all(balances) + return Promise.all(stakes) .then((responses) => responses.map((resp) => resp.arrayBuffer())) .then((buffers) => Promise.all(buffers)) - .then((buffers) => - buffers.map((buffer) => ({ - nonce: new DataView(buffer).getBigUint64(0, true), - value: new DataView(buffer).getBigUint64(8, true), - })) - ); + .then((buffers) => buffers.map(StakeInfo.parse)); } } diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/profile.js b/web-wallet/src/lib/vendor/w3sper.js/src/profile.js index 4d0cc8b6ac..ada3a4750f 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/profile.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/profile.js @@ -6,7 +6,7 @@ // Copyright (c) DUSK NETWORK. All rights reserved. import * as ProtocolDriver from "../src/protocol-driver/mod.js"; -import * as base58 from "./b58.js"; +import * as base58 from "./encoders/b58.js"; const _index = Symbol("profile::index"); const _seeder = Symbol("profile::seeder"); diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/protocol-driver/mod.js b/web-wallet/src/lib/vendor/w3sper.js/src/protocol-driver/mod.js index 6bd242209f..a3ead4b0e9 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/protocol-driver/mod.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/protocol-driver/mod.js @@ -574,6 +574,15 @@ export const moonlight = async (info) => let tx = await malloc(4); let hash = await malloc(64); + const data = serializeMemo(info.data); + + if (data) { + ptr.data = await malloc(data.byteLength); + await memcpy(ptr.data, data); + } else { + ptr.data = null; + } + // Copy the value to the WASM memory const code = await moonlight( ptr.seed, @@ -585,7 +594,7 @@ export const moonlight = async (info) => ptr.gas_price, ptr.nonce, info.chainId, - info.data, + ptr.data, tx, hash ); @@ -783,3 +792,28 @@ export const shield = async (info) => hash = new TextDecoder().decode(await memcpy(null, hash, 64)); return [tx_buffer, hash]; })(); + +function serializeMemo(memo) { + if (!memo) { + return null; + } + + let buffer = null; + if (typeof memo === "string") { + buffer = new TextEncoder().encode(memo); + } else if (memo instanceof ArrayBuffer) { + buffer = new Uint8Array(memo); + } else if (memo instanceof Uint8Array) { + buffer = memo; + } + + if (!buffer) { + return null; + } + + const memoBuffer = new Uint8Array(1 + buffer.byteLength); + memoBuffer[0] = 3; // Memo type + memoBuffer.set(buffer, 1); + + return new Uint8Array(DataBuffer.from(memoBuffer)); +} diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/rues/event.js b/web-wallet/src/lib/vendor/w3sper.js/src/rues/event.js index c09cba2575..8c545b4dbc 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/rues/event.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/rues/event.js @@ -53,16 +53,19 @@ export class RuesEvent extends Event { } static from(event, options = {}) { + let headers; + let payload; + if (event instanceof MessageEvent) { const { data } = event; const headersLength = new DataView(data).getUint32(0, true); const headersBuffer = new Uint8Array(data, 4, headersLength); - const headers = new Headers( + + headers = new Headers( JSON.parse(new TextDecoder().decode(headersBuffer)) ); const body = new Uint8Array(data, 4 + headersLength); - let payload; switch (headers.get("content-type")) { case "application/json": payload = JSON.parse(new TextDecoder().decode(body)); @@ -77,28 +80,20 @@ export class RuesEvent extends Event { payload = body; } } - - let type = new RuesEventOrigin( - headers.get("content-location"), - options - ).toString(); - - const ruesEvent = new RuesEvent(type); - ruesEvent.#headers = headers; - ruesEvent.#payload = payload; - - return ruesEvent; } else if (event instanceof RuesEvent) { - let type = new RuesEventOrigin( - event.headers.get("content-location"), - options - ).toString(); + headers = event.headers; + payload = event.payload; + } - const ruesEvent = new RuesEvent(type); - ruesEvent.#headers = event.headers; - ruesEvent.#payload = event.payload; + let type = new RuesEventOrigin( + headers.get("content-location"), + options + ).toString(); - return ruesEvent; - } + const ruesEvent = new this(type); + ruesEvent.#headers = headers; + ruesEvent.#payload = payload; + + return ruesEvent; } } diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/rues/mod.js b/web-wallet/src/lib/vendor/w3sper.js/src/rues/mod.js index 17e8a3c620..1c4ed4857d 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/rues/mod.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/rues/mod.js @@ -8,6 +8,7 @@ import { CallableProxy } from "./callable.js"; import { ListenerProxy } from "./listener.js"; import { RuesEvent } from "./event.js"; +import { RuesScope } from "./scope.js"; const _rues = Symbol("rues"); @@ -64,12 +65,15 @@ class RuesTarget { export class Rues extends EventTarget { #url; #socket; + #scopes; #session; #version = "0.8.0"; constructor(url, options = {}) { super(); + this.#scopes = new Map(); + if (typeof url === "string") { this.#url = new URL(url); } else if (!(url instanceof URL)) { @@ -155,7 +159,16 @@ export class Rues extends EventTarget { return this.#socket?.readyState === WebSocket.OPEN; } - scope(name, options = {}) { + scope(source, options = {}) { + let name; + + if (typeof source === "string") { + name = source; + } else if (source instanceof RuesScope) { + ({ name } = source); + this.#scopes.set(name, source); + } + const target = new RuesTarget(name, options); target[_rues] = this; @@ -165,6 +178,13 @@ export class Rues extends EventTarget { handleEvent(event) { if (event instanceof MessageEvent) { let ruesEvent = RuesEvent.from(event); + + const scope = this.#scopes.get(ruesEvent.origin.scope); + + if (scope) { + ruesEvent = scope.eventFrom(ruesEvent); + } + let ruesComponentEvent = RuesEvent.from(ruesEvent, { as: "component" }); this.dispatchEvent(ruesEvent); diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/rues/scope.js b/web-wallet/src/lib/vendor/w3sper.js/src/rues/scope.js new file mode 100644 index 0000000000..18c8e59b74 --- /dev/null +++ b/web-wallet/src/lib/vendor/w3sper.js/src/rues/scope.js @@ -0,0 +1,16 @@ +// @ts-nocheck +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +export class RuesScope { + constructor(name) { + this.name = name; + } + + eventFrom(ruesEvent) { + return ruesEvent; + } +} diff --git a/web-wallet/src/lib/vendor/w3sper.js/src/transaction.js b/web-wallet/src/lib/vendor/w3sper.js/src/transaction.js index ea8ab956bc..d75bc7ffe1 100644 --- a/web-wallet/src/lib/vendor/w3sper.js/src/transaction.js +++ b/web-wallet/src/lib/vendor/w3sper.js/src/transaction.js @@ -9,10 +9,10 @@ export const TRANSFER = "0100000000000000000000000000000000000000000000000000000000000000"; import { AddressSyncer } from "./network/syncer/address.js"; -import { Gas } from "./network/gas.js"; import * as ProtocolDriver from "./protocol-driver/mod.js"; import { ProfileGenerator, Profile } from "./profile.js"; -import * as base58 from "./b58.js"; +import * as base58 from "./encoders/b58.js"; +import { Gas } from "./gas.js"; const _attributes = Symbol("builder::attributes"); @@ -86,10 +86,15 @@ class AccountTransfer extends Transfer { return this; } + memo(value) { + this[_attributes].memo = value; + return this; + } + async build(network) { const sender = this.bookentry.profile; const { attributes } = this; - const { to, amount: transfer_value, gas } = attributes; + const { to, amount: transfer_value, memo: data, gas } = attributes; const receiver = base58.decode(to); @@ -122,7 +127,7 @@ class AccountTransfer extends Transfer { gas_price: gas.price, nonce, chainId, - data: null, + data, }); return Object.freeze({ diff --git a/web-wallet/src/lib/vendor/wallet_core.wasm b/web-wallet/src/lib/vendor/wallet_core.wasm index 0d1fbd1c44..e98a5cfe2c 100755 Binary files a/web-wallet/src/lib/vendor/wallet_core.wasm and b/web-wallet/src/lib/vendor/wallet_core.wasm differ 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 58369c3516..f779a2e0b9 100644 --- a/web-wallet/src/lib/wallet-cache/__tests__/index.spec.js +++ b/web-wallet/src/lib/wallet-cache/__tests__/index.spec.js @@ -15,12 +15,14 @@ import { take, takeFrom, uniques, + updatePathIn, } from "lamb"; import { cacheBalances, cachePendingNotesInfo, cacheSpentNotes, + cacheStakeInfo, cacheSyncInfo, cacheUnspentNotes, } from "$lib/mock-data"; @@ -181,6 +183,26 @@ describe("Wallet cache", () => { ).resolves.toStrictEqual([]); }); + it("should expose a method to retrieve the cached stake info for a given account", async () => { + for (const stakeInfo of cacheStakeInfo) { + await expect( + walletCache.getStakeInfo(stakeInfo.account) + ).resolves.toStrictEqual(stakeInfo.stakeInfo); + } + }); + + it('should return "empty" stake info if none is stored in the cache for the given account', async () => { + await expect( + walletCache.getStakeInfo("fake-account") + ).resolves.toStrictEqual({ + amount: null, + faults: 0, + hardFaults: 0, + nonce: 0n, + reward: 0n, + }); + }); + it("should expose a method to retrieve the sync info, which returns `{ blockHeight: 0n, bookmark: 0n }` if there is no info stored", async () => { await expect(walletCache.getSyncInfo()).resolves.toStrictEqual( cacheSyncInfo[0] @@ -577,14 +599,21 @@ describe("Wallet cache", () => { ).resolves.toStrictEqual(balanceInfo.balance); } + // overwrite test + const modifiedBalance = updatePathIn( + newBalance.balance, + "shielded.value", + add(345n) + ); + await walletCache.setBalanceInfo( cacheBalances[0].address, - newBalance.balance + modifiedBalance ); await expect( walletCache.getBalanceInfo(cacheBalances[0].address) - ).resolves.toStrictEqual(newBalance.balance); + ).resolves.toStrictEqual(modifiedBalance); }); it("should expose a method to update the last block height", async () => { @@ -647,6 +676,53 @@ describe("Wallet cache", () => { ).resolves.toStrictEqual(expectedInfo); }); + it("should expose a method to set the stake info of a given account", async () => { + const newStakeInfo = { + account: "fake-account", + stakeInfo: { + amount: { + eligibility: 123n, + locked: 789n, + get total() { + return this.value + this.locked; + }, + value: 3456n, + }, + faults: 1, + hardFaults: 1, + nonce: 10n, + reward: 9000n, + }, + }; + + await walletCache.setStakeInfo( + newStakeInfo.account, + newStakeInfo.stakeInfo + ); + + for (const stakeInfo of cacheStakeInfo.concat(newStakeInfo)) { + await expect( + walletCache.getStakeInfo(stakeInfo.account) + ).resolves.toStrictEqual(stakeInfo.stakeInfo); + } + + // overwrite test + const modifiedStakeInfo = updatePathIn( + newStakeInfo.stakeInfo, + "amount.eligibility", + add(345n) + ); + + await walletCache.setStakeInfo( + cacheStakeInfo[0].account, + modifiedStakeInfo + ); + + await expect( + walletCache.getStakeInfo(cacheStakeInfo[0].account) + ).resolves.toStrictEqual(modifiedStakeInfo); + }); + 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 3767c123c3..5ffa2eee8f 100644 --- a/web-wallet/src/lib/wallet-cache/index.js +++ b/web-wallet/src/lib/wallet-cache/index.js @@ -1,6 +1,7 @@ import { Dexie } from "dexie"; import { compose, + condition, getKey, getPath, head, @@ -15,8 +16,8 @@ import { when, } from "lamb"; -/** @typedef {{ nullifiers?: Uint8Array[] } | { addresses?: string[] }} RawCriteria */ -/** @typedef {{ field: "nullifier", values: Uint8Array[] } | { field: "address", values: string[]} | undefined} Criteria */ +/** @typedef {{ nullifiers?: Uint8Array[] } | { addresses?: string[] } | { accounts?: string[] }} RawCriteria */ +/** @typedef {{ field: "nullifier", values: Uint8Array[] } | { field: "address", values: string[]} | { field: "account", values: string[]} | undefined} Criteria */ /** @type {(buffer: ArrayBuffer) => Uint8Array} */ const bufferToUint8Array = (buffer) => new Uint8Array(buffer); @@ -45,7 +46,12 @@ const toCriteria = pipe([ pairs, head, unless(isUndefined, (pair) => ({ - field: pair[0] === "nullifiers" ? "nullifier" : "address", + field: + pair[0] === "nullifiers" + ? "nullifier" + : pair[0] === "addresses" + ? "address" + : "account", values: pair[1], })), ]); @@ -93,6 +99,15 @@ class WalletCache { unspentNotes: "nullifier,address", }); + db.version(2).stores({ + balancesInfo: "address", + pendingNotesInfo: "nullifier,txId", + spentNotes: "nullifier,address", + stakeInfo: "account", + syncInfo: "++", + unspentNotes: "nullifier,address", + }); + this.#db = db; } @@ -167,6 +182,43 @@ class WalletCache { ); } + /** + * @param {string} account + * @returns {Promise<StakeInfo>} + */ + getStakeInfo(account) { + return this.#getEntriesFrom("stakeInfo", false, { + accounts: [account], + }) + .then(getPath("0.stakeInfo")) + .then( + condition( + isUndefined, + () => ({ + amount: null, + faults: 0, + hardFaults: 0, + nonce: 0n, + reward: 0n, + }), + + // we reinstate the `total` getter if the + // amount is not `null` + (stakeInfo) => ({ + ...stakeInfo, + amount: stakeInfo.amount + ? { + ...stakeInfo.amount, + get total() { + return this.value + this.locked; + }, + } + : null, + }) + ) + ); + } + /** @returns {Promise<WalletCacheSyncInfo>} */ getSyncInfo() { return this.#getEntriesFrom("syncInfo", false) @@ -272,6 +324,20 @@ class WalletCache { .finally(() => this.#db.close()); } + /** + * @param {string} account + * @param {StakeInfo} stakeInfo + * @returns {Promise<void>} + */ + async setStakeInfo(account, stakeInfo) { + return this.#db + .open() + .then(async (db) => { + await db.table("stakeInfo").put({ account, stakeInfo }); + }) + .finally(() => this.#db.close()); + } + /** * @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 15a83cb6c0..2225570585 100644 --- a/web-wallet/src/lib/wallet-cache/wallet-cache.d.ts +++ b/web-wallet/src/lib/wallet-cache/wallet-cache.d.ts @@ -17,14 +17,23 @@ type WalletCacheDbNote = Omit<WalletCacheNote, "note" | "nullifier"> & { nullifier: ArrayBuffer; }; +type WalletCacheDbStakeInfo = { + account: string; + stakeInfo: Omit<StakeInfo, "amount"> & { + amount: null | Omit<Exclude<StakeAmount, null>, "total">; + }; +}; + type WalletCacheGetDataType<T extends WalletCacheTableName> = T extends "balancesInfo" ? WalletCacheBalanceInfo[] : T extends "pendingNotesInfo" ? WalletCacheDbPendingNoteInfo[] - : T extends "syncInfo" - ? WalletCacheSyncInfo[] - : WalletCacheDbNote[]; + : T extends "stakeInfo" + ? WalletCacheDbStakeInfo[] + : T extends "syncInfo" + ? WalletCacheSyncInfo[] + : WalletCacheDbNote[]; type WalletCacheGetEntriesReturnType< T extends WalletCacheTableName, @@ -35,7 +44,9 @@ type WalletCacheGetEntriesReturnType< ? never : T extends "balancesInfo" ? never - : ArrayBuffer[]; + : T extends "stakeInfo" + ? never + : ArrayBuffer[]; type WalletCacheHistoryEntry = { history: Transaction[]; @@ -67,4 +78,5 @@ type WalletCacheTableName = | "pendingNotesInfo" | "syncInfo" | "spentNotes" + | "stakeInfo" | "unspentNotes"; 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 eb4bed189e..a2be7eb3ef 100644 --- a/web-wallet/src/lib/wallet-treasury/__tests__/index.spec.js +++ b/web-wallet/src/lib/wallet-treasury/__tests__/index.spec.js @@ -128,5 +128,38 @@ describe("WalletTreasury", () => { ).toStrictEqual(sortNullifiers(pluckFrom(expectedNotes, "note"))); expect(unspentNotesMapB).toStrictEqual(new Map()); }); + + it("should implement the `stakeInfo` method of the treasury interface to retrieve the stake info for a given account", async () => { + const abortController = new AbortController(); + + await walletTreasury.update(0n, () => {}, abortController.signal); + + // @ts-expect-error We don't care to pass a real `Key` object right now + await expect(walletTreasury.stakeInfo(fakeKey)).resolves.toStrictEqual( + expect.objectContaining({ + amount: expect.objectContaining({ + eligibility: expect.any(BigInt), + locked: expect.any(BigInt), + total: expect.any(BigInt), + value: expect.any(BigInt), + }), + faults: expect.any(Number), + hardFaults: expect.any(Number), + nonce: expect.any(BigInt), + reward: expect.any(BigInt), + }) + ); + }); + + it("should return a rejected promise if the `stakeInfo` method isn't able to find the stake info for the given account", async () => { + await expect( + // @ts-expect-error We don't care to pass a real `Key` object right now + walletTreasury.stakeInfo({ + toString() { + return "non-existent address"; + }, + }) + ).rejects.toThrow(); + }); }); }); diff --git a/web-wallet/src/lib/wallet-treasury/index.js b/web-wallet/src/lib/wallet-treasury/index.js index 6487b9525f..55a4702745 100644 --- a/web-wallet/src/lib/wallet-treasury/index.js +++ b/web-wallet/src/lib/wallet-treasury/index.js @@ -10,6 +10,9 @@ class WalletTreasury { #profiles; + /** @type {StakeInfo[]} */ + #accountStakeInfo = []; + /** @param {Array<import("$lib/vendor/w3sper.js/src/mod").Profile>} profiles */ constructor(profiles = []) { this.#profiles = profiles; @@ -59,6 +62,23 @@ class WalletTreasury { this.#profiles = profiles; } + /** + * @param {import("$lib/vendor/w3sper.js/src/mod").Profile["account"]} identifier + * @returns {Promise<StakeInfo>} + */ + async stakeInfo(identifier) { + const stakeInfo = this.#accountStakeInfo.at(+identifier); + + return ( + stakeInfo ?? + Promise.reject( + new Error( + "No stake info found for the account with the given identifier" + ) + ) + ); + } + /** * @param {bigint | import("$lib/vendor/w3sper.js/src/mod").Bookmark} from * @param {(evt: CustomEvent) => void} syncIterationListener @@ -72,7 +92,10 @@ class WalletTreasury { // @ts-ignore addressSyncer.addEventListener("synciteration", syncIterationListener); - this.#accountBalances = await accountSyncer.balances(this.#profiles); + [this.#accountBalances, this.#accountStakeInfo] = await Promise.all([ + accountSyncer.balances(this.#profiles), + accountSyncer.stakes(this.#profiles), + ]); const notesStream = await addressSyncer.notes(this.#profiles, { from, diff --git a/web-wallet/src/lib/wallet-treasury/wallet-treasury.d.ts b/web-wallet/src/lib/wallet-treasury/wallet-treasury.d.ts index 75be1673ec..bb78cfcd11 100644 --- a/web-wallet/src/lib/wallet-treasury/wallet-treasury.d.ts +++ b/web-wallet/src/lib/wallet-treasury/wallet-treasury.d.ts @@ -7,3 +7,9 @@ type AddressBalance = { spendable: bigint; value: bigint; }; + +type StakeInfo = Awaited< + ReturnType<import("$lib/vendor/w3sper.js/src/mod").AccountSyncer["stakes"]> +>[number]; + +type StakeAmount = StakeInfo["amount"]; diff --git a/web-wallet/src/lib/wallet/wallet.d.ts b/web-wallet/src/lib/wallet/wallet.d.ts index 5be373d72d..bcc2a847cf 100644 --- a/web-wallet/src/lib/wallet/wallet.d.ts +++ b/web-wallet/src/lib/wallet/wallet.d.ts @@ -3,10 +3,3 @@ type MnemonicEncryptInfo = { iv: Uint8Array; salt: Uint8Array; }; - -type WalletStakeInfo = { - amount: number; - reward: number; - has_key: boolean; - has_staked: boolean; -}; diff --git a/web-wallet/src/routes/(app)/dashboard/staking/__tests__/__snapshots__/page.spec.js.snap b/web-wallet/src/routes/(app)/dashboard/staking/__tests__/__snapshots__/page.spec.js.snap index 0bf71360b1..7c9d03ea80 100644 --- a/web-wallet/src/routes/(app)/dashboard/staking/__tests__/__snapshots__/page.spec.js.snap +++ b/web-wallet/src/routes/(app)/dashboard/staking/__tests__/__snapshots__/page.spec.js.snap @@ -89,7 +89,7 @@ exports[`Staking > should render the staking page 1`] = ` class="contract-statuses__value svelte-1up3579" > <span> - 0.000000000 + N/A </span> <svg @@ -145,7 +145,6 @@ exports[`Staking > should render the staking page 1`] = ` > <button class="dusk-button dusk-button--type--button dusk-button--variant--primary dusk-button--size--default dusk-icon-button--labeled contract-operations__operation-button" - disabled="" type="button" > <svg