Skip to content

Commit

Permalink
w3sper: Add an API to fetch stake information for the given account(s)
Browse files Browse the repository at this point in the history
- Remove unnecessary `async` from `BookEntry#balance`
- Add `BookEntry#stakeInfo()` method
- Add `Bookkeeper#stakeInfo()` method
- Add `Contracts#stakeContract` getter
- Add private `StakeAmount` class in `AccountSyncer`
- Add private `StakeInfo` class in `AccountSyncer`
- Add `AccountSyncer#stakes` method
- Change test Treasury to fetch and store stakes information
- Add `tests/stake_test.js`

Resolves #2942
  • Loading branch information
ZER0 committed Nov 12, 2024
1 parent 1d0fb37 commit ebbdbb9
Show file tree
Hide file tree
Showing 5 changed files with 283 additions and 13 deletions.
15 changes: 14 additions & 1 deletion w3sper.js/src/bookkeeper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions w3sper.js/src/network/components/contracts.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,10 @@ export class Contracts {
"0100000000000000000000000000000000000000000000000000000000000000",
);
}

get stakeContract() {
return this.withId(
"0200000000000000000000000000000000000000000000000000000000000000",
);
}
}
158 changes: 147 additions & 11 deletions w3sper.js/src/network/syncer/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,31 +105,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));
}
}
10 changes: 9 additions & 1 deletion w3sper.js/tests/harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export class Treasury {
#keySet = new Set();

#accounts = [];
#stakes = [];

lastSyncInfo;

Expand All @@ -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) {
Expand Down Expand Up @@ -131,4 +135,8 @@ export class Treasury {
account(identifier) {
return this.#accounts.at(+identifier);
}

stakeInfo(identifier) {
return this.#stakes.at(+identifier);
}
}
107 changes: 107 additions & 0 deletions w3sper.js/tests/stake_test.js
Original file line number Diff line number Diff line change
@@ -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);
});

0 comments on commit ebbdbb9

Please sign in to comment.