diff --git a/core/src/mantle/transactions/codec.rs b/core/src/mantle/transactions/codec.rs index 83652fd4e..551291b90 100644 --- a/core/src/mantle/transactions/codec.rs +++ b/core/src/mantle/transactions/codec.rs @@ -84,8 +84,8 @@ pub fn predict_signed_mantle_tx_size(tx: &MantleTx, context: &MantleTxGasContext calculate_channel_multi_sig_proof_byte_size(channel_withdraw_threshold) } - // None - Op::ChannelDeposit(_) => 0, + // ZkSigProof = ZkSignature = Groth16 + Op::ChannelDeposit(_) => COMPRESSED_PROOF_SIZE, }) .sum::(); diff --git a/nodes/node/http-client/src/lib.rs b/nodes/node/http-client/src/lib.rs index f7224059f..ba756a860 100644 --- a/nodes/node/http-client/src/lib.rs +++ b/nodes/node/http-client/src/lib.rs @@ -301,11 +301,23 @@ impl CommonHttpClient { self.get::<(), ChainServiceInfo>(request_url, None).await } - /// Get the current gas prices from the ledger state at the tip. - pub async fn gas_prices(&self, base_url: Url) -> Result { - let request_url = base_url + /// Get the gas prices from the ledger state at `tip`, or at the current + /// tip when `tip` is `None`. + pub async fn gas_prices( + &self, + base_url: Url, + tip: Option, + ) -> Result { + let mut request_url = base_url .join(MANTLE_GAS_PRICES.trim_start_matches('/')) .map_err(Error::Url)?; + + if let Some(t) = tip { + request_url + .query_pairs_mut() + .append_pair("tip", &t.to_string()); + } + self.get::<(), GasPricesResponseBody>(request_url, None) .await } diff --git a/tests/cucumber_tests/features/fees.feature b/tests/cucumber_tests/features/fees.feature index b68b1b794..c0a7748a4 100644 --- a/tests/cucumber_tests/features/fees.feature +++ b/tests/cucumber_tests/features/fees.feature @@ -10,7 +10,7 @@ Feature: Fees | node_name | account_index | wallet_name | connected_to | | NODE_1 | 1 | WALLET_1A | | When node "NODE_1" is at height 1 in 180 seconds - Then gas prices on node "NODE_1" equal the genesis gas prices + Then gas prices on node "NODE_1" at the genesis block equal the genesis gas prices Then I stop all nodes @transactions_ci diff --git a/tests/cucumber_tests/features/transactions.feature b/tests/cucumber_tests/features/transactions.feature index 405e59f05..eb79fc892 100644 --- a/tests/cucumber_tests/features/transactions.feature +++ b/tests/cucumber_tests/features/transactions.feature @@ -27,7 +27,7 @@ Feature: Transactions | account_index | token_count | token_amount | | 1 | 2 | 1000 | | 2 | 0 | 0 | - And we have a sponsored genesis fee account with 2 tokens of 997 value each + And we have a sponsored genesis fee account with 2 tokens of 20000 value each And I have a cluster with capacity of 2 nodes And we use IBD peers And all peers must be mode online after startup in 30 seconds @@ -50,7 +50,7 @@ Feature: Transactions | account_index | token_count | token_amount | | 1 | 2 | 1000 | | 2 | 0 | 0 | - And we have a sponsored genesis fee account with 2 tokens of 997 value each + And we have a sponsored genesis fee account with 2 tokens of 20000 value each And I have a cluster with capacity of 2 nodes And we use IBD peers And all peers must be mode online after startup in 30 seconds @@ -70,7 +70,7 @@ Feature: Transactions Scenario: Many nodes with wallets startup Given the genesis block has the following wallet resources: | account_index | token_count | token_amount | - | 1 | 2 | 1000 | + | 1 | 2 | 2000 | | 2 | 0 | 0 | | 3 | 0 | 0 | | 4 | 0 | 0 | @@ -132,7 +132,7 @@ Feature: Transactions | NODE_1 | 1 | WALLET_1A | | | NODE_2 | 2 | WALLET_2A | NODE_1 | When node "NODE_1" is at height 2 in 300 seconds - When I perform continuous transactions on user wallets with 5 coin split outputs of 1000 LGO, 5 transactions of 900 LGO each for 3 cycles and timeout of 300 seconds + When I perform continuous transactions on user wallets with 5 coin split outputs of 2500 LGO, 5 transactions of 900 LGO each for 3 cycles and timeout of 300 seconds Then I stop all nodes @local_transactions @@ -170,15 +170,15 @@ Feature: Transactions Scenario: Continuous transactions next wallet with coin split Given the genesis block has the following wallet resources: | account_index | token_count | token_amount | - | 1 | 2 | 12000 | - | 2 | 2 | 12000 | + | 1 | 2 | 50000 | + | 2 | 2 | 50000 | And I have a cluster with capacity of 2 nodes And I start nodes with wallet resources: | node_name | account_index | wallet_name | connected_to | | NODE_1 | 1 | WALLET_1A | | | NODE_2 | 2 | WALLET_2A | NODE_1 | When all nodes have at least 2 blocks and converged to within 1 blocks in 300 seconds - And I perform 2 coin split transactions for each user wallet with 10 outputs of 1000 LGO each + And I perform 2 coin split transactions for each user wallet with 10 outputs of 4000 LGO each And I verify each wallet has minimum 20 outputs "available" in 300 seconds And I perform 3 stress continuous cycles with 20 transactions of 1000 LGO to the next user wallet Then I stop all nodes @@ -220,7 +220,7 @@ Feature: Transactions Scenario: Coin split with many transfers to other Given the genesis block has the following wallet resources: | account_index | token_count | token_amount | - | 1 | 4 | 26000 | + | 1 | 4 | 30000 | | 2 | 0 | 0 | And I have a cluster with capacity of 2 nodes And I start nodes with wallet resources: @@ -238,7 +238,7 @@ Feature: Transactions And I send 50 transactions of 1000 LGO each from wallet "WALLET_1A" to wallet "WALLET_2A" When wallet "WALLET_2A" has 50 or more outputs in 240 seconds # All outputs accounted for - When wallet "WALLET_1A" has 56000 or less LGO in 180 seconds + When wallet "WALLET_1A" has 70000 or less LGO in 180 seconds When wallet "WALLET_1A" has 0 or less encumbered outputs in 60 seconds Then I stop all nodes @@ -246,10 +246,10 @@ Feature: Transactions Scenario: Two fork chains join later and preserve persisted state wallet balances Given the genesis block has the following wallet resources: | account_index | token_count | token_amount | - | 1 | 2 | 1400 | - | 2 | 2 | 1400 | - | 4 | 2 | 1400 | - | 5 | 2 | 1400 | + | 1 | 2 | 2500 | + | 2 | 2 | 2500 | + | 4 | 2 | 2500 | + | 5 | 2 | 2500 | | 7 | 0 | 0 | | 8 | 0 | 0 | And I have a cluster with capacity of 5 nodes @@ -305,10 +305,10 @@ Feature: Transactions And wallet "WALLET_2A" has 3 or more outputs in 10 seconds And wallet "WALLET_4A" has 3 or more outputs in 10 seconds And wallet "WALLET_5A" has 3 or more outputs in 10 seconds - When wallet "WALLET_1A" has 2100 or less LGO in 10 seconds - And wallet "WALLET_2A" has 2100 or less LGO in 10 seconds - And wallet "WALLET_4A" has 2100 or less LGO in 10 seconds - And wallet "WALLET_5A" has 2100 or less LGO in 10 seconds + When wallet "WALLET_1A" has 4300 or less LGO in 10 seconds + And wallet "WALLET_2A" has 4300 or less LGO in 10 seconds + And wallet "WALLET_4A" has 4300 or less LGO in 10 seconds + And wallet "WALLET_5A" has 4300 or less LGO in 10 seconds Then I stop all nodes @transactions_ci @@ -317,7 +317,7 @@ Feature: Transactions | account_index | token_count | token_amount | | 1 | 10 | 25000 | | 2 | 0 | 0 | - And we have a sponsored genesis fee account with 200 tokens of 100 value each + And we have a sponsored genesis fee account with 4 tokens of 20000 value each And I have a cluster with capacity of 2 nodes And I start nodes with wallet resources: | node_name | account_index | wallet_name | connected_to | @@ -346,7 +346,7 @@ Feature: Transactions | account_index | token_count | token_amount | | 1 | 3 | 100000 | | 2 | 0 | 0 | - And we have a sponsored genesis fee account with 5 tokens of 100 value each + And we have a sponsored genesis fee account with 2 tokens of 20000 value each And I have a cluster with capacity of 2 nodes And I start nodes with wallet resources: | node_name | account_index | wallet_name | connected_to | diff --git a/tests/src/common/mantle_inscription.rs b/tests/src/common/mantle_inscription.rs index db5860dd6..8559ad72b 100644 --- a/tests/src/common/mantle_inscription.rs +++ b/tests/src/common/mantle_inscription.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use lb_core::mantle::{ OpProof, TxHash, - gas::GasPrice, ops::{ Op, channel::{ @@ -11,7 +10,8 @@ use lb_core::mantle::{ }, }, transactions::{ - GENESIS_STORAGE_GAS_PRICE, GasPrices, MantleTxBuilder, MantleTxContext, MantleTxGasContext, + GENESIS_EXECUTION_GAS_PRICE, GENESIS_STORAGE_GAS_PRICE, GasPrices, MantleTxBuilder, + MantleTxContext, MantleTxGasContext, }, }; use lb_key_management_system_service::keys::{Ed25519Key, Ed25519Signature}; @@ -27,7 +27,7 @@ pub fn build_inscription_tx_builder( HashMap::new(), HashMap::new(), GasPrices { - execution_base_gas_price: GasPrice::new(0), + execution_base_gas_price: GENESIS_EXECUTION_GAS_PRICE, storage_gas_price: GENESIS_STORAGE_GAS_PRICE, }, ), diff --git a/tests/src/common/manual_cluster.rs b/tests/src/common/manual_cluster.rs index 33b6edc7d..a723899dd 100644 --- a/tests/src/common/manual_cluster.rs +++ b/tests/src/common/manual_cluster.rs @@ -5,6 +5,7 @@ use std::{ time::Duration, }; +use lb_core::mantle::{GenesisTx as _, Utxo}; use lb_key_management_system_service::keys::ZkPublicKey; use lb_libp2p::Multiaddr; use lb_node::{UserConfig, config::RunConfig}; @@ -347,6 +348,19 @@ pub fn api_url(node: &NodeHttpClient, path: &str) -> Url { .expect("manual-cluster client base URL should join API path") } +/// UTXOs created by the genesis transfer of the deployment's genesis block. +#[must_use] +pub fn genesis_wallet_utxos(config: &TopologyConfig) -> Vec { + let genesis_tx = config + .genesis_block + .as_ref() + .expect("manual-cluster deployment should include genesis tx") + .genesis_tx(); + let genesis_transfer = genesis_tx.genesis_transfer(); + + genesis_transfer.outputs.utxos(genesis_transfer).collect() +} + #[must_use] pub fn unique_scenario_base_dir(label: &str, scenario_dir_override: Option) -> PathBuf { let nanos = std::time::SystemTime::now() diff --git a/tests/src/common/wallet/funding_from_chain.rs b/tests/src/common/wallet/funding_from_chain.rs index afaaca27a..9ba863908 100644 --- a/tests/src/common/wallet/funding_from_chain.rs +++ b/tests/src/common/wallet/funding_from_chain.rs @@ -1,7 +1,12 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use lb_common_http_client::{ApiBlock, Error as HttpClientError}; -use lb_core::mantle::{TxHash, Utxo}; +use lb_core::mantle::{ + Op, OpProof, SignedMantleTx, Transaction as _, TxHash, Utxo, + gas::MainnetGasConstants, + ops::channel::{ChannelId, ChannelKeyIndex}, + transactions::{GasPrices, MantleTxBuilder, MantleTxContext, MantleTxGasContext}, +}; use lb_testing_framework::{NodeHttpClient, configs::wallet::WalletAccount}; use thiserror::Error; @@ -9,7 +14,9 @@ use super::{ NodeHttpWalletChainSource, WalletChainSource, WalletId, WalletUtxos, chain::state::{TrackedWalletKeys, TrackedWalletKeysError, WalletChainState}, }; -use crate::common::wallet::WalletFundingSource; +use crate::common::wallet::{ + WalletFundingSource, fund_builder_from_wallet_source, transfer_proofs_for_funded_wallet_tx, +}; #[derive(Debug, Error)] pub enum DirectWalletSourceError { @@ -29,6 +36,64 @@ pub enum WalletFundingSourceFromChainError { FetchBlock(FetchError), } +/// Build, fund, and sign a single-op transaction. +/// +/// The op fee is paid from the funding wallet (synced from chain), whose +/// trailing transfer op gets its own proof. The op proof is built via +/// `op_proof` from the funded transaction hash. `withdraw_thresholds` is +/// needed by the gas-size predictor for `ChannelWithdraw` ops. Returns the +/// signed transaction and its fee at genesis gas prices. +#[expect( + clippy::implicit_hasher, + reason = "The thresholds map is forwarded to MantleTxGasContext, which requires the default hasher." +)] +pub async fn funded_signed_tx( + node: &NodeHttpClient, + genesis_utxos: &[Utxo], + funding_account: &WalletAccount, + withdraw_thresholds: HashMap, + op: Op, + op_proof: impl FnOnce(TxHash) -> OpProof, +) -> (SignedMantleTx, u64) { + let funding_source = + current_wallet_funding_source(node, genesis_utxos, funding_account.clone()) + .await + .expect("funding wallet source should sync from chain"); + + let tx_context = MantleTxContext { + gas_context: MantleTxGasContext::new( + withdraw_thresholds, + HashMap::new(), + GasPrices::default(), + ), + leader_reward_amount: 0, + }; + let tx_builder = MantleTxBuilder::new() + .push_op(op) + .expect("op should fit op bounds"); + + let funded_builder = fund_builder_from_wallet_source(&funding_source, &tx_builder, &tx_context) + .expect("funding transaction should succeed"); + let fee = funded_builder + .gas_cost::(&tx_context) + .expect("funded tx gas cost should calculate") + .into_inner(); + + let mantle_tx = funded_builder.build().expect("funded builder should build"); + let tx_hash = mantle_tx.hash(); + + let mut proofs = vec![op_proof(tx_hash)]; + proofs.extend( + transfer_proofs_for_funded_wallet_tx(&mantle_tx, &funding_account.secret_key) + .expect("transfer proofs should build"), + ); + + let signed_tx = + SignedMantleTx::new(mantle_tx, proofs).expect("funded transaction should be valid"); + + (signed_tx, fee) +} + pub async fn current_wallet_funding_source( client: &NodeHttpClient, genesis_utxos: &[Utxo], diff --git a/tests/src/common/wallet/mod.rs b/tests/src/common/wallet/mod.rs index ec14e779d..1f5acff4a 100644 --- a/tests/src/common/wallet/mod.rs +++ b/tests/src/common/wallet/mod.rs @@ -30,7 +30,7 @@ pub(crate) use funding::{ }; pub use funding_from_chain::{ DirectWalletSourceError, WalletFundingSourceFromChainError, current_wallet_funding_source, - wallet_funding_source_from_chain, wallet_utxos_from_chain, + funded_signed_tx, wallet_funding_source_from_chain, wallet_utxos_from_chain, }; pub use ids::{WalletChainSourceId, WalletId, wallet_id_for_chain_source}; pub use tracked::{ @@ -42,7 +42,7 @@ pub use tracked_wallet::{TrackedWalletState, WalletBalance, WalletOutputState, W pub use transaction::{ PreparedWalletTransaction, SignedWalletTransaction, WalletTransactionError, WalletTransactionIntent, fund_builder_from_wallet_source, prepare_wallet_transaction, - wallet_state_from_utxos, + transfer_proofs_for_funded_wallet_tx, wallet_state_from_utxos, }; pub(crate) use transaction::{ PreparedWalletTransactionWorkItem, finalize_prepared_wallet_transaction, diff --git a/tests/src/common/wallet/transaction/builder_funding.rs b/tests/src/common/wallet/transaction/builder_funding.rs index 3a9917945..a90563078 100644 --- a/tests/src/common/wallet/transaction/builder_funding.rs +++ b/tests/src/common/wallet/transaction/builder_funding.rs @@ -335,25 +335,39 @@ fn funding_delta_for_chunked_builder( #[cfg(test)] mod tests { - use lb_core::mantle::ops::channel::inscribe::Inscription; + use std::collections::HashMap; + + use lb_core::mantle::{ + ops::channel::{ + ChannelId, MsgId, + inscribe::{Inscription, InscriptionOp}, + }, + transactions::{GasPrices, MantleTxGasContext}, + }; use lb_key_management_system_service::keys::Ed25519Key; use lb_testing_framework::configs::wallet::WalletAccount; use super::*; - use crate::common::mantle_inscription::{ - build_inscription_tx_builder, channel_id_for_payload_size, - }; #[test] fn zero_cost_wallet_transaction_still_uses_funding_input() { - let payload_size = 1024; let signing_key = Ed25519Key::from_bytes(&[0u8; 32]); - let (tx_builder, context) = build_inscription_tx_builder( - Inscription::new_unchecked(vec![0xab; payload_size]), - &signing_key, - channel_id_for_payload_size(payload_size), - None, - ); + let context = MantleTxContext { + gas_context: MantleTxGasContext::new( + HashMap::new(), + HashMap::new(), + GasPrices::new(0, 0), + ), + leader_reward_amount: 0, + }; + let tx_builder = MantleTxBuilder::new() + .push_op(Op::ChannelInscribe(InscriptionOp { + channel_id: ChannelId::from([0xAA; 32]), + inscription: Inscription::new_unchecked(vec![0xab; 1024]), + parent: MsgId::root(), + signer: signing_key.public_key(), + })) + .expect("inscription test builder should fit op bounds"); assert_eq!( tx_builder .funding_delta::(&context) diff --git a/tests/src/common/wallet/transaction/intent.rs b/tests/src/common/wallet/transaction/intent.rs index 8ffbea256..781c063c3 100644 --- a/tests/src/common/wallet/transaction/intent.rs +++ b/tests/src/common/wallet/transaction/intent.rs @@ -4,7 +4,10 @@ use std::collections::HashMap; use lb_core::mantle::{ Note, Op, - transactions::{GasPrices, MantleTxBuilder, MantleTxContext, MantleTxGasContext}, + transactions::{ + GENESIS_EXECUTION_GAS_PRICE, GasPrices, MantleTxBuilder, MantleTxContext, + MantleTxGasContext, + }, }; use lb_key_management_system_service::keys::ZkPublicKey; @@ -47,7 +50,7 @@ impl WalletTransactionIntent { gas_context: MantleTxGasContext::new( HashMap::new(), HashMap::new(), - GasPrices::new(0, storage_gas_price), + GasPrices::new(GENESIS_EXECUTION_GAS_PRICE.into_inner(), storage_gas_price), ), ..MantleTxContext::default() }; diff --git a/tests/src/common/wallet/transaction/mod.rs b/tests/src/common/wallet/transaction/mod.rs index 49ed3e759..87550e7c0 100644 --- a/tests/src/common/wallet/transaction/mod.rs +++ b/tests/src/common/wallet/transaction/mod.rs @@ -21,3 +21,4 @@ pub use prepare::{ }; pub use prepared::PreparedWalletTransaction; pub use signed::SignedWalletTransaction; +pub use signing::transfer_proofs_for_funded_wallet_tx; diff --git a/tests/src/common/wallet/transaction/signing.rs b/tests/src/common/wallet/transaction/signing.rs index def6abce6..88bb8690b 100644 --- a/tests/src/common/wallet/transaction/signing.rs +++ b/tests/src/common/wallet/transaction/signing.rs @@ -3,7 +3,8 @@ use std::collections::HashMap; use lb_core::mantle::{ - AuthenticatedMantleTx as _, NoteId, Op, OpProof, SignedMantleTx, TxHash, + AuthenticatedMantleTx as _, MantleTx, NoteId, Op, OpProof, SignedMantleTx, Transaction as _, + TxHash, gas::MainnetGasConstants, transactions::{MantleTxBuilder, MantleTxContext}, }; @@ -41,6 +42,34 @@ pub(super) fn sign_prepared_wallet_transaction( )) } +/// Build one `ZkSig` proof per transfer op in a funded transaction, signing +/// every input with the same wallet key. Suitable for transactions whose +/// funding inputs all come from a single wallet account. +pub fn transfer_proofs_for_funded_wallet_tx( + tx: &MantleTx, + signing_key: &ZkKey, +) -> Result, WalletTransactionError> { + let tx_hash = tx.hash(); + tx.ops() + .iter() + .filter_map(|op| match op { + Op::Transfer(transfer_op) => Some(transfer_op), + _ => None, + }) + .map(|transfer_op| { + let signing_keys = transfer_op + .inputs + .iter() + .map(|_| signing_key.clone()) + .collect::>(); + Ok(OpProof::ZkSig(ZkKey::multi_sign( + &signing_keys, + &tx_hash.to_fr(), + )?)) + }) + .collect() +} + pub(super) fn build_transfer_proofs( ops: &[Op], tx_hash: &TxHash, diff --git a/tests/src/cucumber/fee_reserve.rs b/tests/src/cucumber/fee_reserve.rs index 455a52169..27ecb8ea8 100644 --- a/tests/src/cucumber/fee_reserve.rs +++ b/tests/src/cucumber/fee_reserve.rs @@ -3,7 +3,7 @@ use std::{ num::NonZero, }; -use lb_core::mantle::{NoteId, Utxo}; +use lb_core::mantle::{NoteId, Utxo, transactions::GENESIS_STORAGE_GAS_PRICE}; use lb_testing_framework::configs::wallet::WalletAccount; use thiserror::Error; @@ -14,7 +14,7 @@ use crate::{ cucumber::{error::StepError, wallet::WalletStateView}, }; -pub const DEFAULT_STORAGE_GAS_PRICE: u64 = 0; +pub const DEFAULT_STORAGE_GAS_PRICE: u64 = GENESIS_STORAGE_GAS_PRICE.into_inner(); pub const SCENARIO_FEE_ACCOUNT_NAME: &str = "__SCENARIO_FEE_ACCOUNT__"; const SCENARIO_FEE_ACCOUNT_INDEX: u64 = 1 << 63; diff --git a/tests/src/cucumber/steps/fees.rs b/tests/src/cucumber/steps/fees.rs index 77070269f..24f1ad36a 100644 --- a/tests/src/cucumber/steps/fees.rs +++ b/tests/src/cucumber/steps/fees.rs @@ -1,9 +1,12 @@ use std::str::FromStr; use cucumber::{Parameter, gherkin::Step, then}; -use lb_core::mantle::{ - gas::GasPrice, - transactions::genesis_tx::{GENESIS_EXECUTION_GAS_PRICE, GENESIS_STORAGE_GAS_PRICE}, +use lb_core::{ + header::HeaderId, + mantle::{ + gas::GasPrice, + transactions::genesis_tx::{GENESIS_EXECUTION_GAS_PRICE, GENESIS_STORAGE_GAS_PRICE}, + }, }; use tracing::info; @@ -56,6 +59,43 @@ async fn step_gas_prices_equal_reference( world, step, &node_name, + None, + expected_execution, + expected_storage, + ) + .await +} + +#[then( + expr = "gas prices on node {string} at the genesis block equal the {gas_prices_reference} \ + gas prices" +)] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "cucumber step entrypoints must take `&mut World`" +)] +async fn step_gas_prices_at_genesis_equal_reference( + world: &mut CucumberWorld, + step: &Step, + node_name: String, + reference: GasPricesReference, +) -> StepResult { + let genesis_block_id = world + .genesis_block_id + .ok_or_else(|| StepError::LogicalError { + message: format!( + "Step `{}` error: genesis block id is not available for this cluster", + step.value + ), + })?; + + let (expected_execution, expected_storage) = reference.expected(); + + assert_gas_prices( + world, + step, + &node_name, + Some(genesis_block_id), expected_execution, expected_storage, ) @@ -78,6 +118,7 @@ async fn step_gas_prices_are( world, step, &node_name, + None, GasPrice::new(execution), GasPrice::new(storage), ) @@ -88,13 +129,14 @@ async fn assert_gas_prices( world: &CucumberWorld, step: &Step, node_name: &str, + tip: Option, expected_execution: GasPrice, expected_storage: GasPrice, ) -> StepResult { let client = world.resolve_node_http_client(node_name)?; let gas_prices = client - .gas_prices() + .gas_prices(tip) .await .map_err(|source| StepError::StepFail { message: format!( diff --git a/tests/src/cucumber/steps/manual_cluster.rs b/tests/src/cucumber/steps/manual_cluster.rs index fd92dbd37..93b1bff3c 100644 --- a/tests/src/cucumber/steps/manual_cluster.rs +++ b/tests/src/cucumber/steps/manual_cluster.rs @@ -98,6 +98,7 @@ pub fn build_manual_cluster_deployment( crate::cucumber::steps::manual_nodes::utils::genesis_block_utxos( &genesis_block.genesis_tx(), ); + world.genesis_block_id = Some(genesis_block.header().id()); } Ok(deployment) @@ -127,6 +128,7 @@ fn build_devnet_manual_cluster_deployment( // settings, so locally generated genesis outputs are not meaningful for // wallet tracking. world.genesis_block_utxos.clear(); + world.genesis_block_id = None; world.wallet_accounts.clear(); world.node_provisioned_wallet_pks.clear(); diff --git a/tests/src/cucumber/world.rs b/tests/src/cucumber/world.rs index 049c95f6e..b32f4a659 100644 --- a/tests/src/cucumber/world.rs +++ b/tests/src/cucumber/world.rs @@ -13,6 +13,7 @@ use cucumber::World; use derivative::Derivative; use lb_core::{ codec::DeserializeOp as _, + header::HeaderId, mantle::{ SignedMantleTx, TxHash, Utxo, Value, ops::channel::{ @@ -855,6 +856,9 @@ pub struct CucumberWorld { /// Manual: List of genesis block UTXOs allocated in the genesis /// configuration. pub genesis_block_utxos: Vec, + /// Manual: Header id of the locally generated genesis block, when the + /// cluster deployment carries one. + pub genesis_block_id: Option, /// Manual: Optional local cluster instance for scenarios that use the local /// deployer. #[derivative(Default(value = "None"))] @@ -1045,6 +1049,7 @@ impl Debug for CucumberWorld { "genesis_block_utxos", &format!("{:?}", self.genesis_block_utxos), ) + .field("genesis_block_id", &self.genesis_block_id) .field("local_cluster", { if self.local_cluster.is_some() { &"Has LbcManualCluster" @@ -2025,6 +2030,11 @@ impl CucumberWorld { format!("{:?}", FullDebugInfo(self)) } + #[expect( + clippy::too_many_lines, + reason = "Flat field-by-field dump of the whole world state; splitting it would not \ + improve readability" + )] pub fn full_debug_info(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let wallet_diagnostics = self .wallet_diagnostics_for_debug() @@ -2054,6 +2064,7 @@ impl CucumberWorld { "genesis_block_utxos", &format!("{:?}", self.genesis_block_utxos), ) + .field("genesis_block_id", &self.genesis_block_id) .field("local_cluster", { if self.local_cluster.is_some() { &"Has LbcManualCluster" diff --git a/tests/src/tests/mantle/channel.rs b/tests/src/tests/mantle/channel.rs index a800f13f4..83e3e1677 100644 --- a/tests/src/tests/mantle/channel.rs +++ b/tests/src/tests/mantle/channel.rs @@ -1,4 +1,4 @@ -use std::{num::NonZero, path::PathBuf, time::Duration}; +use std::{collections::HashMap, num::NonZero, path::PathBuf, time::Duration}; use futures::StreamExt as _; use lb_common_http_client::ProcessedBlockEvent; @@ -6,7 +6,7 @@ use lb_core::{ events::{Event, Events, TxEvent, TxEventPayload}, header::HeaderId, mantle::{ - GenesisTx as _, MantleTx, Note, NoteId, OpProof, SignedMantleTx, Transaction as _, TxHash, + GenesisTx as _, Note, NoteId, OpProof, Transaction as _, TxHash, gas::GasCost, ledger::{Inputs, Outputs}, ops::{ @@ -31,9 +31,12 @@ use lb_testing_framework::{ }; use lb_utils::math::NonNegativeRatio; use logos_blockchain_tests::{ - common::manual_cluster::{ - ManualNodeLayout, api_url, get_wallet_balance, start_local_manual_cluster_with_layout, - wait_for_nodes_height, + common::{ + manual_cluster::{ + ManualNodeLayout, api_url, genesis_wallet_utxos, get_wallet_balance, + start_local_manual_cluster_with_layout, wait_for_nodes_height, + }, + wallet::funded_signed_tx, }, cucumber::defaults::E2E_ARTIFACTS_DIR, }; @@ -41,6 +44,14 @@ use serial_test::serial; use testing_framework_core::scenario::DynError; use tokio::time::{sleep, timeout}; +/// Generous cap on channel transaction fees at genesis gas prices; actual fees +/// are a few hundred gas units for these small transactions. +const MAX_CHANNEL_TX_FEE: u64 = 10_000; +/// Covers all channel transaction fees in these tests with wide margin while +/// staying small relative to leader notes: genesis total stake is the sum of +/// all note values, so large fee notes dilute leader election. +const FEE_NOTE_AMOUNT: u64 = 50_000; + /// End-to-end test for the channel deposit flow: /// /// 1. Spawn validators that produce blocks. @@ -55,7 +66,8 @@ use tokio::time::{sleep, timeout}; #[serial] async fn channel_deposit() { let deposit_amount = 1; - let (wallet_config, funding_pk) = channel_deposit_wallet_config(deposit_amount, 100); + let (wallet_config, funding_pk, _funding_account) = + channel_deposit_wallet_config(deposit_amount, FEE_NOTE_AMOUNT); let (base, nodes) = start_local_manual_cluster_with_layout( "channel-deposit", "mantle-channel", @@ -121,7 +133,7 @@ async fn channel_deposit() { deposit: deposit_op.clone(), change_public_key: funding_pk, funding_public_keys: vec![funding_pk], - max_tx_fee: GasCost::new(10), + max_tx_fee: GasCost::new(MAX_CHANNEL_TX_FEE), }; let response = reqwest::Client::new() .post(api_url(&validator.client, "channel/deposit")) @@ -181,10 +193,10 @@ async fn channel_deposit() { assert_eq!(metadata, deposit_op.metadata); let balance_after = get_wallet_balance(&validator.client, funding_pk).await; - assert_eq!( - balance_after, - balance_before - deposit_amount, - "wallet balance should decrease after deposit: before={balance_before}, after={balance_after}, deposit_amount={deposit_amount}", + let spent = balance_before - balance_after; + assert!( + spent >= deposit_amount && spent <= deposit_amount + MAX_CHANNEL_TX_FEE, + "wallet balance should decrease by deposit plus fee: before={balance_before}, after={balance_after}, deposit_amount={deposit_amount}", ); let channel_balance_after = get_channel_balance(&validator.client, channel_id).await; @@ -208,8 +220,9 @@ async fn channel_deposit() { async fn channel_withdraw_updates_wallet_balance() { let deposit_amount = 5; let withdraw_amount = 2; - let (wallet_config, funding_pk) = channel_deposit_wallet_config(deposit_amount, 100); - let (_base, nodes) = start_local_manual_cluster_with_layout( + let (wallet_config, funding_pk, funding_account) = + channel_deposit_wallet_config(deposit_amount, FEE_NOTE_AMOUNT); + let (base, nodes) = start_local_manual_cluster_with_layout( "channel-withdraw-wallet-balance", "mantle-channel", DeploymentBuilder::new( @@ -238,9 +251,33 @@ async fn channel_withdraw_updates_wallet_balance() { ) .await; + let genesis_utxos = genesis_wallet_utxos(&base.deployment().config); + let initial_balance = get_wallet_balance(&validator.client, funding_pk).await; + let channel_id = ChannelId::from([42; 32]); let channel_signing_key = Ed25519Key::from_bytes(&[7; 32]); - let signed_inscription_tx = signed_channel_inscription(channel_id, &channel_signing_key); + let inscription_op = InscriptionOp { + channel_id, + inscription: b"channel withdraw wallet balance test" + .to_vec() + .try_into() + .expect("inscription payload should fit"), + parent: MsgId::root(), + signer: channel_signing_key.public_key(), + }; + let (signed_inscription_tx, inscription_fee) = funded_signed_tx( + &validator.client, + &genesis_utxos, + &funding_account, + HashMap::new(), + Op::ChannelInscribe(inscription_op), + |tx_hash| { + OpProof::Ed25519Sig( + channel_signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref()), + ) + }, + ) + .await; let mut block_stream = validator.client.blocks_stream().await.unwrap(); let inscription_tx_hash = signed_inscription_tx.hash(); @@ -253,12 +290,32 @@ async fn channel_withdraw_updates_wallet_balance() { wait_for_tx_inclusion(&mut block_stream, inscription_tx_hash, "inscription").await; + let balance_after_inscription = wait_for_wallet_balance( + &validator.client, + funding_pk, + initial_balance - inscription_fee, + Duration::from_mins(2), + ) + .await; + let (deposit_op, deposit_tx_hash) = submit_channel_deposit(&validator.client, channel_id, funding_pk, deposit_amount).await; wait_for_tx_inclusion(&mut block_stream, deposit_tx_hash, "deposit").await; - let balance_after_deposit = - wait_for_wallet_balance(&validator.client, funding_pk, 100, Duration::from_mins(2)).await; + // The deposit is funded server-side, so its exact fee is unknown here; it + // may even be zero once gas prices decay, so wait only for the deposit + // amount to leave the wallet. + let balance_after_deposit = wait_for_wallet_balance_at_most( + &validator.client, + funding_pk, + balance_after_inscription - deposit_amount, + Duration::from_mins(2), + ) + .await; + assert!( + balance_after_deposit >= balance_after_inscription - deposit_amount - MAX_CHANNEL_TX_FEE, + "deposit fee should stay within the max fee bound: after_inscription={balance_after_inscription}, after_deposit={balance_after_deposit}", + ); let channel_balance_after_deposit = get_channel_balance(&validator.client, channel_id).await; assert_eq!( channel_balance_after_deposit, deposit_amount, @@ -270,7 +327,26 @@ async fn channel_withdraw_updates_wallet_balance() { outputs: Outputs::new([Note::new(withdraw_amount, funding_pk)]), withdraw_nonce: 0, }; - let signed_withdraw_tx = signed_channel_withdraw(withdraw.clone(), &channel_signing_key); + let (signed_withdraw_tx, withdraw_fee) = funded_signed_tx( + &validator.client, + &genesis_utxos, + &funding_account, + HashMap::from([(channel_id, 1)]), + Op::ChannelWithdraw(withdraw.clone()), + |tx_hash| { + OpProof::ChannelMultiSigProof( + ChannelMultiSigProof::try_new( + [IndexedSignature::new( + 0, + channel_signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref()), + )] + .into(), + ) + .expect("withdraw proof should be valid"), + ) + }, + ) + .await; let withdraw_tx_hash = signed_withdraw_tx.hash(); validator @@ -281,17 +357,17 @@ async fn channel_withdraw_updates_wallet_balance() { wait_for_tx_inclusion(&mut block_stream, withdraw_tx_hash, "withdraw").await; + let expected_after_withdraw = balance_after_deposit + withdraw_amount - withdraw_fee; let balance_after_withdraw = wait_for_wallet_balance( &validator.client, funding_pk, - balance_after_deposit + withdraw_amount, + expected_after_withdraw, Duration::from_mins(2), ) .await; assert_eq!( - balance_after_withdraw, - balance_after_deposit + withdraw_amount, - "wallet balance should increase after withdraw: before={balance_after_deposit}, after={balance_after_withdraw}, withdraw_amount={withdraw_amount}", + balance_after_withdraw, expected_after_withdraw, + "wallet balance should increase by withdraw minus fee: before={balance_after_deposit}, after={balance_after_withdraw}, withdraw_amount={withdraw_amount}, withdraw_fee={withdraw_fee}", ); let channel_balance_after_withdraw = get_channel_balance(&validator.client, channel_id).await; @@ -307,7 +383,7 @@ async fn channel_withdraw_updates_wallet_balance() { fn channel_deposit_wallet_config( deposit_note_amount: u64, fee_note_amount: u64, -) -> (WalletConfig, ZkPublicKey) { +) -> (WalletConfig, ZkPublicKey, WalletAccount) { let deposit_note = WalletAccount::deterministic(0, deposit_note_amount, false) .expect("deposit wallet should be valid"); @@ -319,8 +395,13 @@ fn channel_deposit_wallet_config( ) .expect("fee wallet should be valid"); let funding_pk = deposit_note.public_key(); + let funding_account = deposit_note.clone(); - (WalletConfig::new(vec![deposit_note, fee_note]), funding_pk) + ( + WalletConfig::new(vec![deposit_note, fee_note]), + funding_pk, + funding_account, + ) } fn channel_test_config(mut config: RunConfig) -> RunConfig { @@ -353,7 +434,7 @@ async fn submit_channel_deposit( deposit: deposit_op.clone(), change_public_key: funding_pk, funding_public_keys: vec![funding_pk], - max_tx_fee: GasCost::new(10), + max_tx_fee: GasCost::new(MAX_CHANNEL_TX_FEE), }; let response = reqwest::Client::new() .post(api_url(node, "channel/deposit")) @@ -378,47 +459,6 @@ async fn submit_channel_deposit( (deposit_op, deposit_tx_hash) } -fn signed_channel_inscription(channel_id: ChannelId, signing_key: &Ed25519Key) -> SignedMantleTx { - let inscription = InscriptionOp { - channel_id, - inscription: b"channel withdraw wallet balance test" - .to_vec() - .try_into() - .expect("inscription payload should fit"), - parent: MsgId::root(), - signer: signing_key.public_key(), - }; - let mantle_tx = MantleTx([Op::ChannelInscribe(inscription)].into()); - let tx_hash = mantle_tx.hash(); - let inscription_proof = - OpProof::Ed25519Sig(signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref())); - - SignedMantleTx::new(mantle_tx, vec![inscription_proof]) - .expect("inscription transaction should be valid") -} - -fn signed_channel_withdraw( - withdraw: ChannelWithdrawOp, - signing_key: &Ed25519Key, -) -> SignedMantleTx { - let mantle_tx = MantleTx([Op::ChannelWithdraw(withdraw)].into()); - let tx_hash = mantle_tx.hash(); - let withdraw_proof = ChannelMultiSigProof::try_new( - [IndexedSignature::new( - 0, - signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref()), - )] - .into(), - ) - .expect("withdraw proof should be valid"); - - SignedMantleTx::new( - mantle_tx, - vec![OpProof::ChannelMultiSigProof(withdraw_proof)], - ) - .expect("withdraw transaction should be valid") -} - async fn wait_for_tx_inclusion( block_stream: &mut (impl futures::Stream + Unpin), tx_hash: TxHash, @@ -490,6 +530,29 @@ async fn wait_for_wallet_balance( panic!("timed out waiting for wallet balance {expected}, last balance was {last_balance}"); } +async fn wait_for_wallet_balance_at_most( + node: &NodeHttpClient, + pk: ZkPublicKey, + threshold: u64, + wait: Duration, +) -> u64 { + let start = tokio::time::Instant::now(); + let mut last_balance = get_wallet_balance(node, pk).await; + + while start.elapsed() < wait { + if last_balance <= threshold { + return last_balance; + } + + sleep(Duration::from_millis(500)).await; + last_balance = get_wallet_balance(node, pk).await; + } + + panic!( + "timed out waiting for wallet balance at most {threshold}, last balance was {last_balance}" + ); +} + async fn fetch_block_events(node: &NodeHttpClient, block_id: HeaderId) -> Events { let url = api_url(node, &format!("cryptarchia/blocks/{block_id}/events")); let response = reqwest::Client::new() diff --git a/tests/src/tests/mantle/sdp/ops.rs b/tests/src/tests/mantle/sdp/ops.rs index 2858e7ec6..8ee47ac64 100644 --- a/tests/src/tests/mantle/sdp/ops.rs +++ b/tests/src/tests/mantle/sdp/ops.rs @@ -12,11 +12,7 @@ use std::{ use lb_chain_service::Epoch; use lb_common_http_client::Error; use lb_core::{ - mantle::{ - GenesisTx as _, MantleTx, NoteId, OpProof, SignedMantleTx, Transaction as _, Utxo, - ops::Op, - transactions::{GENESIS_STORAGE_GAS_PRICE, GasPrices, MantleTxBuilder, MantleTxGasContext}, - }, + mantle::{NoteId, OpProof, Transaction as _, Utxo, ops::Op}, sdp::{Declaration, DeclarationMessage, Locator, ProviderId, ServiceType, WithdrawMessage}, }; use lb_key_management_system_service::keys::{Ed25519Key, Ed25519Signature, ZkKey}; @@ -32,10 +28,11 @@ use logos_blockchain_tests::{ common::{ chain::wait_for_transactions_inclusion, manual_cluster::{ - LocalManualClusterHarnessBase, build_local_manual_cluster, read_manual_node_logs, - wait_for_height as wait_for_manual_cluster_height, wait_for_tip_slot, + LocalManualClusterHarnessBase, build_local_manual_cluster, genesis_wallet_utxos, + read_manual_node_logs, wait_for_height as wait_for_manual_cluster_height, + wait_for_tip_slot, }, - wallet::{current_wallet_funding_source, fund_builder_from_wallet_source}, + wallet::funded_signed_tx, }, cucumber::defaults::E2E_ARTIFACTS_DIR, }; @@ -104,32 +101,32 @@ async fn sdp_ops_e2e() { }; let declaration_id = declaration.id(); - let declare_mantle_tx = fund_sdp_transaction( + let (declare_tx, _declare_fee) = funded_signed_tx( &node0, &genesis_utxos, &funding_wallet, + HashMap::new(), Op::SDPDeclare(declaration), + |tx_hash| { + let ed25519_sig = Ed25519Signature::from_bytes( + &provider_signing_key + .sign_payload(tx_hash.as_signing_bytes().as_ref()) + .to_bytes(), + ); + let zk_sig = ZkKey::multi_sign( + &[spare_note_secret_key.clone(), provider_zk_key.clone()], + &tx_hash.to_fr(), + ) + .expect("SDP declare zk proof should build"); + + OpProof::ZkAndEd25519Sigs { + zk_sig, + ed25519_sig, + } + }, ) .await; - let declare_hash = declare_mantle_tx.hash(); - let declare_ed25519_sig = Ed25519Signature::from_bytes( - &provider_signing_key - .sign_payload(declare_hash.as_signing_bytes().as_ref()) - .to_bytes(), - ); - let declare_zk_sig = ZkKey::multi_sign( - &[spare_note_secret_key.clone(), provider_zk_key.clone()], - &declare_hash.to_fr(), - ) - .expect("SDP declare zk proof should build"); - let declare_tx = SignedMantleTx::new( - declare_mantle_tx, - vec![OpProof::ZkAndEd25519Sigs { - zk_sig: declare_zk_sig, - ed25519_sig: declare_ed25519_sig, - }], - ) - .expect("funded SDP declare transaction should be valid"); + let declare_hash = declare_tx.hash(); node0 .submit_transaction(&declare_tx) @@ -153,24 +150,24 @@ async fn sdp_ops_e2e() { nonce: declaration_created.nonce + 1, }; - let withdraw_mantle_tx = fund_sdp_transaction( + let (withdraw_tx, _withdraw_fee) = funded_signed_tx( &node0, &genesis_utxos, &funding_wallet, + HashMap::new(), Op::SDPWithdraw(withdraw_message), + |tx_hash| { + OpProof::ZkSig( + ZkKey::multi_sign( + &[spare_note_secret_key.clone(), provider_zk_key.clone()], + &tx_hash.to_fr(), + ) + .expect("SDP withdraw zk proof should build"), + ) + }, ) .await; - - let withdraw_hash = withdraw_mantle_tx.hash(); - let withdraw_zk_sig = ZkKey::multi_sign( - &[spare_note_secret_key.clone(), provider_zk_key.clone()], - &withdraw_hash.to_fr(), - ) - .expect("SDP withdraw zk proof should build"); - - let withdraw_tx = - SignedMantleTx::new(withdraw_mantle_tx, vec![OpProof::ZkSig(withdraw_zk_sig)]) - .expect("funded SDP withdraw transaction should be valid"); + let withdraw_hash = withdraw_tx.hash(); node0 .submit_transaction(&withdraw_tx) @@ -372,26 +369,7 @@ async fn start_sdp_manual_cluster( .await .expect("node-0 should produce the first block"); - let genesis_utxos: Vec<_> = cluster_harness - .deployment() - .config - .genesis_block - .clone() - .expect("manual-cluster deployment should include genesis tx") - .genesis_tx() - .genesis_transfer() - .outputs - .utxos( - cluster_harness - .deployment() - .config - .genesis_block - .as_ref() - .expect("manual-cluster deployment should include genesis tx") - .genesis_tx() - .genesis_transfer(), - ) - .collect(); + let genesis_utxos = genesis_wallet_utxos(&cluster_harness.deployment().config); let spare_note_id = genesis_utxos .iter() @@ -454,39 +432,3 @@ fn patch_sdp_manual_cluster_config(mut config: RunConfig) -> RunConfig { config } - -async fn fund_sdp_transaction( - node: &NodeHttpClient, - genesis_utxos: &[Utxo], - funding_wallet: &WalletAccount, - extra_op: Op, -) -> MantleTx { - let funding_source = current_wallet_funding_source(node, genesis_utxos, funding_wallet.clone()) - .await - .expect("funding wallet source should sync from chain"); - - let empty_context = MantleTxGasContext::new( - HashMap::new(), - HashMap::new(), - GasPrices { - execution_base_gas_price: 0.into(), - storage_gas_price: GENESIS_STORAGE_GAS_PRICE, - }, - ); - let tx_context = lb_core::mantle::transactions::MantleTxContext { - gas_context: empty_context, - leader_reward_amount: 0, - }; - let tx_builder = MantleTxBuilder::new() - .push_op(extra_op) - .expect("mixed-op helper should fit op bounds"); - - let funded_builder = fund_builder_from_wallet_source(&funding_source, &tx_builder, &tx_context) - .expect("funding mixed-op transaction should succeed"); - - // With zero gas prices the funding step adds no input, so the built tx carries - // no trailing transfer op — the SDP op is the only op, and the only proof. - funded_builder - .build() - .expect("funded mixed-op builder should build") -} diff --git a/tests/testing_framework/src/node/http_client.rs b/tests/testing_framework/src/node/http_client.rs index d14d7d2d7..b11ff8bcc 100644 --- a/tests/testing_framework/src/node/http_client.rs +++ b/tests/testing_framework/src/node/http_client.rs @@ -77,10 +77,10 @@ impl NodeHttpClient { .await } - pub async fn gas_prices(&self) -> Result { + pub async fn gas_prices(&self, tip: Option) -> Result { self.with_timeout( "Gas prices request", - self.http_client.gas_prices(self.base_url.clone()), + self.http_client.gas_prices(self.base_url.clone(), tip), ) .await } diff --git a/tests/testing_framework/src/workloads/transaction/workload.rs b/tests/testing_framework/src/workloads/transaction/workload.rs index c7541b358..491e2a90d 100644 --- a/tests/testing_framework/src/workloads/transaction/workload.rs +++ b/tests/testing_framework/src/workloads/transaction/workload.rs @@ -192,7 +192,7 @@ impl<'a, E: LbcScenarioEnv> Submission<'a, E> { async fn execute(mut self) -> Result<(), DynError> { let gas_context = - MantleTxGasContext::new(HashMap::new(), HashMap::new(), GasPrices::new(0, 0)); + MantleTxGasContext::new(HashMap::new(), HashMap::new(), GasPrices::default()); while let Some(input) = self.plan.pop_front() { submit_wallet_transaction(self.ctx, &input, gas_context.clone()).await?; if !self.interval.is_zero() { diff --git a/tools/config/src/consensus.rs b/tools/config/src/consensus.rs index f1d842859..d9543f0ff 100644 --- a/tools/config/src/consensus.rs +++ b/tools/config/src/consensus.rs @@ -41,7 +41,9 @@ const KEY_MATERIAL_LEN: usize = 16; const REGULAR_NOTE_VALUE: u64 = 100_000; const BLEND_NOTE_VALUE: u64 = 1; -const SDP_NOTE_VALUE: u64 = 100; +/// Funds SDP declare/activity transaction fees at non-zero gas prices; an +/// activity transaction costs roughly 400-1000 at genesis prices. +const SDP_NOTE_VALUE: u64 = 10_000; #[derive(Clone)] pub struct ProviderInfo {