fix(e2e): non-zero gas price (#3083)

Co-authored-by: Youngjoon Lee <5462944+youngjoon-lee@users.noreply.github.com>
This commit is contained in:
Andrus Salumets
2026-07-08 14:35:13 +02:00
committed by GitHub
co-authored by Youngjoon Lee
parent 22574c25fa
commit 368f9a4872
21 changed files with 418 additions and 218 deletions
+2 -2
View File
@@ -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::<usize>();
+15 -3
View File
@@ -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<GasPricesResponseBody, Error> {
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<HeaderId>,
) -> Result<GasPricesResponseBody, Error> {
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
}
+1 -1
View File
@@ -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
@@ -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 |
+3 -3
View File
@@ -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,
},
),
+14
View File
@@ -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<Utxo> {
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>) -> PathBuf {
let nanos = std::time::SystemTime::now()
+68 -3
View File
@@ -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<FetchError> {
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<ChannelId, ChannelKeyIndex>,
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::<MainnetGasConstants>(&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],
+2 -2
View File
@@ -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,
@@ -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::<MainnetGasConstants>(&context)
@@ -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()
};
@@ -21,3 +21,4 @@ pub use prepare::{
};
pub use prepared::PreparedWalletTransaction;
pub use signed::SignedWalletTransaction;
pub use signing::transfer_proofs_for_funded_wallet_tx;
+30 -1
View File
@@ -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<Vec<OpProof>, 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::<Vec<_>>();
Ok(OpProof::ZkSig(ZkKey::multi_sign(
&signing_keys,
&tx_hash.to_fr(),
)?))
})
.collect()
}
pub(super) fn build_transfer_proofs(
ops: &[Op],
tx_hash: &TxHash,
+2 -2
View File
@@ -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;
+46 -4
View File
@@ -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<HeaderId>,
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!(
@@ -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();
+11
View File
@@ -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<Utxo>,
/// Manual: Header id of the locally generated genesis block, when the
/// cluster deployment carries one.
pub genesis_block_id: Option<HeaderId>,
/// 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"
+128 -65
View File
@@ -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<Item = ProcessedBlockEvent> + 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()
+38 -96
View File
@@ -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")
}
@@ -77,10 +77,10 @@ impl NodeHttpClient {
.await
}
pub async fn gas_prices(&self) -> Result<GasPricesResponseBody, Error> {
pub async fn gas_prices(&self, tip: Option<HeaderId>) -> Result<GasPricesResponseBody, Error> {
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
}
@@ -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() {
+3 -1
View File
@@ -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 {