From 3b64497691b2b247f8aae39ca5d9ed0d2e154c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Leegwater=20Sim=C3=B5es?= Date: Tue, 10 Sep 2024 16:27:59 +0200 Subject: [PATCH 1/3] wallet-core: add Moonlight stake operations We introduce new functions, performing the operations necessary to interact with the stake contract: - `moonlight_stake` - `moonlight_stake_reward` - `moonlight_unstake` These functions are callable in the same way as their phoenix counterparts. --- wallet-core/src/transaction.rs | 177 +++++++++++++++++++++++++++++++-- 1 file changed, 170 insertions(+), 7 deletions(-) diff --git a/wallet-core/src/transaction.rs b/wallet-core/src/transaction.rs index 75f46a79f8..51f9a313c0 100644 --- a/wallet-core/src/transaction.rs +++ b/wallet-core/src/transaction.rs @@ -84,7 +84,8 @@ pub fn phoenix( .into()) } -/// Creates a totally generic Moonlight transaction, all fields being variable +/// Creates a totally generic Moonlight [`Transaction`], all fields being +/// variable. /// /// # Errors /// The creation of a transaction is not possible and will error if: @@ -125,7 +126,6 @@ pub fn moonlight( /// - the `inputs` vector contains duplicate `Note`s /// - the `Prove` trait is implemented incorrectly #[allow(clippy::too_many_arguments)] -#[allow(clippy::missing_panics_doc)] pub fn phoenix_stake( rng: &mut R, phoenix_sender_sk: &PhoenixSecretKey, @@ -145,8 +145,9 @@ pub fn phoenix_stake( let transfer_value = 0; let obfuscated_transaction = false; let deposit = stake_value; + let nonce = current_nonce + 1; - let stake = Stake::new(stake_sk, stake_value, current_nonce + 1, chain_id); + let stake = Stake::new(stake_sk, stake_value, nonce, chain_id); let contract_call = ContractCall::new(STAKE_CONTRACT, "stake", &stake)?; @@ -168,6 +169,44 @@ pub fn phoenix_stake( ) } +/// Create a [`Transaction`] to stake from a Moonlight account. +/// +/// # Errors +/// The creation of this transaction doesn't error, but still returns a result +/// for the sake of API consistency. +#[allow(clippy::too_many_arguments)] +pub fn moonlight_stake( + moonlight_sender_sk: &BlsSecretKey, + stake_sk: &BlsSecretKey, + stake_value: u64, + gas_limit: u64, + gas_price: u64, + moonlight_current_nonce: u64, + stake_current_nonce: u64, + chain_id: u8, +) -> Result { + let transfer_value = 0; + let deposit = stake_value; + let moonlight_nonce = moonlight_current_nonce + 1; + let stake_nonce = stake_current_nonce + 1; + + let stake = Stake::new(stake_sk, stake_value, stake_nonce, chain_id); + + let contract_call = ContractCall::new(STAKE_CONTRACT, "stake", &stake)?; + + moonlight( + moonlight_sender_sk, + None, + transfer_value, + deposit, + gas_limit, + gas_price, + moonlight_nonce, + chain_id, + Some(contract_call), + ) +} + /// Create an unproven [`Transaction`] to withdraw stake rewards into a /// phoenix-note. /// @@ -179,7 +218,6 @@ pub fn phoenix_stake( /// - the `inputs` vector contains duplicate `Note`s /// - the `Prove` trait is implemented incorrectly #[allow(clippy::too_many_arguments)] -#[allow(clippy::missing_panics_doc)] pub fn phoenix_stake_reward( rng: &mut R, phoenix_sender_sk: &PhoenixSecretKey, @@ -237,6 +275,49 @@ pub fn phoenix_stake_reward( ) } +/// Create a [`Transaction`] to withdraw stake rewards into Moonlight account. +/// +/// # Errors +/// The creation of this transaction doesn't error, but still returns a result +/// for the sake of API consistency. +#[allow(clippy::too_many_arguments)] +pub fn moonlight_stake_reward( + rng: &mut R, + moonlight_sender_sk: &BlsSecretKey, + stake_sk: &BlsSecretKey, + reward_amount: u64, + gas_limit: u64, + gas_price: u64, + current_nonce: u64, + chain_id: u8, +) -> Result { + let transfer_value = 0; + let deposit = 0; + let nonce = current_nonce + 1; + + let gas_payment_token = WithdrawReplayToken::Moonlight(nonce); + + let contract_call = stake_reward_to_moonlight( + rng, + moonlight_sender_sk, + stake_sk, + gas_payment_token, + reward_amount, + )?; + + moonlight( + moonlight_sender_sk, + None, + transfer_value, + deposit, + gas_limit, + gas_price, + nonce, + chain_id, + Some(contract_call), + ) +} + /// Create an unproven [`Transaction`] to unstake into a phoenix-note. /// /// # Errors @@ -247,7 +328,6 @@ pub fn phoenix_stake_reward( /// - the `inputs` vector contains duplicate `Note`s /// - the `Prove` trait is implemented incorrectly #[allow(clippy::too_many_arguments)] -#[allow(clippy::missing_panics_doc)] pub fn phoenix_unstake( rng: &mut R, phoenix_sender_sk: &PhoenixSecretKey, @@ -305,6 +385,49 @@ pub fn phoenix_unstake( ) } +/// Create a [`Transaction`] to unstake into a Moonlight account. +/// +/// # Errors +/// The creation of a transaction is not possible and will error if: +/// - the Memo provided with `data` is too large +#[allow(clippy::too_many_arguments)] +pub fn moonlight_unstake( + rng: &mut R, + moonlight_sender_sk: &BlsSecretKey, + stake_sk: &BlsSecretKey, + unstake_value: u64, + gas_limit: u64, + gas_price: u64, + current_nonce: u64, + chain_id: u8, +) -> Result { + let transfer_value = 0; + let deposit = 0; + let nonce = current_nonce + 1; + + let gas_payment_token = WithdrawReplayToken::Moonlight(nonce); + + let contract_call = unstake_to_moonlight( + rng, + moonlight_sender_sk, + stake_sk, + gas_payment_token, + unstake_value, + )?; + + moonlight( + moonlight_sender_sk, + None, + transfer_value, + deposit, + gas_limit, + gas_price, + nonce, + chain_id, + Some(contract_call), + ) +} + /// Create an unproven [`Transaction`] to convert Phoenix Dusk into Moonlight /// Dusk. /// @@ -319,7 +442,6 @@ pub fn phoenix_unstake( /// - the `inputs` vector contains duplicate `Note`s /// - the `Prove` trait is implemented incorrectly #[allow(clippy::too_many_arguments)] -#[allow(clippy::missing_panics_doc)] pub fn phoenix_to_moonlight( rng: &mut R, phoenix_sender_sk: &PhoenixSecretKey, @@ -393,12 +515,13 @@ pub fn moonlight_to_phoenix( convert_value: u64, gas_limit: u64, gas_price: u64, - nonce: u64, + current_nonce: u64, chain_id: u8, ) -> Result { let transfer_value = 0; let deposit = convert_value; // a convertion is a simultaneous deposit to *and* withdrawal from the // transfer contract + let nonce = current_nonce + 1; let gas_payment_token = WithdrawReplayToken::Moonlight(nonce); @@ -442,6 +565,26 @@ fn stake_reward_to_phoenix( ContractCall::new(STAKE_CONTRACT, "withdraw", &reward_withdraw) } +fn stake_reward_to_moonlight( + rng: &mut R, + moonlight_receiver_sk: &BlsSecretKey, + stake_sk: &BlsSecretKey, + gas_payment_token: WithdrawReplayToken, + reward_amount: u64, +) -> Result { + let withdraw = withdraw_to_moonlight( + rng, + moonlight_receiver_sk, + STAKE_CONTRACT, + gas_payment_token, + reward_amount, + ); + + let reward_withdraw = StakeWithdraw::new(stake_sk, withdraw); + + ContractCall::new(STAKE_CONTRACT, "withdraw", &reward_withdraw) +} + fn unstake_to_phoenix( rng: &mut R, phoenix_sender_sk: &PhoenixSecretKey, @@ -462,6 +605,26 @@ fn unstake_to_phoenix( ContractCall::new(STAKE_CONTRACT, "unstake", &unstake) } +fn unstake_to_moonlight( + rng: &mut R, + moonlight_receiver_sk: &BlsSecretKey, + stake_sk: &BlsSecretKey, + gas_payment_token: WithdrawReplayToken, + unstake_value: u64, +) -> Result { + let withdraw = withdraw_to_moonlight( + rng, + moonlight_receiver_sk, + STAKE_CONTRACT, + gas_payment_token, + unstake_value, + ); + + let unstake = StakeWithdraw::new(stake_sk, withdraw); + + ContractCall::new(STAKE_CONTRACT, "unstake", &unstake) +} + fn convert_to_moonlight( rng: &mut R, moonlight_receiver_sk: &BlsSecretKey, From 33b73c379f6a87620324c53fd209deeaf0d27f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Leegwater=20Sim=C3=B5es?= Date: Tue, 10 Sep 2024 16:30:10 +0200 Subject: [PATCH 2/3] test-wallet: implement Moonlight stake operations --- test-wallet/src/imp.rs | 146 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 2 deletions(-) diff --git a/test-wallet/src/imp.rs b/test-wallet/src/imp.rs index f1564c8593..7340ef3826 100644 --- a/test-wallet/src/imp.rs +++ b/test-wallet/src/imp.rs @@ -38,7 +38,8 @@ use wallet_core::{ keys::{derive_bls_sk, derive_phoenix_sk}, phoenix_balance, transaction::{ - moonlight_to_phoenix, phoenix as phoenix_transaction, phoenix_stake, + moonlight_stake, moonlight_stake_reward, moonlight_to_phoenix, + moonlight_unstake, phoenix as phoenix_transaction, phoenix_stake, phoenix_stake_reward, phoenix_to_moonlight, phoenix_unstake, }, BalanceInfo, @@ -711,6 +712,147 @@ where Ok(tx.into()) } + /// Stakes an amount of Dusk using a Moonlight account. + #[allow(clippy::too_many_arguments)] + pub fn moonlight_stake( + &self, + sender_index: u8, + staker_index: u8, + stake_value: u64, + gas_limit: u64, + gas_price: u64, + ) -> Result> { + let mut sender_sk = self.account_secret_key(sender_index)?; + let sender_pk = self.account_public_key(sender_index)?; + + let mut staker_sk = self.account_secret_key(staker_index)?; + let staker_pk = self.account_public_key(staker_index)?; + + let sender_account = self + .state + .fetch_account(&sender_pk) + .map_err(Error::from_state_err)?; + let staker_data = self + .state + .fetch_stake(&staker_pk) + .map_err(Error::from_state_err)?; + + let chain_id = + self.state.fetch_chain_id().map_err(Error::from_state_err)?; + + let tx = moonlight_stake( + &sender_sk, + &staker_sk, + stake_value, + gas_limit, + gas_price, + sender_account.nonce, + staker_data.nonce, + chain_id, + )?; + + sender_sk.zeroize(); + staker_sk.zeroize(); + + Ok(tx) + } + + /// Unstakes a key from the stake contract, using a Moonlight account. + pub fn moonlight_unstake( + &self, + rng: &mut Rng, + sender_index: u8, + staker_index: u8, + gas_limit: u64, + gas_price: u64, + ) -> Result> { + let mut sender_sk = self.account_secret_key(sender_index)?; + let sender_pk = self.account_public_key(sender_index)?; + + let mut staker_sk = self.account_secret_key(staker_index)?; + let staker_pk = self.account_public_key(staker_index)?; + + let sender_account = self + .state + .fetch_account(&sender_pk) + .map_err(Error::from_state_err)?; + let staker_data = self + .state + .fetch_stake(&staker_pk) + .map_err(Error::from_state_err)?; + + let unstake_value = staker_data + .amount + .ok_or(Error::NotStaked { + key: staker_pk, + stake: staker_data, + })? + .value; + let chain_id = + self.state.fetch_chain_id().map_err(Error::from_state_err)?; + + let tx = moonlight_unstake( + rng, + &sender_sk, + &staker_sk, + unstake_value, + gas_limit, + gas_price, + sender_account.nonce, + chain_id, + )?; + + sender_sk.zeroize(); + staker_sk.zeroize(); + + Ok(tx) + } + + /// Withdraw the accumulated staking reward for a key, into a Moonlight + /// notes. Rewards are accumulated by participating in the consensus. + pub fn moonlight_stake_withdraw( + &self, + rng: &mut Rng, + sender_index: u8, + staker_index: u8, + gas_limit: u64, + gas_price: u64, + ) -> Result> { + let mut sender_sk = self.account_secret_key(sender_index)?; + let sender_pk = self.account_public_key(sender_index)?; + + let mut staker_sk = self.account_secret_key(staker_index)?; + let staker_pk = self.account_public_key(staker_index)?; + + let sender_account = self + .state + .fetch_account(&sender_pk) + .map_err(Error::from_state_err)?; + let staker_data = self + .state + .fetch_stake(&staker_pk) + .map_err(Error::from_state_err)?; + + let chain_id = + self.state.fetch_chain_id().map_err(Error::from_state_err)?; + + let tx = moonlight_stake_reward( + rng, + &sender_sk, + &staker_sk, + staker_data.reward, + gas_limit, + gas_price, + sender_account.nonce, + chain_id, + )?; + + sender_sk.zeroize(); + staker_sk.zeroize(); + + Ok(tx) + } + /// Convert some Moonlight Dusk into Phoenix Dusk. pub fn moonlight_to_phoenix( &self, @@ -733,7 +875,7 @@ where .fetch_account(&moonlight_sender_pk) .map_err(Error::from_state_err)?; - let nonce = moonlight_sender_account.nonce + 1; + let nonce = moonlight_sender_account.nonce; let chain_id = self.state.fetch_chain_id().map_err(Error::from_state_err)?; From e3e59a1e13c3d8ab185e25acee6f352bf5c5c9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Leegwater=20Sim=C3=B5es?= Date: Tue, 10 Sep 2024 16:30:29 +0200 Subject: [PATCH 3/3] rusk: test Moonlight stake operations The tests for the Moonlight stake operations are shamelessly lifted from the existing Phoenix tests, and adapted to use Moonlight. This ensure parity of Moonlight and Phoenix. --- rusk/tests/config/stake.toml | 4 + rusk/tests/services/mod.rs | 3 +- rusk/tests/services/moonlight_stake.rs | 240 ++++++++++++++++++ .../services/{stake.rs => phoenix_stake.rs} | 0 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 rusk/tests/services/moonlight_stake.rs rename rusk/tests/services/{stake.rs => phoenix_stake.rs} (100%) diff --git a/rusk/tests/config/stake.toml b/rusk/tests/config/stake.toml index 9aca8ee612..2eb458bd9b 100644 --- a/rusk/tests/config/stake.toml +++ b/rusk/tests/config/stake.toml @@ -14,6 +14,10 @@ notes = [ 10_000_000_000_000, ] +[[moonlight_account]] +address = "qe1FbZxf6YaCAeFNSvL1G82cBhG4Q4gBf4vKYo527Vws3b23jdbBuzKSFsdUHnZeBgsTnyNJLkApEpRyJw87sdzR9g9iESJrG5ZgpCs9jq88m6d4qMY5txGpaXskRQmkzE3" +balance = 10_000_000_000_000 + [[stake]] address = "qe1FbZxf6YaCAeFNSvL1G82cBhG4Q4gBf4vKYo527Vws3b23jdbBuzKSFsdUHnZeBgsTnyNJLkApEpRyJw87sdzR9g9iESJrG5ZgpCs9jq88m6d4qMY5txGpaXskRQmkzE3" amount = 1_000_000_000_000 diff --git a/rusk/tests/services/mod.rs b/rusk/tests/services/mod.rs index e75aadbe57..7c63583148 100644 --- a/rusk/tests/services/mod.rs +++ b/rusk/tests/services/mod.rs @@ -7,8 +7,9 @@ pub mod contract_deployment; pub mod conversion; pub mod gas_behavior; +pub mod moonlight_stake; pub mod multi_transfer; pub mod owner_calls; -pub mod stake; +pub mod phoenix_stake; pub mod transfer; pub mod unspendable; diff --git a/rusk/tests/services/moonlight_stake.rs b/rusk/tests/services/moonlight_stake.rs new file mode 100644 index 0000000000..1fdfb0bc5c --- /dev/null +++ b/rusk/tests/services/moonlight_stake.rs @@ -0,0 +1,240 @@ +// 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. + +use std::path::Path; +use std::sync::{Arc, RwLock}; + +use execution_core::stake::MINIMUM_STAKE; + +use rand::prelude::*; +use rand::rngs::StdRng; +use rusk::{Result, Rusk}; +use std::collections::HashMap; +use tempfile::tempdir; +use test_wallet::{self as wallet}; +use tracing::info; + +use crate::common::state::{generator_procedure, new_state}; +use crate::common::wallet::{TestStateClient, TestStore}; +use crate::common::*; + +const BLOCK_HEIGHT: u64 = 1; +const BLOCK_GAS_LIMIT: u64 = 100_000_000_000; +const GAS_LIMIT: u64 = 10_000_000_000; +const GAS_PRICE: u64 = 1; + +// Creates the Rusk initial state for the tests below +fn stake_state>(dir: P) -> Result { + let snapshot = toml::from_str(include_str!("../config/stake.toml")) + .expect("Cannot deserialize config"); + + new_state(dir, &snapshot, BLOCK_GAS_LIMIT) +} + +/// Stakes an amount Dusk and produces a block with this single transaction, +/// checking the stake is set successfully. It then proceeds to withdraw the +/// stake and checking it is correctly withdrawn. +fn wallet_stake( + rusk: &Rusk, + wallet: &wallet::Wallet, + value: u64, +) { + let mut rng = StdRng::seed_from_u64(0xdead); + + wallet + .get_stake(0) + .expect("stakeinfo to be found") + .amount + .expect("stake amount to be found"); + + assert!( + wallet + .get_stake(2) + .expect("stakeinfo to be found") + .amount + .is_none(), + "stake amount not to be found" + ); + + let tx = wallet + .moonlight_stake(0, 2, value, GAS_LIMIT, GAS_PRICE) + .expect("Failed to create a stake transaction"); + let executed_txs = generator_procedure( + rusk, + &[tx], + BLOCK_HEIGHT, + BLOCK_GAS_LIMIT, + vec![], + None, + ) + .expect("generator procedure to succeed"); + if let Some(e) = &executed_txs + .first() + .expect("Transaction must be executed") + .err + { + panic!("Stake transaction failed due to {e}") + } + + let stake = wallet.get_stake(2).expect("stake to be found"); + let stake_value = stake.amount.expect("stake should have an amount").value; + + assert_eq!(stake_value, value); + + wallet + .get_stake(0) + .expect("stakeinfo to be found") + .amount + .expect("stake amount to be found"); + + let tx = wallet + .moonlight_unstake(&mut rng, 0, 0, GAS_LIMIT, GAS_PRICE) + .expect("Failed to unstake"); + let spent_txs = generator_procedure( + rusk, + &[tx], + BLOCK_HEIGHT, + BLOCK_GAS_LIMIT, + vec![], + None, + ) + .expect("generator procedure to succeed"); + let spent_tx = spent_txs.first().expect("Unstake tx to be included"); + assert_eq!(spent_tx.err, None, "unstake to be successfull"); + + let stake = wallet.get_stake(0).expect("stake should still be state"); + assert_eq!(stake.amount, None); + + let tx = wallet + .moonlight_stake_withdraw(&mut rng, 0, 1, GAS_LIMIT, GAS_PRICE) + .expect("failed to withdraw reward"); + generator_procedure( + rusk, + &[tx], + BLOCK_HEIGHT, + BLOCK_GAS_LIMIT, + vec![], + None, + ) + .expect("generator procedure to succeed"); + + let stake = wallet.get_stake(1).expect("stake should still be state"); + assert_eq!(stake.reward, 0); +} + +#[tokio::test(flavor = "multi_thread")] +pub async fn stake() -> Result<()> { + // Setup the logger + logger(); + + let tmp = tempdir().expect("Should be able to create temporary directory"); + let rusk = stake_state(&tmp)?; + + let cache = Arc::new(RwLock::new(HashMap::new())); + + // Create a wallet + let wallet = wallet::Wallet::new( + TestStore, + TestStateClient { + rusk: rusk.clone(), + cache, + }, + ); + + let original_root = rusk.state_root(); + + info!("Original Root: {:?}", hex::encode(original_root)); + + // Perform some staking actions. + wallet_stake(&rusk, &wallet, MINIMUM_STAKE); + + // Check the state's root is changed from the original one + let new_root = rusk.state_root(); + info!( + "New root after the 1st transfer: {:?}", + hex::encode(new_root) + ); + assert_ne!(original_root, new_root, "Root should have changed"); + + // let recv = kadcast_recv.try_recv(); + // let (_, _, h) = recv.expect("Transaction has not been locally + // propagated"); assert_eq!(h, 0, "Transaction locally propagated with + // wrong height"); + + Ok(()) +} + +/// Attempt to submit a management transaction intending it to fail. Verify that +/// the reward amount remains unchanged and confirm that the transaction indeed +/// fails +fn wallet_reward( + rusk: &Rusk, + wallet: &wallet::Wallet, +) { + let mut rng = StdRng::seed_from_u64(0xdead); + + let stake = wallet.get_stake(2).expect("stake to be found"); + assert_eq!(stake.reward, 0, "stake reward must be empty"); + + let tx = wallet + .moonlight_stake_withdraw(&mut rng, 0, 2, GAS_LIMIT, GAS_PRICE) + .expect("Creating reward transaction should succeed"); + + let executed_txs = generator_procedure( + rusk, + &[tx], + BLOCK_HEIGHT, + BLOCK_GAS_LIMIT, + vec![], + None, + ) + .expect("generator procedure to succeed"); + let _ = executed_txs + .first() + .expect("Transaction must be executed") + .err + .as_ref() + .expect("reward transaction to fail"); + let stake = wallet.get_stake(2).expect("stake to be found"); + assert_eq!(stake.reward, 0, "stake reward must be empty"); +} + +#[tokio::test(flavor = "multi_thread")] +pub async fn reward() -> Result<()> { + // Setup the logger + logger(); + + let tmp = tempdir().expect("Should be able to create temporary directory"); + let rusk = stake_state(&tmp)?; + + let cache = Arc::new(RwLock::new(HashMap::new())); + + // Create a wallet + let wallet = wallet::Wallet::new( + TestStore, + TestStateClient { + rusk: rusk.clone(), + cache, + }, + ); + + let original_root = rusk.state_root(); + + info!("Original Root: {:?}", hex::encode(original_root)); + + // Perform some staking actions. + wallet_reward(&rusk, &wallet); + + // Check the state's root is changed from the original one + let new_root = rusk.state_root(); + info!( + "New root after the 1st transfer: {:?}", + hex::encode(new_root) + ); + assert_ne!(original_root, new_root, "Root should have changed"); + + Ok(()) +} diff --git a/rusk/tests/services/stake.rs b/rusk/tests/services/phoenix_stake.rs similarity index 100% rename from rusk/tests/services/stake.rs rename to rusk/tests/services/phoenix_stake.rs