From de78fafa9066c3a98ff4c64dd18771e2a0851e30 Mon Sep 17 00:00:00 2001 From: zer0 Date: Fri, 6 Dec 2024 14:02:49 +0100 Subject: [PATCH] w3sper: Update the SDK to match Stake contract changes - Remove `nonce` field from stake transfer and info - Add support for new stake features: top-ups, partial unstaking, etc. - Change SDK methods to align with the updated contract structure - Add validations for stake and top-up operations - Move stake transfer related tests in its own test file Resolves #3131 --- w3sper.js/src/bookkeeper.js | 26 +- w3sper.js/src/network/syncer/account.js | 20 +- w3sper.js/src/protocol-driver/mod.js | 6 - w3sper.js/src/transaction.js | 73 ++-- w3sper.js/tests/assets/genesis.toml | 2 +- w3sper.js/tests/harness.js | 2 +- w3sper.js/tests/stake_info_test.js | 14 +- w3sper.js/tests/stake_test.js | 528 ++++++++++++++++++++++++ w3sper.js/tests/transfer_test.js | 368 ++--------------- 9 files changed, 634 insertions(+), 405 deletions(-) create mode 100644 w3sper.js/tests/stake_test.js diff --git a/w3sper.js/src/bookkeeper.js b/w3sper.js/src/bookkeeper.js index 6f53812c6..5f8f43bd1 100644 --- a/w3sper.js/src/bookkeeper.js +++ b/w3sper.js/src/bookkeeper.js @@ -5,13 +5,13 @@ // Copyright (c) DUSK NETWORK. All rights reserved. import * as ProtocolDriver from "../src/protocol-driver/mod.js"; -import { ProfileGenerator, Profile } from "./profile.js"; +import { Profile, ProfileGenerator } from "./profile.js"; import { - Transfer, - UnshieldTransfer, ShieldTransfer, StakeTransfer, + Transfer, + UnshieldTransfer, UnstakeTransfer, WithdrawStakeRewardTransfer, } from "../src/transaction.js"; @@ -25,14 +25,9 @@ class BookEntry { } get info() { - const entry = this; return { - balance(type) { - return entry.bookkeeper.balance(entry.profile[type]); - }, - stake() { - return entry.bookkeeper.stakeInfo(entry.profile.account); - }, + balance: (type) => this.bookkeeper.balance(this.profile[type]), + stake: () => this.bookkeeper.stakeInfo(this.profile.account), }; } @@ -52,13 +47,17 @@ class BookEntry { return new StakeTransfer(this).amount(amount); } - unstake() { - return new UnstakeTransfer(this); + unstake(amount) { + return new UnstakeTransfer(this).amount(amount); } withdraw(amount) { return new WithdrawStakeRewardTransfer(this).amount(amount); } + + topup(amount) { + return new StakeTransfer(this, { topup: true }).amount(amount); + } } export class Bookkeeper { @@ -73,12 +72,13 @@ export class Bookkeeper { switch (type) { case "account": return await this.#treasury.account(identifier); - case "address": + case "address": { const notes = await this.#treasury.address(identifier); const seed = await ProfileGenerator.seedFrom(identifier); const index = +identifier; return ProtocolDriver.balance(seed, index, notes); + } } } diff --git a/w3sper.js/src/network/syncer/account.js b/w3sper.js/src/network/syncer/account.js index 17e5ab9e4..e7b88794e 100644 --- a/w3sper.js/src/network/syncer/account.js +++ b/w3sper.js/src/network/syncer/account.js @@ -29,8 +29,8 @@ class StakeAmount { } /** - * Holds information about a user's stake, including amount, reward, - * and a nonce to prevent repeat attacks. Also tracks faults. + * Holds information about a user's stake, including amount, reward + * and tracks faults. */ class StakeInfo { /** @type {StakeAmount|null} */ @@ -38,8 +38,6 @@ class StakeInfo { /** @type {bigint} */ reward; /** @type {bigint} */ - nonce; - /** @type {number} */ faults; /** @type {number} */ hardFaults; @@ -47,7 +45,6 @@ class StakeInfo { constructor() { this.amount = null; this.reward = 0n; - this.nonce = 0n; this.faults = 0; this.hardFaults = 0; } @@ -77,9 +74,8 @@ class StakeInfo { } stakeInfo.reward = view.getBigUint64(40, true); - stakeInfo.nonce = view.getBigUint64(48, true); - stakeInfo.faults = view.getUint8(56); - stakeInfo.hardFaults = view.getUint8(57); + stakeInfo.faults = view.getUint8(48); + stakeInfo.hardFaults = view.getUint8(49); return Object.freeze(stakeInfo); } @@ -155,8 +151,8 @@ export class AccountSyncer extends EventTarget { async balances(profiles) { const balances = await accountsIntoRaw(profiles).then((rawUsers) => rawUsers.map((user) => - this.#network.contracts.transferContract.call.account(user), - ), + this.#network.contracts.transferContract.call.account(user) + ) ); return Promise.all(balances) @@ -174,8 +170,8 @@ export class AccountSyncer extends EventTarget { async stakes(profiles) { const stakes = await accountsIntoRaw(profiles).then((rawUsers) => rawUsers.map((user) => - this.#network.contracts.stakeContract.call.get_stake(user), - ), + this.#network.contracts.stakeContract.call.get_stake(user) + ) ); return Promise.all(stakes) diff --git a/w3sper.js/src/protocol-driver/mod.js b/w3sper.js/src/protocol-driver/mod.js index b1253861a..248fd51dc 100644 --- a/w3sper.js/src/protocol-driver/mod.js +++ b/w3sper.js/src/protocol-driver/mod.js @@ -843,11 +843,6 @@ export const stake = async (info) => ptr.nonce = await malloc(8); await memcpy(ptr.nonce, nonce); - const stake_nonce = new Uint8Array(8); - new DataView(stake_nonce.buffer).setBigUint64(0, info.stake_nonce, true); - ptr.stake_nonce = await malloc(8); - await memcpy(ptr.stake_nonce, stake_nonce); - let tx = await malloc(4); let hash = await malloc(64); @@ -859,7 +854,6 @@ export const stake = async (info) => ptr.gas_price, ptr.nonce, info.chainId, - ptr.stake_nonce, tx, hash, ); diff --git a/w3sper.js/src/transaction.js b/w3sper.js/src/transaction.js index 1fc6d17f3..6e98039e6 100644 --- a/w3sper.js/src/transaction.js +++ b/w3sper.js/src/transaction.js @@ -9,7 +9,7 @@ export const TRANSFER = import { AddressSyncer } from "./network/syncer/address.js"; import * as ProtocolDriver from "./protocol-driver/mod.js"; -import { ProfileGenerator, Profile } from "./profile.js"; +import { Profile, ProfileGenerator } from "./profile.js"; import * as base58 from "./encoders/b58.js"; import { Gas } from "./gas.js"; @@ -52,7 +52,7 @@ export class Transfer extends BasicTransfer { to(value) { let builder; - let identifier = String(value); + const identifier = String(value); switch (ProfileGenerator.typeOf(identifier)) { case "account": builder = new AccountTransfer(this.bookentry); @@ -117,7 +117,7 @@ class AccountTransfer extends Transfer { nonce += 1n; - let [buffer, hash] = await ProtocolDriver.moonlight({ + const [buffer, hash] = await ProtocolDriver.moonlight({ sender, receiver, transfer_value, @@ -183,7 +183,7 @@ class AddressTransfer extends Transfer { const { chainId } = await network.node.info; // Create the unproven transaction - let [tx, circuits] = await ProtocolDriver.phoenix({ + const [tx, circuits] = await ProtocolDriver.phoenix({ sender, receiver, inputs, @@ -245,7 +245,7 @@ export class UnshieldTransfer extends BasicTransfer { const { chainId } = await network.node.info; // Create the unproven transaction - let [tx, circuits] = await ProtocolDriver.unshield({ + const [tx, circuits] = await ProtocolDriver.unshield({ profile, inputs, openings, @@ -279,7 +279,7 @@ export class ShieldTransfer extends BasicTransfer { async build(network) { const { attributes } = this; const { amount: allocate_value, gas } = attributes; - const { profile, bookkeeper } = this.bookentry; + const { profile } = this.bookentry; // Get the chain id from the network const { chainId } = await network.node.info; @@ -289,7 +289,7 @@ export class ShieldTransfer extends BasicTransfer { nonce += 1n; - let [buffer, hash] = await ProtocolDriver.shield({ + const [buffer, hash] = await ProtocolDriver.shield({ profile, allocate_value, gas_limit: gas.limit, @@ -307,35 +307,45 @@ export class ShieldTransfer extends BasicTransfer { } export class StakeTransfer extends BasicTransfer { - constructor(from) { + constructor(from, options = {}) { super(from); + this[_attributes].topup = Boolean(options.topup) || false; } async build(network) { const { attributes } = this; - const { amount: stake_value, gas } = attributes; + const { amount: stake_value, gas, topup: isTopup } = attributes; const { profile, bookkeeper } = this.bookentry; const minimumStake = await bookkeeper.minimumStake; - if (stake_value < minimumStake) { - throw new Error(`Stake value must be greater than ${minimumStake}`); + if (!isTopup && stake_value < minimumStake) { + throw new RangeError( + `Stake amount must be greater or equal than ${minimumStake}`, + ); } // Get the chain id from the network const { chainId } = await network.node.info; - // Obtain the nonces + // Obtain the infos let { nonce } = await this.bookentry.info.balance("account"); - let { nonce: stake_nonce } = await this.bookentry.info.stake(); + const stakeInfo = await this.bookentry.info.stake(); + const hasStake = stakeInfo.amount !== null; + + if (hasStake && !isTopup) { + throw new Error( + "Stake already exists. Use `topup` to add to the current stake", + ); + } else if (!hasStake && isTopup) { + throw new Error("No stake to topup. Use `stake` to create a new stake"); + } nonce += 1n; - stake_nonce += 1n; - let [buffer, hash] = await ProtocolDriver.stake({ + const [buffer, hash] = await ProtocolDriver.stake({ profile, stake_value, - stake_nonce, gas_limit: gas.limit, gas_price: gas.price, nonce, @@ -357,7 +367,7 @@ export class UnstakeTransfer extends BasicTransfer { async build(network) { const { attributes } = this; - const { gas } = attributes; + const { gas, amount: unstake_amount } = attributes; const { profile } = this.bookentry; // Get the chain id from the network @@ -367,13 +377,28 @@ export class UnstakeTransfer extends BasicTransfer { let { nonce } = await this.bookentry.info.balance("account"); // Obtain the staked amount - let { amount } = await this.bookentry.info.stake(); + const { amount } = await this.bookentry.info.stake(); + + const minimumStake = await this.bookentry.bookkeeper.minimumStake; nonce += 1n; - let [buffer, hash] = await ProtocolDriver.unstake({ + const unstake_value = + typeof unstake_amount === "bigint" && unstake_amount < amount.total + ? unstake_amount + : amount.total; + + const remainingStake = amount.total - unstake_value; + + if (remainingStake > 0n && remainingStake < minimumStake) { + throw new RangeError( + `Remaining stake must be greater or equal than ${minimumStake}`, + ); + } + + const [buffer, hash] = await ProtocolDriver.unstake({ profile, - unstake_value: amount.total, + unstake_value, gas_limit: gas.limit, gas_price: gas.price, nonce, @@ -405,23 +430,23 @@ export class WithdrawStakeRewardTransfer extends BasicTransfer { let { nonce } = await this.bookentry.info.balance("account"); // Obtain the staked amount - let { reward } = await this.bookentry.info.stake(); + const { reward } = await this.bookentry.info.stake(); if (!reward) { throw new Error(`No stake available to withdraw the reward from`); } else if (reward_amount > reward) { - throw new Error( + throw new RangeError( `The withdrawn reward amount must be less or equal to ${reward}`, ); } else if (!reward_amount) { - throw new Error( + throw new RangeError( `Can't withdraw an empty reward amount. I mean, you could, but it would be pointless.`, ); } nonce += 1n; - let [buffer, hash] = await ProtocolDriver.withdraw({ + const [buffer, hash] = await ProtocolDriver.withdraw({ profile, reward_amount, gas_limit: gas.limit, diff --git a/w3sper.js/tests/assets/genesis.toml b/w3sper.js/tests/assets/genesis.toml index 81f47d99a..852edf318 100644 --- a/w3sper.js/tests/assets/genesis.toml +++ b/w3sper.js/tests/assets/genesis.toml @@ -1,6 +1,6 @@ [[stake]] address = 'oCqYsUMRqpRn2kSabH52Gt6FQCwH5JXj5MtRdYVtjMSJ73AFvdbPf98p3gz98fQwNy9ZBiDem6m9BivzURKFSKLYWP3N9JahSPZs9PnZ996P18rTGAjQTNFsxtbrKx79yWu' -amount = 1_000_000_000_000 +amount = 2_000_000_000_000 [[moonlight_account]] address = 'oCqYsUMRqpRn2kSabH52Gt6FQCwH5JXj5MtRdYVtjMSJ73AFvdbPf98p3gz98fQwNy9ZBiDem6m9BivzURKFSKLYWP3N9JahSPZs9PnZ996P18rTGAjQTNFsxtbrKx79yWu' diff --git a/w3sper.js/tests/harness.js b/w3sper.js/tests/harness.js index 8e96194ad..2416efec1 100644 --- a/w3sper.js/tests/harness.js +++ b/w3sper.js/tests/harness.js @@ -20,8 +20,8 @@ const mergeMap = (dest, source, lookup) => { }; export { - test, assert, + test, } from "http://rawcdn.githack.com/mio-mini/test-harness/0.1.1/mod.js"; import { Bookmark } from "@dusk/w3sper"; diff --git a/w3sper.js/tests/stake_info_test.js b/w3sper.js/tests/stake_info_test.js index 106cdaaf8..4dd3ce8b7 100644 --- a/w3sper.js/tests/stake_info_test.js +++ b/w3sper.js/tests/stake_info_test.js @@ -5,13 +5,13 @@ // Copyright (c) DUSK NETWORK. All rights reserved. import { + AccountSyncer, + Bookkeeper, Network, ProfileGenerator, - Bookkeeper, - AccountSyncer, } from "@dusk/w3sper"; -import { test, assert, seeder, Treasury } from "./harness.js"; +import { assert, seeder, test, Treasury } from "./harness.js"; /** * Tests fetching the stake information using string representations @@ -31,7 +31,7 @@ test("stake info without profiles", async () => { assert.equal(stakes.length, 2); - assert.equal(stakes[0].amount.value, 1_000_000_000_000n); + assert.equal(stakes[0].amount.value, 2_000_000_000_000n); assert.equal( stakes[0].amount.total, stakes[0].amount.value + stakes[0].amount.locked, @@ -40,14 +40,12 @@ test("stake info without profiles", async () => { // 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); @@ -86,7 +84,7 @@ test("stake info with treasury", async () => { // 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.value, 2_000_000_000_000n); assert.equal( stakes[0].amount.total, stakes[0].amount.value + stakes[0].amount.locked, @@ -95,14 +93,12 @@ test("stake info with treasury", async () => { // 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); diff --git a/w3sper.js/tests/stake_test.js b/w3sper.js/tests/stake_test.js new file mode 100644 index 000000000..920ffd8d2 --- /dev/null +++ b/w3sper.js/tests/stake_test.js @@ -0,0 +1,528 @@ +// 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 { + AccountSyncer, + Bookkeeper, + Network, + ProfileGenerator, +} from "@dusk/w3sper"; + +import { assert, seeder, test, Treasury } from "./harness.js"; + +const MINIMUM_STAKE = 1_000_000_000_000n; +const STAKE_AMOUNT = MINIMUM_STAKE + 321n; + +test("minimum stake correct", async () => { + const network = await Network.connect("http://localhost:8080/"); + + const bookkeeper = new Bookkeeper(); + const minimumStake = await bookkeeper.minimumStake; + + assert.equal(minimumStake, MINIMUM_STAKE); + + await network.disconnect(); +}); + +test("stake amount insufficient", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + + await treasury.update({ accounts }); + + const bookentry = new Bookkeeper(treasury).as(users[0]); + + const transfer = bookentry + .stake(MINIMUM_STAKE - 1n) + .gas({ limit: 500_000_000n }); + + await assert.reject( + async () => await network.execute(transfer), + RangeError, + `Stake amount must be greater or equal than ${MINIMUM_STAKE}`, + ); + + await network.disconnect(); +}); + +test("cannot top up with no stake", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default, await profiles.next()]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + + await treasury.update({ accounts }); + + const bookentry = new Bookkeeper(treasury).as(users[1]); + + const transfer = bookentry.topup(STAKE_AMOUNT).gas({ + limit: 500_000_000n, + }); + + await assert.reject( + async () => await network.execute(transfer), + Error, + "No stake to topup. Use `stake` to create a new stake", + ); + + await network.disconnect(); +}); + +test("partial unstake insufficient", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default, await profiles.next()]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + const bookkeeper = new Bookkeeper(treasury); + const bookentry = bookkeeper.as(users[0]); + + await treasury.update({ accounts }); + + const stakeInfo = await bookentry.info.stake(); + + assert.equal(stakeInfo.amount.eligibility, 0n); + assert.equal(stakeInfo.amount.locked, 0n); + assert.equal(stakeInfo.amount.value, MINIMUM_STAKE * 2n); + + const partialAmount = stakeInfo.amount.value - 1n; + const transfer = bookentry.unstake(partialAmount).gas({ + limit: 500_000_000n, + }); + + await assert.reject( + async () => await network.execute(transfer), + RangeError, + `Remaining stake must be greater or equal than ${MINIMUM_STAKE}`, + ); + + await network.disconnect(); +}); + +test("stake", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default, await profiles.next()]; + const treasury = new Treasury(users); + + const accounts = new AccountSyncer(network); + + await treasury.update({ accounts }); + + const bookentry = new Bookkeeper(treasury).as(users[1]); + + const accountBalance = await bookentry.info.balance("account"); + + let stakeInfo = await bookentry.info.stake(); + const hasNoStake = stakeInfo.amount === null; + assert.ok(hasNoStake, "User should not have stake"); + + const transfer = bookentry.stake(STAKE_AMOUNT).gas({ + limit: 500_000_000n, + }); + + const { hash } = await network.execute(transfer); + + const evt = await network.transactions.withId(hash).once.executed(); + const { gasPaid } = evt; + + await treasury.update({ accounts }); + + stakeInfo = await bookentry.info.stake(); + + assert.equal(stakeInfo.amount?.value, transfer.attributes.amount); + + const newAccountBalance = await bookentry.info.balance("account"); + + assert.equal( + newAccountBalance.value, + accountBalance.value - transfer.attributes.amount - gasPaid, + ); + + await network.disconnect(); +}); + +test("cannot stake twice", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default, await profiles.next()]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + + await treasury.update({ accounts }); + + const bookentry = new Bookkeeper(treasury).as(users[1]); + + const transfer = bookentry.stake(STAKE_AMOUNT).gas({ + limit: 500_000_000n, + }); + + await assert.reject( + async () => await network.execute(transfer), + Error, + "Stake already exists. Use `topup` to add to the current stake", + ); + + await network.disconnect(); +}); + +test("partial unstake", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default, await profiles.next()]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + const bookentry = new Bookkeeper(treasury).as(users[1]); + + await treasury.update({ accounts }); + + const accountBalance = await bookentry.info.balance("account"); + let stakeInfo = await bookentry.info.stake(); + + assert.equal(stakeInfo.amount.eligibility, 4320n); + assert.equal(stakeInfo.amount.locked, 0n); + assert.equal(stakeInfo.amount.value, STAKE_AMOUNT); + + const transfer = bookentry.unstake(123n).gas({ + limit: 500_000_000n, + }); + + const { hash } = await network.execute(transfer); + + const evt = await network.transactions.withId(hash).once.executed(); + const { gasPaid } = evt; + + await treasury.update({ accounts }); + + stakeInfo = await bookentry.info.stake(); + + assert.equal(stakeInfo.amount.eligibility, 4320n); + assert.equal(stakeInfo.amount.locked, 0n); + assert.equal(stakeInfo.amount.value, STAKE_AMOUNT - 123n); + + const newAccountBalance = await bookentry.info.balance("account"); + + assert.equal(newAccountBalance.value, accountBalance.value + 123n - gasPaid); + + await network.disconnect(); +}); + +test("topup with no penalty", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default, await profiles.next()]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + const bookentry = new Bookkeeper(treasury).as(users[1]); + + await treasury.update({ accounts }); + + const accountBalance = await bookentry.info.balance("account"); + let stakeInfo = await bookentry.info.stake(); + + assert.equal(stakeInfo.amount.eligibility, 4320n); + assert.equal(stakeInfo.amount.locked, 0n); + assert.equal(stakeInfo.amount.value, STAKE_AMOUNT - 123n); + + const transfer = bookentry.topup(123n).gas({ + limit: 500_000_000n, + }); + + const { hash } = await network.execute(transfer); + + const evt = await network.transactions.withId(hash).once.executed(); + const { gasPaid } = evt; + + await treasury.update({ accounts }); + + stakeInfo = await bookentry.info.stake(); + + assert.equal(stakeInfo.amount.eligibility, 4320n); + assert.equal(stakeInfo.amount.locked, 0n); + assert.equal(stakeInfo.amount.value, STAKE_AMOUNT); + + const newAccountBalance = await bookentry.info.balance("account"); + + assert.equal(newAccountBalance.value, accountBalance.value - 123n - gasPaid); + + await network.disconnect(); +}); + +test("topup with penalty", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default, await profiles.next()]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + const bookentry = new Bookkeeper(treasury).as(users[0]); + + await treasury.update({ accounts }); + + const accountBalance = await bookentry.info.balance("account"); + let stakeInfo = await bookentry.info.stake(); + + assert.equal(stakeInfo.amount.eligibility, 0n); + assert.equal(stakeInfo.amount.locked, 0n); + assert.equal(stakeInfo.amount.value, MINIMUM_STAKE * 2n); + + const transfer = bookentry.topup(500n).gas({ + limit: 500_000_000n, + }); + + const { hash } = await network.execute(transfer); + + const evt = await network.transactions.withId(hash).once.executed(); + const { gasPaid } = evt; + + await treasury.update({ accounts }); + + stakeInfo = await bookentry.info.stake(); + + assert.equal(stakeInfo.amount.eligibility, 0n); + assert.equal(stakeInfo.amount.locked, 50n); + assert.equal(stakeInfo.amount.value, MINIMUM_STAKE * 2n + 450n); + + const newAccountBalance = await bookentry.info.balance("account"); + + assert.equal(newAccountBalance.value, accountBalance.value - 500n - gasPaid); + + await network.disconnect(); +}); + +test("unstake", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default, await profiles.next()]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + const bookkeeper = new Bookkeeper(treasury); + const bookentry = bookkeeper.as(users[1]); + + await treasury.update({ accounts }); + + const accountBalance = await bookentry.info.balance("account"); + let stakeInfo = await bookentry.info.stake(); + + assert.equal(stakeInfo.amount.eligibility, 4320n); + assert.equal(stakeInfo.amount.locked, 0n); + assert.equal(stakeInfo.amount.value, STAKE_AMOUNT); + + const transfer = bookentry.unstake().gas({ limit: 500_000_000n }); + + const { hash } = await network.execute(transfer); + + const evt = await network.transactions.withId(hash).once.executed(); + const { gasPaid } = evt; + + await treasury.update({ accounts }); + + stakeInfo = await bookentry.info.stake(); + + assert.equal(stakeInfo.amount, null); + + const newAccountBalance = await bookentry.info.balance("account"); + + assert.equal( + newAccountBalance.value, + accountBalance.value + STAKE_AMOUNT - gasPaid, + ); + + await network.disconnect(); +}); + +test("withdraw stake reward with no stake", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [ + await profiles.default, + await profiles.next(), + await profiles.next(), + ]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + const bookkeeper = new Bookkeeper(treasury); + const bookentry = bookkeeper.as(users[2]); + + await treasury.update({ accounts }); + + const stakeInfo = await bookentry.info.stake(); + + assert.equal(stakeInfo.amount, null); + + const transfer = bookentry.withdraw(1000n).gas({ limit: 500_000_000n }); + + assert.reject(async () => await network.execute(transfer)); + + await network.disconnect(); +}); + +test("withdraw stake reward greater than available", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [ + await profiles.default, + await profiles.next(), + await profiles.next(), + ]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + const bookkeeper = new Bookkeeper(treasury); + const bookentry = bookkeeper.as(users[0]); + + await treasury.update({ accounts }); + + const stakeInfo = await bookentry.info.stake(); + + const transfer = bookentry + .withdraw(stakeInfo.reward + 1n) + .gas({ limit: 500_000_000n }); + + await assert.reject( + async () => await network.execute(transfer), + RangeError, + "The withdrawn reward amount must be less or equal", + ); + + await network.disconnect(); +}); + +test("withdraw partial stake reward", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default, await profiles.next()]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + const bookkeeper = new Bookkeeper(treasury); + const bookentry = bookkeeper.as(users[0]); + + await treasury.update({ accounts }); + + let stakeInfo = await bookentry.info.stake(); + const accountBalance = await bookentry.info.balance("account"); + + const claimAmount = stakeInfo.reward / 2n; + + const transfer = bookentry.withdraw(claimAmount).gas({ limit: 500_000_000n }); + + const { hash } = await network.execute(transfer); + + const evt = await network.transactions.withId(hash).once.executed(); + const { gasPaid } = evt; + + await treasury.update({ accounts }); + + stakeInfo = await bookentry.info.stake(); + const newAccountBalance = await bookentry.info.balance("account"); + + assert.equal( + newAccountBalance.value, + accountBalance.value + claimAmount - gasPaid, + ); + + await network.disconnect(); +}); + +test("withdraw full stake reward", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default, await profiles.next()]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + const bookkeeper = new Bookkeeper(treasury); + const bookentry = bookkeeper.as(users[0]); + + await treasury.update({ accounts }); + + let stakeInfo = await bookentry.info.stake(); + const accountBalance = await bookentry.info.balance("account"); + + const claimAmount = stakeInfo.reward; + + const transfer = bookentry.withdraw(claimAmount).gas({ limit: 500_000_000n }); + + const { hash } = await network.execute(transfer); + + const evt = await network.transactions.withId(hash).once.executed(); + const { gasPaid } = evt; + + await treasury.update({ accounts }); + + stakeInfo = await bookentry.info.stake(); + const newAccountBalance = await bookentry.info.balance("account"); + + assert.equal( + newAccountBalance.value, + accountBalance.value + claimAmount - gasPaid, + ); + + await network.disconnect(); +}); + +test("withdraw 0 as stake reward", async () => { + const network = await Network.connect("http://localhost:8080/"); + const profiles = new ProfileGenerator(seeder); + + const users = [await profiles.default, await profiles.next()]; + + const accounts = new AccountSyncer(network); + + const treasury = new Treasury(users); + const bookkeeper = new Bookkeeper(treasury); + const bookentry = bookkeeper.as(users[0]); + + await treasury.update({ accounts }); + + const transfer = bookentry.withdraw(0n).gas({ limit: 500_000_000n }); + + await assert.reject( + async () => await network.execute(transfer), + RangeError, + "Can't withdraw an empty reward amount.", + ); + + await network.disconnect(); +}); diff --git a/w3sper.js/tests/transfer_test.js b/w3sper.js/tests/transfer_test.js index 1f9853388..53a3a6248 100644 --- a/w3sper.js/tests/transfer_test.js +++ b/w3sper.js/tests/transfer_test.js @@ -5,25 +5,40 @@ // Copyright (c) DUSK NETWORK. All rights reserved. import { - Network, - ProfileGenerator, + AccountSyncer, + AddressSyncer, Bookkeeper, Bookmark, - AddressSyncer, - AccountSyncer, + Network, + ProfileGenerator, Transfer, useAsProtocolDriver, } from "@dusk/w3sper"; import { - test, assert, + getLocalWasmBuffer, seeder, + test, Treasury, - getLocalWasmBuffer, } from "./harness.js"; test("Offline account transfers", async () => { + // Since the tests files runs in parallel, there is no guarantee that the + // `nonce` starts from `0`, so we need to fetch the current nonce for the + // sender from the network before the offline operations. + + const network = await Network.connect("http://localhost:8080/"); + + const from = + "ocXXBAafr7xFqQTpC1vfdSYdHMXerbPCED2apyUVpLjkuycsizDxwA6b9D7UW91kG58PFKqm9U9NmY9VSwufUFL5rVRSnFSYxbiKK658TF6XjHsHGBzavFJcxAzjjBRM4eF"; + + const [balance] = await new AccountSyncer(network).balances([from]); + + // here we can disconnect from the network, since we are going to do + // everything offline + await network.disconnect(); + // What is inside this block, uses a local protocol driver instead of fetching // from the network, so it does not need to be connected. // All transactions are signed locally. @@ -41,24 +56,15 @@ test("Offline account transfers", async () => { new Transfer(users[1]) .amount(amount) .to(to) - .nonce(BigInt(nonce)) + .nonce(balance.nonce + BigInt(nonce)) .chain(Network.LOCALNET) .gas({ limit: 500_000_000n }) .build(), ), ); - assert.equal( - transfers[0].hash, - "72bc75e53d31afec67e32df825e5793594d937ae2c8d5b0726e833dc21db2b0d", - ); - assert.equal(transfers[0].nonce, 1n); - - assert.equal( - transfers[1].hash, - "9b4039406a620b7537ab873e17c0ae5442afa4514a59f77b95644effd293936f", - ); - assert.equal(transfers[1].nonce, 2n); + assert.equal(transfers[0].nonce, balance.nonce + 1n); + assert.equal(transfers[1].nonce, balance.nonce + 2n); return { transfers, users }; }); @@ -67,7 +73,7 @@ test("Offline account transfers", async () => { // Here we gather the transactions generated "offline", we connect to the network, // and propagate all of them - const network = await Network.connect("http://localhost:8080/"); + await network.connect(); const balances = await new AccountSyncer(network).balances(users); @@ -121,7 +127,7 @@ test("accounts", async () => { await treasury.update({ accounts }); - let newBalances = [ + const newBalances = [ await bookkeeper.balance(users[0].account), await bookkeeper.balance(users[1].account), ]; @@ -142,7 +148,7 @@ test("addresses", async () => { const addresses = new AddressSyncer(network); const treasury = new Treasury(users); - let from = Bookmark.from(0n); + const from = Bookmark.from(0n); await treasury.update({ addresses, from }); @@ -165,7 +171,7 @@ test("addresses", async () => { await treasury.update({ addresses }); - let newBalances = [ + const newBalances = [ await bookkeeper.balance(users[0].address), await bookkeeper.balance(users[1].address), ]; @@ -283,6 +289,7 @@ test("memo transfer", async () => { evt = await network.transactions.withId(hash).once.executed(); + // deno-fmt-ignore assert.equal( [...evt.memo()], [ @@ -299,320 +306,3 @@ test("memo transfer", async () => { await network.disconnect(); }); - -test("stake amount insufficient", async () => { - const network = await Network.connect("http://localhost:8080/"); - const profiles = new ProfileGenerator(seeder); - - const users = [await profiles.default]; - - const accounts = new AccountSyncer(network); - - const treasury = new Treasury(users); - - await treasury.update({ accounts }); - - const bookkeeper = new Bookkeeper(treasury); - let bookentry = bookkeeper.as(users[0]); - - const minimumStake = await bookentry.bookkeeper.minimumStake; - - let transfer = bookentry - .stake(minimumStake - 1n) - .gas({ limit: 500_000_000n }); - - assert.reject(async () => await network.execute(transfer)); - - await network.disconnect(); -}); - -test("stake twice", async () => { - const network = await Network.connect("http://localhost:8080/"); - const profiles = new ProfileGenerator(seeder); - - const users = [await profiles.default]; - - const accounts = new AccountSyncer(network); - - const treasury = new Treasury(users); - - await treasury.update({ accounts }); - - const bookkeeper = new Bookkeeper(treasury); - let bookentry = bookkeeper.as(users[0]); - - const accountBalance = await bookentry.info.balance("account"); - - const minimumStake = await bookentry.bookkeeper.minimumStake; - - let transfer = bookentry.stake(minimumStake).gas({ limit: 500_000_000n }); - - const { hash } = await network.execute(transfer); - - const evt = await network.transactions.withId(hash).once.executed(); - const { gasPaid } = evt; - - assert.equal(evt.payload.err, "Panic: Can't stake twice for the same key"); - - await treasury.update({ accounts }); - - const newAccountBalance = await bookentry.info.balance("account"); - - assert.equal(newAccountBalance.value, accountBalance.value - gasPaid); - - await network.disconnect(); -}); - -test("stake", async () => { - const network = await Network.connect("http://localhost:8080/"); - const profiles = new ProfileGenerator(seeder); - - const users = [await profiles.default, await profiles.next()]; - - const accounts = new AccountSyncer(network); - - const treasury = new Treasury(users); - - await treasury.update({ accounts }); - - const bookkeeper = new Bookkeeper(treasury); - let bookentry = bookkeeper.as(users[1]); - - const accountBalance = await bookentry.info.balance("account"); - - const minimumStake = await bookentry.bookkeeper.minimumStake; - - let transfer = bookentry.stake(minimumStake).gas({ limit: 500_000_000n }); - - let stakeInfo = await bookentry.info.stake(); - - assert.equal(stakeInfo.amount, null); - - const { hash } = await network.execute(transfer); - - const evt = await network.transactions.withId(hash).once.executed(); - const { gasPaid } = evt; - - await treasury.update({ accounts }); - - stakeInfo = await bookentry.info.stake(); - - assert.equal(stakeInfo.amount?.value, transfer.attributes.amount); - - const newAccountBalance = await bookentry.info.balance("account"); - - assert.equal( - newAccountBalance.value, - accountBalance.value - transfer.attributes.amount - gasPaid, - ); - - await network.disconnect(); -}); - -test("unstake", async () => { - const network = await Network.connect("http://localhost:8080/"); - const profiles = new ProfileGenerator(seeder); - - const users = [await profiles.default, await profiles.next()]; - - const accounts = new AccountSyncer(network); - - const treasury = new Treasury(users); - const bookkeeper = new Bookkeeper(treasury); - const bookentry = bookkeeper.as(users[1]); - - await treasury.update({ accounts }); - - const accountBalance = await bookentry.info.balance("account"); - let stakeInfo = await bookentry.info.stake(); - - const minimumStake = await bookentry.bookkeeper.minimumStake; - - assert.equal(stakeInfo.amount.eligibility, 4320n); - assert.equal(stakeInfo.amount.locked, 0n); - assert.equal(stakeInfo.amount.value, minimumStake); - - let transfer = bookentry.unstake().gas({ limit: 500_000_000n }); - - const { hash } = await network.execute(transfer); - - const evt = await network.transactions.withId(hash).once.executed(); - const { gasPaid } = evt; - - await treasury.update({ accounts }); - - stakeInfo = await bookentry.info.stake(); - - assert.equal(stakeInfo.amount, null); - - const newAccountBalance = await bookentry.info.balance("account"); - - assert.equal( - newAccountBalance.value, - accountBalance.value + minimumStake - gasPaid, - ); - - await network.disconnect(); -}); - -test("withdraw stake reward with no stake", async () => { - const network = await Network.connect("http://localhost:8080/"); - const profiles = new ProfileGenerator(seeder); - - const users = [ - await profiles.default, - await profiles.next(), - await profiles.next(), - ]; - - const accounts = new AccountSyncer(network); - - const treasury = new Treasury(users); - const bookkeeper = new Bookkeeper(treasury); - const bookentry = bookkeeper.as(users[2]); - - await treasury.update({ accounts }); - - let stakeInfo = await bookentry.info.stake(); - - assert.equal(stakeInfo.amount, null); - - let transfer = bookentry.withdraw(1000n).gas({ limit: 500_000_000n }); - - assert.reject(async () => await network.execute(transfer)); - - await network.disconnect(); -}); - -test("withdraw stake reward greater than available", async () => { - const network = await Network.connect("http://localhost:8080/"); - const profiles = new ProfileGenerator(seeder); - - const users = [ - await profiles.default, - await profiles.next(), - await profiles.next(), - ]; - - const accounts = new AccountSyncer(network); - - const treasury = new Treasury(users); - const bookkeeper = new Bookkeeper(treasury); - const bookentry = bookkeeper.as(users[0]); - - await treasury.update({ accounts }); - - const stakeInfo = await bookentry.info.stake(); - - const transfer = bookentry - .withdraw(stakeInfo.reward + 1n) - .gas({ limit: 500_000_000n }); - - assert.reject(async () => await network.execute(transfer)); - - await network.disconnect(); -}); - -test("withdraw partial stake reward", async () => { - const network = await Network.connect("http://localhost:8080/"); - const profiles = new ProfileGenerator(seeder); - - const users = [await profiles.default, await profiles.next()]; - - const accounts = new AccountSyncer(network); - - const treasury = new Treasury(users); - const bookkeeper = new Bookkeeper(treasury); - const bookentry = bookkeeper.as(users[0]); - - await treasury.update({ accounts }); - - let stakeInfo = await bookentry.info.stake(); - const accountBalance = await bookentry.info.balance("account"); - - const claimAmount = stakeInfo.reward / 2n; - - const transfer = bookentry.withdraw(claimAmount).gas({ limit: 500_000_000n }); - - const { hash } = await network.execute(transfer); - - const evt = await network.transactions.withId(hash).once.executed(); - const { gasPaid } = evt; - - await treasury.update({ accounts }); - - stakeInfo = await bookentry.info.stake(); - const newAccountBalance = await bookentry.info.balance("account"); - - assert.equal( - newAccountBalance.value, - accountBalance.value + claimAmount - gasPaid, - ); - - await network.disconnect(); -}); - -test("withdraw full stake reward", async () => { - const network = await Network.connect("http://localhost:8080/"); - const profiles = new ProfileGenerator(seeder); - - const users = [await profiles.default, await profiles.next()]; - - const accounts = new AccountSyncer(network); - - const treasury = new Treasury(users); - const bookkeeper = new Bookkeeper(treasury); - const bookentry = bookkeeper.as(users[0]); - - await treasury.update({ accounts }); - - let stakeInfo = await bookentry.info.stake(); - const accountBalance = await bookentry.info.balance("account"); - - const claimAmount = stakeInfo.reward; - - const transfer = bookentry.withdraw(claimAmount).gas({ limit: 500_000_000n }); - - const { hash } = await network.execute(transfer); - - const evt = await network.transactions.withId(hash).once.executed(); - const { gasPaid } = evt; - - await treasury.update({ accounts }); - - stakeInfo = await bookentry.info.stake(); - const newAccountBalance = await bookentry.info.balance("account"); - - assert.equal( - newAccountBalance.value, - accountBalance.value + claimAmount - gasPaid, - ); - - await network.disconnect(); -}); - -test("withdraw 0 as stake reward", async () => { - const network = await Network.connect("http://localhost:8080/"); - const profiles = new ProfileGenerator(seeder); - - const users = [await profiles.default, await profiles.next()]; - - const accounts = new AccountSyncer(network); - - const treasury = new Treasury(users); - const bookkeeper = new Bookkeeper(treasury); - const bookentry = bookkeeper.as(users[0]); - - await treasury.update({ accounts }); - - let stakeInfo = await bookentry.info.stake(); - const accountBalance = await bookentry.info.balance("account"); - - const claimAmount = 0; - - const transfer = bookentry.withdraw(claimAmount).gas({ limit: 500_000_000n }); - - assert.reject(async () => await network.execute(transfer)); - - await network.disconnect(); -});