From 641ab0cb916026acd28a9079dbf771446ef27494 Mon Sep 17 00:00:00 2001 From: zer0 Date: Wed, 13 Nov 2024 17:30:01 +0100 Subject: [PATCH] w3sper: Add withdraw stake's reward method - Add `WithdrawStakeRewardTransfer` class in `transaction` - Add `BookEntry#withdraw` method - Add `withdraw` function in `protocol-driver` - Add withdraws tests Resolves #2952 --- w3sper.js/src/bookkeeper.js | 5 + w3sper.js/src/protocol-driver/mod.js | 75 +++++++++++++ w3sper.js/src/transaction.js | 50 +++++++++ w3sper.js/tests/assets/genesis.toml | 1 - w3sper.js/tests/transfer_test.js | 162 +++++++++++++++++++++++++++ 5 files changed, 292 insertions(+), 1 deletion(-) diff --git a/w3sper.js/src/bookkeeper.js b/w3sper.js/src/bookkeeper.js index e355b6cf4..6f53812c6 100644 --- a/w3sper.js/src/bookkeeper.js +++ b/w3sper.js/src/bookkeeper.js @@ -13,6 +13,7 @@ import { ShieldTransfer, StakeTransfer, UnstakeTransfer, + WithdrawStakeRewardTransfer, } from "../src/transaction.js"; class BookEntry { @@ -54,6 +55,10 @@ class BookEntry { unstake() { return new UnstakeTransfer(this); } + + withdraw(amount) { + return new WithdrawStakeRewardTransfer(this).amount(amount); + } } export class Bookkeeper { diff --git a/w3sper.js/src/protocol-driver/mod.js b/w3sper.js/src/protocol-driver/mod.js index cd9923c97..e28842b24 100644 --- a/w3sper.js/src/protocol-driver/mod.js +++ b/w3sper.js/src/protocol-driver/mod.js @@ -942,6 +942,81 @@ export const unstake = async (info) => return [tx_buffer, hash]; })(); +export const withdraw = async (info) => + protocolDriverModule.task(async function ( + { malloc, moonlight_stake_reward }, + { memcpy }, + ) { + const ptr = Object.create(null); + + ptr.rng = await malloc(32); + await memcpy(ptr.rng, new Uint8Array(rng())); + + const seed = new Uint8Array(await info.profile.seed); + + ptr.seed = await malloc(64); + await memcpy(ptr.seed, seed, 64); + + const profile_index = +info.profile; + + const reward_amount = new Uint8Array(8); + new DataView(reward_amount.buffer).setBigUint64( + 0, + info.reward_amount, + true, + ); + ptr.reward_amount = await malloc(8); + await memcpy(ptr.reward_amount, reward_amount); + + const gas_limit = new Uint8Array(8); + new DataView(gas_limit.buffer).setBigUint64(0, info.gas_limit, true); + ptr.gas_limit = await malloc(8); + await memcpy(ptr.gas_limit, gas_limit); + + const gas_price = new Uint8Array(8); + new DataView(gas_price.buffer).setBigUint64(0, info.gas_price, true); + ptr.gas_price = await malloc(8); + await memcpy(ptr.gas_price, gas_price); + + const nonce = new Uint8Array(8); + new DataView(nonce.buffer).setBigUint64(0, info.nonce, true); + ptr.nonce = await malloc(8); + await memcpy(ptr.nonce, nonce); + + let tx = await malloc(4); + let hash = await malloc(64); + + const code = await moonlight_stake_reward( + ptr.rng, + ptr.seed, + profile_index, + ptr.reward_amount, + ptr.gas_limit, + ptr.gas_price, + ptr.nonce, + info.chainId, + tx, + hash, + ); + + if (code > 0) throw DriverError.from(code); + + let tx_ptr = new DataView((await memcpy(null, tx, 4)).buffer).getUint32( + 0, + true, + ); + + let tx_len = new DataView((await memcpy(null, tx_ptr, 4)).buffer).getUint32( + 0, + true, + ); + + const tx_buffer = await memcpy(null, tx_ptr + 4, tx_len); + + hash = new TextDecoder().decode(await memcpy(null, hash, 64)); + return [tx_buffer, hash]; + })(); + function serializeMemo(memo) { if (!memo) { return null; diff --git a/w3sper.js/src/transaction.js b/w3sper.js/src/transaction.js index 30cd7a770..9b1badca4 100644 --- a/w3sper.js/src/transaction.js +++ b/w3sper.js/src/transaction.js @@ -387,3 +387,53 @@ export class UnstakeTransfer extends BasicTransfer { }); } } + +export class WithdrawStakeRewardTransfer extends BasicTransfer { + constructor(from) { + super(from); + } + + async build(network) { + const { attributes } = this; + const { amount: reward_amount, gas } = attributes; + const { profile } = this.bookentry; + + // Get the chain id from the network + const { chainId } = await network.node.info; + + // Obtain the nonces + let { nonce } = await this.bookentry.info.balance("account"); + + // Obtain the staked amount + let { 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( + `The withdrawed reward amount must be less or equal to ${reward}`, + ); + } else if (!reward_amount) { + throw new Error( + `Can't wiotdraw an empty reward amount. I mean, you could, but it would be pointless.`, + ); + } + + nonce += 1n; + + let [buffer, hash] = await ProtocolDriver.withdraw({ + profile, + reward_amount, + gas_limit: gas.limit, + gas_price: gas.price, + nonce, + chainId, + }); + + return Object.freeze({ + buffer, + hash, + nonce, + }); + } +} diff --git a/w3sper.js/tests/assets/genesis.toml b/w3sper.js/tests/assets/genesis.toml index 0820a69d1..81f47d99a 100644 --- a/w3sper.js/tests/assets/genesis.toml +++ b/w3sper.js/tests/assets/genesis.toml @@ -1,7 +1,6 @@ [[stake]] address = 'oCqYsUMRqpRn2kSabH52Gt6FQCwH5JXj5MtRdYVtjMSJ73AFvdbPf98p3gz98fQwNy9ZBiDem6m9BivzURKFSKLYWP3N9JahSPZs9PnZ996P18rTGAjQTNFsxtbrKx79yWu' amount = 1_000_000_000_000 -reward = 1_000_000_000_000 [[moonlight_account]] address = 'oCqYsUMRqpRn2kSabH52Gt6FQCwH5JXj5MtRdYVtjMSJ73AFvdbPf98p3gz98fQwNy9ZBiDem6m9BivzURKFSKLYWP3N9JahSPZs9PnZ996P18rTGAjQTNFsxtbrKx79yWu' diff --git a/w3sper.js/tests/transfer_test.js b/w3sper.js/tests/transfer_test.js index 60233fc9f..c002e8869 100644 --- a/w3sper.js/tests/transfer_test.js +++ b/w3sper.js/tests/transfer_test.js @@ -436,3 +436,165 @@ test("unstake", async () => { 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(); +});