diff --git a/w3sper.js/src/bookkeeper.js b/w3sper.js/src/bookkeeper.js index 076af8bc9..0c9f422ab 100644 --- a/w3sper.js/src/bookkeeper.js +++ b/w3sper.js/src/bookkeeper.js @@ -21,10 +21,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); } @@ -59,6 +63,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/w3sper.js/src/network/components/contracts.js b/w3sper.js/src/network/components/contracts.js index b8d809260..6d4227994 100644 --- a/w3sper.js/src/network/components/contracts.js +++ b/w3sper.js/src/network/components/contracts.js @@ -34,4 +34,10 @@ export class Contracts { "0100000000000000000000000000000000000000000000000000000000000000", ); } + + get stakeContract() { + return this.withId( + "0200000000000000000000000000000000000000000000000000000000000000", + ); + } } diff --git a/w3sper.js/src/network/syncer/account.js b/w3sper.js/src/network/syncer/account.js index 7a2d42db0..17e5ab9e4 100644 --- a/w3sper.js/src/network/syncer/account.js +++ b/w3sper.js/src/network/syncer/account.js @@ -7,6 +7,91 @@ import * as ProtocolDriver from "../../protocol-driver/mod.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; @@ -20,31 +105,82 @@ function intoAccount(resource) { return resource; } +/** + * Converts account profiles into raw representations. + * + * @param {Array} profiles - Array of profile objects. + * @returns {Promise>} 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} profiles - Array of profile objects. + * @returns {Promise>} 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} profiles - Array of profile objects. + * @returns {Promise>} 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/w3sper.js/tests/harness.js b/w3sper.js/tests/harness.js index aa9d3f34e..91b7e2445 100644 --- a/w3sper.js/tests/harness.js +++ b/w3sper.js/tests/harness.js @@ -72,6 +72,7 @@ export class Treasury { #keySet = new Set(); #accounts = []; + #stakes = []; lastSyncInfo; @@ -85,7 +86,10 @@ export class Treasury { async update({ from, addresses, accounts }) { if (accounts) { - this.#accounts = await accounts.balances(this.#users); + [this.#accounts, this.#stakes] = await Promise.all([ + accounts.balances(this.#users), + accounts.stakes(this.#users), + ]); } if (!addresses) { @@ -131,4 +135,8 @@ export class Treasury { account(identifier) { return this.#accounts.at(+identifier); } + + stakeInfo(identifier) { + return this.#stakes.at(+identifier); + } } diff --git a/w3sper.js/tests/stake_test.js b/w3sper.js/tests/stake_test.js new file mode 100644 index 000000000..a04aab79b --- /dev/null +++ b/w3sper.js/tests/stake_test.js @@ -0,0 +1,107 @@ +// 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. + +import { + Network, + ProfileGenerator, + Bookkeeper, + AccountSyncer, +} from "../src/mod.js"; + +import { test, assert, seeder, Treasury } from "./harness.js"; + +test.withLocalWasm = "release"; + +/** + * Tests fetching the stake information using string representations + * of accounts, verifying access without requiring instances of + * ProfileGenerator, Treasury, or Bookkeeper. + */ +test("stake info without profiles", async () => { + const users = [ + "oCqYsUMRqpRn2kSabH52Gt6FQCwH5JXj5MtRdYVtjMSJ73AFvdbPf98p3gz98fQwNy9ZBiDem6m9BivzURKFSKLYWP3N9JahSPZs9PnZ996P18rTGAjQTNFsxtbrKx79yWu", + "ocXXBAafr7xFqQTpC1vfdSYdHMXerbPCED2apyUVpLjkuycsizDxwA6b9D7UW91kG58PFKqm9U9NmY9VSwufUFL5rVRSnFSYxbiKK658TF6XjHsHGBzavFJcxAzjjBRM4eF", + ]; + + const network = new Network("http://localhost:8080/"); + const syncer = new AccountSyncer(network); + + const stakes = await syncer.stakes(users); + + assert.equal(stakes.length, 2); + + assert.equal(stakes[0].amount.value, 1_000_000_000_000n); + assert.equal( + stakes[0].amount.total, + stakes[0].amount.value + stakes[0].amount.locked, + ); + assert.equal(stakes[0].amount.locked, 0n); + + // No check for reward's value since it is not deterministic + assert.equal(typeof stakes[0].reward, "bigint"); + assert.equal(stakes[0].nonce, 0n); + assert.equal(stakes[0].faults, 0); + assert.equal(stakes[0].hardFaults, 0); + + // No stakes for the 2nd user + assert.equal(stakes[1].amount, null); + assert.equal(stakes[1].reward, 0n); + assert.equal(stakes[1].nonce, 0n); + assert.equal(stakes[1].faults, 0); + assert.equal(stakes[1].hardFaults, 0); +}); + +/** + * Test fetching the stake information using profiles and treasury/bookkeeper + * instances. + * + * Although this requires more code than using the syncer directly, it enables + * the use of a decoupled cache to retrieve and store the stake information. + */ +test("stake info with treasury", async () => { + const profiles = new ProfileGenerator(seeder); + const users = await Promise.all([profiles.default, profiles.next()]); + + const network = new Network("http://localhost:8080/"); + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + + await treasury.update({ accounts }); + + const bookkeeper = new Bookkeeper(treasury); + + const stakes = await Promise.all([ + bookkeeper.stakeInfo(users[0].account), + bookkeeper.stakeInfo(users[1].account), + bookkeeper.as(users[0]).stakeInfo(), + ]); + + assert.equal(stakes.length, 3); + + // Stake information for the default profile matches + assert.equal(stakes[0], stakes[2]); + + assert.equal(stakes[0].amount.value, 1_000_000_000_000n); + assert.equal( + stakes[0].amount.total, + stakes[0].amount.value + stakes[0].amount.locked, + ); + assert.equal(stakes[0].amount.locked, 0n); + + // No check for reward's value since it is not deterministic + assert.equal(typeof stakes[0].reward, "bigint"); + assert.equal(stakes[0].nonce, 0n); + assert.equal(stakes[0].faults, 0); + assert.equal(stakes[0].hardFaults, 0); + + // No stakes for the 2nd user + assert.equal(stakes[1].amount, null); + assert.equal(stakes[1].reward, 0n); + assert.equal(stakes[1].nonce, 0n); + assert.equal(stakes[1].faults, 0); + assert.equal(stakes[1].hardFaults, 0); +});