diff --git a/src/chain/store/chain_store.rs b/src/chain/store/chain_store.rs index 8f9dd4d86da8..091f260d79a9 100644 --- a/src/chain/store/chain_store.rs +++ b/src/chain/store/chain_store.rs @@ -661,7 +661,7 @@ where for message in unsigned_box.chain(signed_box) { let from_address = &message.from(); - if applied.contains_key(from_address) { + if !applied.contains_key(from_address) { let actor_state = state .get_actor(from_address)? .ok_or_else(|| Error::Other("Actor state not found".to_string()))?; diff --git a/src/cli_shared/cli/config.rs b/src/cli_shared/cli/config.rs index f8516c9d033a..0ee8d5216af8 100644 --- a/src/cli_shared/cli/config.rs +++ b/src/cli_shared/cli/config.rs @@ -1,16 +1,16 @@ // Copyright 2019-2025 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT +use super::client::Client; use crate::db::db_engine::DbConfig; use crate::libp2p::Libp2pConfig; use crate::shim::clock::ChainEpoch; +use crate::shim::econ::TokenAmount; use crate::utils::misc::env::is_env_set_and_truthy; use crate::{chain_sync::SyncConfig, networks::NetworkChain}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use super::client::Client; - const FOREST_CHAIN_INDEXER_ENABLED: &str = "FOREST_CHAIN_INDEXER_ENABLED"; /// Structure that defines daemon configuration when process is detached @@ -92,6 +92,23 @@ impl Default for ChainIndexerConfig { } } +#[derive(Deserialize, Serialize, PartialEq, Eq, Debug, Clone)] +#[cfg_attr(test, derive(derive_quickcheck_arbitrary::Arbitrary))] +pub struct FeeConfig { + /// Indicates the default max fee for a message + #[serde(with = "crate::lotus_json")] + pub max_fee: TokenAmount, +} + +impl Default for FeeConfig { + fn default() -> Self { + // The code is taken from https://github.com/filecoin-project/lotus/blob/release/v1.34.1/node/config/def.go#L39 + Self { + max_fee: TokenAmount::from_atto(70_000_000_000_000_000u64), // 0.07 FIL + } + } +} + #[derive(Serialize, Deserialize, PartialEq, Default, Debug, Clone)] #[cfg_attr(test, derive(derive_quickcheck_arbitrary::Arbitrary))] #[serde(default)] @@ -104,6 +121,7 @@ pub struct Config { pub daemon: DaemonConfig, pub events: EventsConfig, pub fevm: FevmConfig, + pub fee: FeeConfig, pub chain_indexer: ChainIndexerConfig, } diff --git a/src/daemon/context.rs b/src/daemon/context.rs index 7e0399229b87..a9af482c1ded 100644 --- a/src/daemon/context.rs +++ b/src/daemon/context.rs @@ -79,6 +79,7 @@ fn get_chain_config_and_set_network(config: &Config) -> Arc { Arc::new(ChainConfig { enable_indexer: config.chain_indexer.enable_indexer, enable_receipt_event_caching: config.client.enable_rpc, + default_max_fee: config.fee.max_fee.clone(), ..chain_config }) } diff --git a/src/lotus_json/signed_message.rs b/src/lotus_json/signed_message.rs index b6efabb990ab..2a61e109e156 100644 --- a/src/lotus_json/signed_message.rs +++ b/src/lotus_json/signed_message.rs @@ -59,8 +59,8 @@ impl HasLotusJson for SignedMessage { }, }), SignedMessage { - message: crate::shim::message::Message::default(), - signature: crate::shim::crypto::Signature { + message: Message::default(), + signature: Signature { sig_type: crate::shim::crypto::SignatureType::Bls, bytes: Vec::from_iter(*b"hello world!"), }, diff --git a/src/networks/mod.rs b/src/networks/mod.rs index dc700ac6a080..0d4a95db4b9c 100644 --- a/src/networks/mod.rs +++ b/src/networks/mod.rs @@ -10,6 +10,7 @@ use fil_actors_shared::v13::runtime::Policy; use fvm_ipld_blockstore::Blockstore; use itertools::Itertools; use libp2p::Multiaddr; +use num_traits::Zero; use serde::{Deserialize, Serialize}; use strum::IntoEnumIterator; use strum_macros::Display; @@ -283,6 +284,7 @@ pub struct ChainConfig { pub f3_initial_power_table: Option, pub enable_indexer: bool, pub enable_receipt_event_caching: bool, + pub default_max_fee: TokenAmount, } impl ChainConfig { @@ -316,6 +318,7 @@ impl ChainConfig { ), enable_indexer: false, enable_receipt_event_caching: true, + default_max_fee: TokenAmount::zero(), } } @@ -352,6 +355,7 @@ impl ChainConfig { ), enable_indexer: false, enable_receipt_event_caching: true, + default_max_fee: TokenAmount::zero(), } } @@ -378,6 +382,7 @@ impl ChainConfig { f3_initial_power_table: None, enable_indexer: false, enable_receipt_event_caching: true, + default_max_fee: TokenAmount::zero(), } } @@ -410,6 +415,7 @@ impl ChainConfig { f3_initial_power_table: None, enable_indexer: false, enable_receipt_event_caching: true, + default_max_fee: TokenAmount::zero(), } } diff --git a/src/rpc/methods/chain.rs b/src/rpc/methods/chain.rs index 82771feaf1ac..cb98e5d605f8 100644 --- a/src/rpc/methods/chain.rs +++ b/src/rpc/methods/chain.rs @@ -219,7 +219,7 @@ impl RpcMethod<1> for ChainGetMessage { const DESCRIPTION: Option<&'static str> = Some("Returns the message with the specified CID."); type Params = (Cid,); - type Ok = Message; + type Ok = FlattenedApiMessage; async fn handle( ctx: Ctx, @@ -229,10 +229,13 @@ impl RpcMethod<1> for ChainGetMessage { .store() .get_cbor(&message_cid)? .with_context(|| format!("can't find message with cid {message_cid}"))?; - Ok(match chain_message { + let message = match chain_message { ChainMessage::Signed(m) => m.into_message(), ChainMessage::Unsigned(m) => m, - }) + }; + + let cid = message.cid(); + Ok(FlattenedApiMessage { message, cid }) } } @@ -1357,6 +1360,18 @@ pub struct ApiMessage { lotus_json_with_self!(ApiMessage); +#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug, Eq, PartialEq)] +pub struct FlattenedApiMessage { + #[serde(flatten, with = "crate::lotus_json")] + #[schemars(with = "LotusJson")] + pub message: Message, + #[serde(rename = "CID", with = "crate::lotus_json")] + #[schemars(with = "LotusJson")] + pub cid: Cid, +} + +lotus_json_with_self!(FlattenedApiMessage); + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ForestChainExportParams { pub version: FilecoinSnapshotVersion, diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index cfe2bd389d79..3c1dcea65cd9 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -848,7 +848,7 @@ impl RpcMethod<0> for EthGasPrice { let ts = ctx.chain_store().heaviest_tipset(); let block0 = ts.block_headers().first(); let base_fee = block0.parent_base_fee.atto(); - let tip = crate::rpc::gas::estimate_gas_premium(&ctx, 0) + let tip = crate::rpc::gas::estimate_gas_premium(&ctx, 0, &ApiTipsetKey(None)) .await .map(|gas_premium| gas_premium.atto().to_owned()) .unwrap_or_default(); @@ -2321,7 +2321,7 @@ impl RpcMethod<0> for EthMaxPriorityFeePerGas { ctx: Ctx, (): Self::Params, ) -> Result { - match crate::rpc::gas::estimate_gas_premium(&ctx, 0).await { + match gas::estimate_gas_premium(&ctx, 0, &ApiTipsetKey(None)).await { Ok(gas_premium) => Ok(EthBigInt(gas_premium.atto().clone())), Err(_) => Ok(EthBigInt(num_bigint::BigInt::zero())), } diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs index f52e951735d6..6d41e5afca52 100644 --- a/src/rpc/methods/gas.rs +++ b/src/rpc/methods/gas.rs @@ -6,6 +6,7 @@ use crate::blocks::Tipset; use crate::chain::{BASE_FEE_MAX_CHANGE_DENOM, BLOCK_GAS_TARGET}; use crate::interpreter::VMTrace; use crate::message::{ChainMessage, Message as MessageTrait, SignedMessage}; +use crate::rpc::chain::FlattenedApiMessage; use crate::rpc::{ApiPaths, Ctx, Permission, RpcMethod, error::ServerError, types::*}; use crate::shim::executor::ApplyRet; use crate::shim::{ @@ -20,6 +21,7 @@ use fvm_ipld_blockstore::Blockstore; use num::BigInt; use num_traits::{FromPrimitive, Zero}; use rand_distr::{Distribution, Normal}; +use std::ops::Add; const MIN_GAS_PREMIUM: f64 = 100000.0; @@ -40,17 +42,19 @@ impl RpcMethod<3> for GasEstimateFeeCap { ctx: Ctx, (msg, max_queue_blks, tsk): Self::Params, ) -> Result { - estimate_fee_cap(&ctx, msg, max_queue_blks, tsk).map(|n| TokenAmount::to_string(&n)) + estimate_fee_cap(&ctx, &msg, max_queue_blks, &tsk).map(|n| TokenAmount::to_string(&n)) } } fn estimate_fee_cap( data: &Ctx, - msg: Message, + msg: &Message, max_queue_blks: i64, - _: ApiTipsetKey, + ApiTipsetKey(ts_key): &ApiTipsetKey, ) -> Result { - let ts = data.chain_store().heaviest_tipset(); + let ts = data + .chain_store() + .load_required_tipset_or_heaviest(ts_key)?; let parent_base_fee = &ts.block_headers().first().parent_base_fee; let increase_factor = @@ -59,8 +63,7 @@ fn estimate_fee_cap( let fee_in_future = parent_base_fee * BigInt::from_f64(increase_factor * (1 << 8) as f64) .context("failed to convert fee_in_future f64 to bigint")?; - let mut out: crate::shim::econ::TokenAmount = fee_in_future.div_floor(1 << 8); - out += msg.gas_premium(); + let out = fee_in_future.div_floor(1 << 8).add(msg.gas_premium()); Ok(out) } @@ -84,31 +87,35 @@ impl RpcMethod<4> for GasEstimateGasPremium { async fn handle( ctx: Ctx, - (nblocksincl, _sender, _gas_limit, _tsk): Self::Params, + (nblocksincl, _sender, _gas_limit, tsk): Self::Params, ) -> Result { - estimate_gas_premium(&ctx, nblocksincl) + estimate_gas_premium(&ctx, nblocksincl, &tsk) .await .map(|n| TokenAmount::to_string(&n)) } } +#[derive(Clone)] +struct GasMeta { + pub price: TokenAmount, + pub limit: u64, +} + pub async fn estimate_gas_premium( data: &Ctx, mut nblocksincl: u64, + ApiTipsetKey(ts_key): &ApiTipsetKey, ) -> Result { if nblocksincl == 0 { nblocksincl = 1; } - struct GasMeta { - pub price: TokenAmount, - pub limit: u64, - } - let mut prices: Vec = Vec::new(); let mut blocks = 0; - let mut ts = data.chain_store().heaviest_tipset(); + let mut ts = data + .chain_store() + .load_required_tipset_or_heaviest(ts_key)?; for _ in 0..(nblocksincl * 2) { if ts.epoch() == 0 { @@ -131,25 +138,9 @@ pub async fn estimate_gas_premium( ts = pts; } - prices.sort_by(|a, b| b.price.cmp(&a.price)); - let mut at = BLOCK_GAS_TARGET * blocks as u64 / 2; - let mut prev = TokenAmount::zero(); - let mut premium = TokenAmount::zero(); - - for price in prices { - at -= price.limit; - if at > 0 { - prev = price.price; - continue; - } - if prev == TokenAmount::zero() { - let ret: TokenAmount = price.price + TokenAmount::from_atto(1); - return Ok(ret); - } - premium = (&price.price + &prev).div_floor(2) + TokenAmount::from_atto(1) - } + let mut premium = compute_gas_premium(prices, blocks as u64); - if premium == TokenAmount::zero() { + if premium < TokenAmount::from_atto(MIN_GAS_PREMIUM as u64) { premium = TokenAmount::from_atto(match nblocksincl { 1 => (MIN_GAS_PREMIUM * 2.0) as u64, 2 => (MIN_GAS_PREMIUM * 1.5) as u64, @@ -164,13 +155,41 @@ pub async fn estimate_gas_premium( .unwrap() .sample(&mut crate::utils::rand::forest_rng()); - premium *= BigInt::from_f64(noise * (1i64 << precision) as f64) + premium *= BigInt::from_f64((noise * (1i64 << precision) as f64) + 1f64) .context("failed to convert gas premium f64 to bigint")?; premium = premium.div_floor(1i64 << precision); Ok(premium) } +// logic taken from here +fn compute_gas_premium(mut prices: Vec, blocks: u64) -> TokenAmount { + prices.sort_by(|a, b| b.price.cmp(&a.price)); + + let mut at = BLOCK_GAS_TARGET * blocks / 2; + at += BLOCK_GAS_TARGET * blocks / (2 * 20); + + let mut prev1 = TokenAmount::zero(); + let mut prev2 = TokenAmount::zero(); + + for p in prices { + prev2 = prev1.clone(); + prev1 = p.price.clone(); + + if p.limit > at { + // We've crossed the threshold + break; + } + at -= p.limit; + } + + if prev2.is_zero() { + prev1 + } else { + (&prev1 + &prev2).div_floor(2) + } +} + pub enum GasEstimateGasLimit {} impl RpcMethod<2> for GasEstimateGasLimit { const NAME: &'static str = "Filecoin.GasEstimateGasLimit"; @@ -283,20 +302,22 @@ impl RpcMethod<3> for GasEstimateMessageGas { Some("Returns the estimated gas for the given parameters."); type Params = (Message, Option, ApiTipsetKey); - type Ok = Message; + type Ok = FlattenedApiMessage; async fn handle( ctx: Ctx, (msg, spec, tsk): Self::Params, ) -> Result { - estimate_message_gas(&ctx, msg, spec, tsk).await + let message = estimate_message_gas(&ctx, msg, spec, tsk).await?; + let cid = message.cid(); + Ok(FlattenedApiMessage { message, cid }) } } pub async fn estimate_message_gas( data: &Ctx, mut msg: Message, - _spec: Option, + msg_spec: Option, tsk: ApiTipsetKey, ) -> Result where @@ -308,12 +329,288 @@ where msg.set_gas_limit((gl as u64).min(BLOCK_GAS_LIMIT)); } if msg.gas_premium.is_zero() { - let gp = estimate_gas_premium(data, 10).await?; + let gp = estimate_gas_premium(data, 10, &tsk).await?; msg.set_gas_premium(gp); } if msg.gas_fee_cap.is_zero() { - let gfp = estimate_fee_cap(data, msg.clone(), 20, tsk)?; + let gfp = estimate_fee_cap(data, &msg, 20, &tsk)?; msg.set_gas_fee_cap(gfp); } + + cap_gas_fee(&data.chain_config().default_max_fee, &mut msg, msg_spec)?; + Ok(msg) } + +/// Caps the gas fee to ensure it doesn't exceed the maximum allowed fee. +/// Returns an error if the msg `gas_limit` is zero +fn cap_gas_fee( + default_max_fee: &TokenAmount, + msg: &mut Message, + msg_spec: Option, +) -> Result<()> { + let gas_limit = msg.gas_limit(); + anyhow::ensure!(gas_limit > 0, "gas limit must be positive for fee capping"); + + let (maximize_fee_cap, max_fee) = match &msg_spec { + Some(spec) => ( + spec.maximize_fee_cap, + if spec.max_fee.is_zero() { + default_max_fee + } else { + &spec.max_fee + }, + ), + None => (false, default_max_fee), + }; + + let total_fee = msg.gas_fee_cap() * gas_limit; + if !max_fee.is_zero() && (maximize_fee_cap || total_fee > *max_fee) { + msg.set_gas_fee_cap(max_fee.div_floor(gas_limit)); + } + + // cap premium at FeeCap + msg.set_gas_premium(msg.gas_fee_cap().min(msg.gas_premium())); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shim::econ::TokenAmount; + use crate::utils; + + #[test] + fn test_compute_gas_premium_single_entry() { + // Test with single entry at full block gas target + let prices = vec![GasMeta { + price: TokenAmount::from_atto(5), + limit: BLOCK_GAS_TARGET, + }]; + let result = compute_gas_premium(prices, 1); + assert_eq!(result, TokenAmount::from_atto(5)); + } + + #[test] + fn test_compute_gas_premium_two_entries() { + // Test with two entries, each at full block gas target + // Function will sort by price descending: [10, 5] + // With 1 block: at = BLOCK_GAS_TARGET/2 + BLOCK_GAS_TARGET/40 = 2.625B gas + // First entry (10): limit = 5B > 2.625B, so we stop immediately and return first price + let prices = vec![ + GasMeta { + price: TokenAmount::from_atto(5), + limit: BLOCK_GAS_TARGET, + }, + GasMeta { + price: TokenAmount::from_atto(10), + limit: BLOCK_GAS_TARGET, + }, + ]; + let result = compute_gas_premium(prices, 1); + assert_eq!(result, TokenAmount::from_atto(10)); + } + + #[test] + fn test_compute_gas_premium_half_block_entries_single_block() { + // Test with entries at half-block gas target, single block + // Function will sort by price descending: [20, 10] + let prices = vec![ + GasMeta { + price: TokenAmount::from_atto(10), + limit: BLOCK_GAS_TARGET / 2, + }, + GasMeta { + price: TokenAmount::from_atto(20), + limit: BLOCK_GAS_TARGET / 2, + }, + ]; + let result = compute_gas_premium(prices, 1); + assert_eq!(result, TokenAmount::from_atto(15)); + } + + #[test] + fn test_compute_gas_premium_three_entries_two_blocks() { + // Test with three entries at a half-block gas target, two blocks + // Function will sort by price descending: [30, 20, 10] + // With 2 blocks: at = BLOCK_GAS_TARGET + BLOCK_GAS_TARGET/20 = 5.25B gas + // First entry (30): at = 5.25B - 2.5B = 2.75B remaining + // Second entry (20): at = 2.75B - 2.5B = 0.25B remaining + // Third entry (10): limit = 2.5B > 0.25B, so we stop and average second and third + let prices = vec![ + GasMeta { + price: TokenAmount::from_atto(10), + limit: BLOCK_GAS_TARGET / 2, + }, + GasMeta { + price: TokenAmount::from_atto(20), + limit: BLOCK_GAS_TARGET / 2, + }, + GasMeta { + price: TokenAmount::from_atto(30), + limit: BLOCK_GAS_TARGET / 2, + }, + ]; + let result = compute_gas_premium(prices, 2); + let expected = (TokenAmount::from_atto(20) + TokenAmount::from_atto(10)).div_floor(2); + assert_eq!(result, expected); + } + + #[test] + fn test_compute_gas_premium_empty_list() { + // Test with empty price list + let prices = vec![]; + let result = compute_gas_premium(prices, 1); + assert_eq!(result, TokenAmount::zero()); + } + + #[test] + fn test_compute_gas_premium_large_gas_limits() { + // Test with entries that have gas limits larger than the threshold + // Function will sort by price descending: [100, 50] + let prices = vec![ + GasMeta { + price: TokenAmount::from_atto(100), + limit: BLOCK_GAS_TARGET * 2, // Exceeds threshold immediately + }, + GasMeta { + price: TokenAmount::from_atto(50), + limit: BLOCK_GAS_TARGET / 4, + }, + ]; + let result = compute_gas_premium(prices, 1); + assert_eq!(result, TokenAmount::from_atto(100)); + } + + #[test] + fn test_compute_gas_premium_unsorted_input() { + // Test that function correctly handles unsorted input (sorting is done internally) + // Input order: [10, 30, 20] -> After internal sorting: [30, 20, 10] + let prices = vec![ + GasMeta { + price: TokenAmount::from_atto(10), + limit: BLOCK_GAS_TARGET / 4, + }, + GasMeta { + price: TokenAmount::from_atto(30), + limit: BLOCK_GAS_TARGET / 4, + }, + GasMeta { + price: TokenAmount::from_atto(20), + limit: BLOCK_GAS_TARGET / 4, + }, + ]; + + let result = compute_gas_premium(prices, 1); + let expected = (TokenAmount::from_atto(20) + TokenAmount::from_atto(10)).div_floor(2); + assert_eq!(result, expected); + } + + #[test] + fn test_compute_gas_premium_multiple_blocks() { + // Test with multiple blocks affecting the threshold calculation + // Function will sort by price descending: [40, 30, 20, 10] + let prices = vec![ + GasMeta { + price: TokenAmount::from_atto(40), + limit: BLOCK_GAS_TARGET / 4, + }, + GasMeta { + price: TokenAmount::from_atto(30), + limit: BLOCK_GAS_TARGET / 4, + }, + GasMeta { + price: TokenAmount::from_atto(20), + limit: BLOCK_GAS_TARGET / 4, + }, + GasMeta { + price: TokenAmount::from_atto(10), + limit: BLOCK_GAS_TARGET / 4, + }, + ]; + + // With 3 blocks, threshold is higher, so we should get a different result + let result_1_block = compute_gas_premium(prices.clone(), 1); + let result_3_blocks = compute_gas_premium(prices, 3); + + // With more blocks, the threshold is higher, so we should pick a lower price + assert!(result_3_blocks <= result_1_block); + } + + // Helper function to create a test message with gas parameters + fn create_test_message(gas_limit: u64, gas_fee_cap: u64, gas_premium: u64) -> Message { + Message { + from: Address::new_id(1000), + to: Address::new_id(1001), + gas_limit, + gas_fee_cap: TokenAmount::from_atto(gas_fee_cap), + gas_premium: TokenAmount::from_atto(gas_premium), + ..Default::default() + } + } + + #[test] + fn test_cap_gas_fee_within_limit() { + // Normal case: total fee is within default max fee + let default_max_fee = TokenAmount::from_atto(1_000_000); + let mut msg = create_test_message(1000, 500, 100); + + cap_gas_fee(&default_max_fee, &mut msg, None).unwrap(); + + assert_eq!(msg.gas_fee_cap(), TokenAmount::from_atto(500)); + assert_eq!(msg.gas_premium(), TokenAmount::from_atto(100)); + } + + #[test] + fn test_cap_gas_fee_exceeds_limit() { + // Fee exceeds max: should cap gas_fee_cap + let default_max_fee = TokenAmount::from_atto(500_000); + let mut msg = create_test_message(1000, 1000, 200); + + cap_gas_fee(&default_max_fee, &mut msg, None).unwrap(); + + assert_eq!(msg.gas_fee_cap(), TokenAmount::from_atto(500)); + assert_eq!(msg.gas_premium(), TokenAmount::from_atto(200)); + } + + #[test] + fn test_cap_gas_fee_premium_exceeds_fee_cap() { + // Premium exceeds fee cap after capping: premium should be capped too + let default_max_fee = TokenAmount::from_atto(300_000); + let mut msg = create_test_message(1000, 1000, 800); + + cap_gas_fee(&default_max_fee, &mut msg, None).unwrap(); + + assert_eq!(msg.gas_fee_cap(), TokenAmount::from_atto(300)); + assert_eq!(msg.gas_premium(), TokenAmount::from_atto(300)); + } + + #[test] + fn test_cap_gas_fee_maximize_flag() { + // maximize_fee_cap flag: should set gas_fee_cap to max even if within limit + let default_max_fee = TokenAmount::from_atto(1_000_000); + let mut msg = create_test_message(1000, 500, 100); + + let spec = MessageSendSpec { + max_fee: TokenAmount::zero(), + msg_uuid: utils::rand::new_uuid_v4(), + maximize_fee_cap: true, + }; + + cap_gas_fee(&default_max_fee, &mut msg, Some(spec)).unwrap(); + + assert_eq!(msg.gas_fee_cap(), TokenAmount::from_atto(1000)); + assert_eq!(msg.gas_premium(), TokenAmount::from_atto(100)); + } + + #[test] + fn test_cap_gas_fee_zero_gas_limit() { + // Edge case: zero gas_limit should return an error + let default_max_fee = TokenAmount::from_atto(1_000_000); + let mut msg = create_test_message(0, 1000, 200); + + let result = cap_gas_fee(&default_max_fee, &mut msg, None); + + assert!(result.is_err()); + } +} diff --git a/src/rpc/methods/msig.rs b/src/rpc/methods/msig.rs index b1e02d093c8d..891659f89f28 100644 --- a/src/rpc/methods/msig.rs +++ b/src/rpc/methods/msig.rs @@ -35,7 +35,7 @@ impl RpcMethod<2> for MsigGetAvailableBalance { .get_required_actor(&address, *ts.parent_state())?; let actor_balance = TokenAmount::from(&actor.balance); let ms = multisig::State::load(ctx.store(), actor.code, actor.state)?; - let locked_balance = ms.locked_balance(height)?.into(); + let locked_balance: TokenAmount = ms.locked_balance(height)?.into(); let avail_balance = &actor_balance - locked_balance; Ok(avail_balance) } diff --git a/src/rpc/types/mod.rs b/src/rpc/types/mod.rs index c0b019e71d91..e0e4315383d0 100644 --- a/src/rpc/types/mod.rs +++ b/src/rpc/types/mod.rs @@ -53,7 +53,9 @@ use std::str::FromStr; pub struct MessageSendSpec { #[schemars(with = "LotusJson")] #[serde(with = "crate::lotus_json")] - max_fee: TokenAmount, + pub max_fee: TokenAmount, + pub msg_uuid: uuid::Uuid, + pub maximize_fee_cap: bool, } lotus_json_with_self!(MessageSendSpec); diff --git a/src/shim/econ.rs b/src/shim/econ.rs index e4f5fbe3e5df..e0b0abcd9bba 100644 --- a/src/shim/econ.rs +++ b/src/shim/econ.rs @@ -1,12 +1,6 @@ // Copyright 2019-2025 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT -use std::{ - fmt, - ops::{Add, AddAssign, Deref, DerefMut, Mul, MulAssign, Sub, SubAssign}, - sync::LazyLock, -}; - use super::fvm_shared_latest::econ::TokenAmount as TokenAmount_latest; use crate::utils::get_size::big_int_heap_size_helper; use fvm_shared2::econ::TokenAmount as TokenAmount_v2; @@ -15,9 +9,14 @@ pub use fvm_shared3::{BLOCK_GAS_LIMIT, TOTAL_FILECOIN_BASE}; use fvm_shared4::econ::TokenAmount as TokenAmount_v4; use get_size2::GetSize; use num_bigint::BigInt; -use num_traits::Zero; +use num_traits::{One, Signed, Zero}; use serde::{Deserialize, Serialize}; use static_assertions::const_assert_eq; +use std::{ + fmt, + ops::{Add, AddAssign, Deref, DerefMut, Div, Mul, MulAssign, Neg, Rem, Sub, SubAssign}, + sync::LazyLock, +}; const_assert_eq!(BLOCK_GAS_LIMIT, fvm_shared2::BLOCK_GAS_LIMIT as u64); const_assert_eq!(TOTAL_FILECOIN_BASE, fvm_shared2::TOTAL_FILECOIN_BASE); @@ -70,6 +69,36 @@ impl Zero for TokenAmount { } } +impl One for TokenAmount { + fn one() -> Self { + TokenAmount::from_atto(1) + } +} + +impl num_traits::Num for TokenAmount { + type FromStrRadixErr = num_bigint::ParseBigIntError; + + fn from_str_radix(str: &str, radix: u32) -> Result { + Ok(Self::from_atto(BigInt::from_str_radix(str, radix)?)) + } +} + +impl Neg for TokenAmount { + type Output = Self; + + fn neg(self) -> Self::Output { + self.0.neg().into() + } +} + +impl Neg for &TokenAmount { + type Output = TokenAmount; + + fn neg(self) -> Self::Output { + (&self.0).neg().into() + } +} + impl Deref for TokenAmount { type Target = TokenAmount_latest; @@ -130,6 +159,29 @@ impl TokenAmount { pub fn div_floor(&self, other: impl Into) -> TokenAmount { self.0.div_floor(other).into() } + + /// Checks if two `TokenAmounts` are within a percentage delta of each other. + /// This method computes the absolute difference between `self` and `other`, then checks + /// if this difference is within `delta_percent` of the larger of the two values. + /// # Arguments + /// * `other` - The value to compare against + /// * `delta_percent` - The allowed percentage difference relative to the larger value (e.g., 5 for 5%) + /// + /// # Returns + /// `true` if the values are within the delta, `false` otherwise + pub fn is_within_percent(&self, other: &TokenAmount, delta_percent: u64) -> bool { + match (self.is_zero(), other.is_zero()) { + (true, true) => return true, // Both zero: equal + (true, false) | (false, true) => return false, // One zero: fundamentally different + _ => {} // Both non-zero: continue + } + + let diff = (self - other).abs(); + let max_magnitude = self.abs().max(other.abs()); + let threshold = (max_magnitude * delta_percent).div_floor(100u64); + + diff <= threshold + } } impl From for BigInt { @@ -264,56 +316,235 @@ impl Mul for TokenAmount { } } -impl Add for &TokenAmount { - type Output = TokenAmount; - fn add(self, rhs: TokenAmount) -> Self::Output { - (&self.0).add(rhs.0).into() +/// Macro to implement binary operators for `TokenAmount`. +macro_rules! impl_token_amount_op { + ($trait:ident, $method:ident) => { + impl $trait for TokenAmount { + type Output = TokenAmount; + #[inline] + fn $method(self, rhs: TokenAmount) -> Self::Output { + self.atto().$method(rhs.atto()).into() + } + } + + impl $trait<&TokenAmount> for TokenAmount { + type Output = TokenAmount; + #[inline] + fn $method(self, rhs: &TokenAmount) -> Self::Output { + self.atto().$method(rhs.atto()).into() + } + } + + impl $trait for &TokenAmount { + type Output = TokenAmount; + #[inline] + fn $method(self, rhs: TokenAmount) -> Self::Output { + self.atto().$method(rhs.atto()).into() + } + } + + impl $trait<&TokenAmount> for &TokenAmount { + type Output = TokenAmount; + #[inline] + fn $method(self, rhs: &TokenAmount) -> Self::Output { + self.atto().$method(rhs.atto()).into() + } + } + }; +} + +impl_token_amount_op!(Add, add); +impl_token_amount_op!(Sub, sub); +impl_token_amount_op!(Mul, mul); +impl_token_amount_op!(Div, div); +impl_token_amount_op!(Rem, rem); + +impl AddAssign for TokenAmount { + fn add_assign(&mut self, other: Self) { + self.0.add_assign(other.0) } } -impl Add<&TokenAmount> for &TokenAmount { - type Output = TokenAmount; - fn add(self, rhs: &TokenAmount) -> Self::Output { - (&self.0).add(&rhs.0).into() +impl SubAssign for TokenAmount { + fn sub_assign(&mut self, other: Self) { + self.0.sub_assign(other.0) } } -impl Add for TokenAmount { - type Output = TokenAmount; - fn add(self, rhs: TokenAmount) -> Self::Output { - (&self.0).add(rhs.0).into() +impl Signed for TokenAmount { + fn abs(&self) -> Self { + self.0.atto().abs().into() } -} -impl Add<&TokenAmount> for TokenAmount { - type Output = TokenAmount; - fn add(self, rhs: &TokenAmount) -> Self::Output { - (&self.0).add(&rhs.0).into() + fn abs_sub(&self, other: &Self) -> Self { + self.0.atto().abs_sub(other.0.atto()).into() } -} -impl AddAssign for TokenAmount { - fn add_assign(&mut self, other: Self) { - self.0.add_assign(other.0) + fn signum(&self) -> Self { + self.0.atto().signum().into() } -} -impl SubAssign for TokenAmount { - fn sub_assign(&mut self, other: Self) { - self.0.sub_assign(other.0) + fn is_positive(&self) -> bool { + self.0.is_positive() } -} -impl Sub<&TokenAmount> for TokenAmount { - type Output = TokenAmount; - fn sub(self, rhs: &TokenAmount) -> Self::Output { - (&self.0).sub(&rhs.0).into() + fn is_negative(&self) -> bool { + self.0.is_negative() } } -impl Sub for &TokenAmount { - type Output = TokenAmount; - fn sub(self, rhs: TokenAmount) -> Self::Output { - (&self.0).sub(&rhs.0).into() +#[cfg(test)] +mod tests { + use super::*; + use num_traits::Signed; + + #[test] + fn test_abs_positive() { + let val = TokenAmount::from_atto(100); + assert_eq!(val.abs(), val); + } + + #[test] + fn test_abs_negative() { + let val = TokenAmount::from_atto(-100); + let expected = TokenAmount::from_atto(100); + assert_eq!(val.abs(), expected); + } + + #[test] + fn test_abs_zero() { + let val = TokenAmount::zero(); + assert_eq!(val.abs(), val); + } + + #[test] + fn test_signum_positive() { + let val = TokenAmount::from_atto(100); + assert_eq!(val.signum(), TokenAmount::one()); + } + + #[test] + fn test_signum_negative() { + let val = TokenAmount::from_atto(-100); + assert_eq!(val.signum(), -TokenAmount::one()); + } + + #[test] + fn test_signum_zero() { + let val = TokenAmount::zero(); + assert_eq!(val.signum(), TokenAmount::zero()); + } + + #[test] + fn test_is_positive() { + assert!(TokenAmount::from_atto(1).is_positive()); + assert!(TokenAmount::from_atto(100).is_positive()); + assert!(!TokenAmount::from_atto(-1).is_positive()); + assert!(!TokenAmount::zero().is_positive()); + } + + #[test] + fn test_is_negative() { + assert!(TokenAmount::from_atto(-1).is_negative()); + assert!(TokenAmount::from_atto(-100).is_negative()); + assert!(!TokenAmount::from_atto(1).is_negative()); + assert!(!TokenAmount::zero().is_negative()); + } + + #[test] + fn test_abs_sub() { + let val1 = TokenAmount::from_atto(100); + let val2 = TokenAmount::from_atto(70); + assert_eq!(val1.abs_sub(&val2), TokenAmount::from_atto(30)); + assert_eq!(val2.abs_sub(&val1), TokenAmount::zero()); + } + + #[test] + fn test_neg_trait() { + let val = TokenAmount::from_atto(100); + assert_eq!(-val.clone(), TokenAmount::from_atto(-100)); + assert_eq!(-(-val), TokenAmount::from_atto(100)); + } + + #[test] + fn test_is_within_percent_zero_edge_cases() { + let zero = TokenAmount::zero(); + assert!(zero.is_within_percent(&zero, 5)); + + let val = TokenAmount::from_atto(100); + assert!(!val.is_within_percent(&zero, 5)); + assert!(!zero.is_within_percent(&val, 5)); + } + + #[test] + fn test_is_within_percent_boundary_conditions() { + let base = TokenAmount::from_atto(100); + + // exactly at threshold + let at_threshold = TokenAmount::from_atto(105); + assert!(base.is_within_percent(&at_threshold, 5)); + assert!(at_threshold.is_within_percent(&base, 5)); + + // over threshold + let over_threshold = TokenAmount::from_atto(106); + assert!(!base.is_within_percent(&over_threshold, 5)); + assert!(!over_threshold.is_within_percent(&base, 5)); + + // below base + let below = TokenAmount::from_atto(95); + assert!(base.is_within_percent(&below, 5)); + + // different thresholds (3% difference) + let val1 = TokenAmount::from_atto(1000); + let val2 = TokenAmount::from_atto(1030); + assert!(val1.is_within_percent(&val2, 5)); + assert!(val1.is_within_percent(&val2, 3)); + assert!(!val1.is_within_percent(&val2, 2)); + } + + #[test] + fn test_is_within_percent_large_values() { + let val1 = TokenAmount::from_atto(1_500_000_000_000_000u64); + let val2 = TokenAmount::from_atto(1_570_000_000_000_000u64); + assert!(val1.is_within_percent(&val2, 5)); + assert!(!val1.is_within_percent(&val2, 4)); + } + + #[test] + fn test_is_within_percent_negative_values() { + let neg1 = TokenAmount::from_atto(-100); + let neg2 = TokenAmount::from_atto(-95); + assert!(neg1.is_within_percent(&neg2, 5)); + assert!(neg2.is_within_percent(&neg1, 5)); + + // over threshold + let neg3 = TokenAmount::from_atto(-94); + assert!(!neg1.is_within_percent(&neg3, 5)); + assert!(!neg3.is_within_percent(&neg1, 5)); + } + + #[test] + fn test_is_within_percent_mixed_signs() { + let pos = TokenAmount::from_atto(100); + let neg = TokenAmount::from_atto(-100); + assert!(!pos.is_within_percent(&neg, 5)); + assert!(!pos.is_within_percent(&neg, 100)); + assert!(pos.is_within_percent(&neg, 200)); + } + + #[test] + fn test_div_rem() { + let dividend = TokenAmount::from_atto(100); + let divisor = TokenAmount::from_atto(30); + let quotient = dividend.clone() / divisor.clone(); + let remainder = dividend.clone() % divisor.clone(); + + assert_eq!(quotient, TokenAmount::from_atto(3)); + assert_eq!(remainder, TokenAmount::from_atto(10)); + } + + #[test] + fn test_one_trait() { + assert_eq!(TokenAmount::one(), TokenAmount::from_atto(1)); } } diff --git a/src/tool/subcommands/api_cmd/api_compare_tests.rs b/src/tool/subcommands/api_cmd/api_compare_tests.rs index d2919a373ecd..09ac26d8eb87 100644 --- a/src/tool/subcommands/api_cmd/api_compare_tests.rs +++ b/src/tool/subcommands/api_cmd/api_compare_tests.rs @@ -15,7 +15,7 @@ use crate::rpc::eth::{ BlockNumberOrHash, EthInt64, ExtBlockNumberOrHash, ExtPredefined, Predefined, new_eth_tx_from_signed_message, types::*, }; -use crate::rpc::gas::GasEstimateGasLimit; +use crate::rpc::gas::{GasEstimateGasLimit, GasEstimateMessageGas}; use crate::rpc::miner::BlockTemplate; use crate::rpc::misc::ActorEventFilter; use crate::rpc::state::StateGetAllClaims; @@ -2156,14 +2156,52 @@ fn gas_tests_with_tipset(shared_tipset: &Tipset) -> Vec { ..Default::default() }; - // The tipset is only used for resolving the 'from' address and not when - // computing the gas cost. This means that the `GasEstimateGasLimit` method - // is inherently non-deterministic but I'm fairly sure we're compensated for - // everything. If not, this test will be flaky. Instead of disabling it, we - // should relax the verification requirement. - vec![RpcTest::identity( - GasEstimateGasLimit::request((message, shared_tipset.key().into())).unwrap(), - )] + vec![ + // The tipset is only used for resolving the 'from' address and not when + // computing the gas cost. This means that the `GasEstimateGasLimit` method + // is inherently non-deterministic, but I'm fairly sure we're compensated for + // everything. If not, this test will be flaky. Instead of disabling it, we + // should relax the verification requirement. + RpcTest::identity( + GasEstimateGasLimit::request((message.clone(), shared_tipset.key().into())).unwrap(), + ), + // Gas estimation is inherently non-deterministic due to randomness in gas premium + // calculation and network state changes. We validate that both implementations + // return reasonable values within expected bounds rather than exact equality. + RpcTest::validate( + GasEstimateMessageGas::request(( + message, + None, // No MessageSendSpec + shared_tipset.key().into(), + )) + .unwrap(), + |forest_api_msg, lotus_api_msg| { + let forest_msg = forest_api_msg.message; + let lotus_msg = lotus_api_msg.message; + // Validate that the gas limit is identical (must be deterministic) + if forest_msg.gas_limit != lotus_msg.gas_limit { + return false; + } + + // Validate gas fee cap and premium are within reasonable bounds (±5%) + let forest_fee_cap = &forest_msg.gas_fee_cap; + let lotus_fee_cap = &lotus_msg.gas_fee_cap; + let forest_premium = &forest_msg.gas_premium; + let lotus_premium = &lotus_msg.gas_premium; + + // Gas fee cap and premium should not be negative + if [forest_fee_cap, lotus_fee_cap, forest_premium, lotus_premium] + .iter() + .any(|amt| amt.is_negative()) + { + return false; + } + + forest_fee_cap.is_within_percent(lotus_fee_cap, 5) + && forest_premium.is_within_percent(lotus_premium, 5) + }, + ), + ] } fn f3_tests() -> anyhow::Result> { diff --git a/src/tool/subcommands/api_cmd/test_snapshots.txt b/src/tool/subcommands/api_cmd/test_snapshots.txt index 29952cd584a2..07de31a5b8c5 100644 --- a/src/tool/subcommands/api_cmd/test_snapshots.txt +++ b/src/tool/subcommands/api_cmd/test_snapshots.txt @@ -7,8 +7,8 @@ filecoin_chaingetevents_1746450551991052.rpcsnap.json.zst filecoin_chaingetevents_1750327595269729.rpcsnap.json.zst filecoin_chaingetfinalizedtipset_1759828121342574.rpcsnap.json.zst filecoin_chaingetgenesis_1736937286915866.rpcsnap.json.zst -filecoin_chaingetmessage_1741270616352800.rpcsnap.json.zst -filecoin_chaingetmessagesintipset_1741270616353560.rpcsnap.json.zst +filecoin_chaingetmessage_1758734340836824.rpcsnap.json.zst +filecoin_chaingetmessagesintipset_1758734696116136.rpcsnap.json.zst filecoin_chaingetparentmessages_1736937305551928.rpcsnap.json.zst filecoin_chaingetparentreceipts_1736937305550289.rpcsnap.json.zst filecoin_chaingetpath_1736937942817384.rpcsnap.json.zst @@ -53,7 +53,7 @@ filecoin_ethcall_1744204533058637.rpcsnap.json.zst filecoin_ethcall_1744204533066529.rpcsnap.json.zst filecoin_ethchainid_1736937942819147.rpcsnap.json.zst filecoin_ethfeehistory_1737446676883828.rpcsnap.json.zst -filecoin_ethgasprice_1741280031788015.rpcsnap.json.zst +filecoin_ethgasprice_1758725940980141.rpcsnap.json.zst filecoin_ethgetbalance_1740048634848277.rpcsnap.json.zst filecoin_ethgetblockbyhash_1740132537807408.rpcsnap.json.zst filecoin_ethgetblockbynumber_1737446676696328.rpcsnap.json.zst @@ -73,7 +73,7 @@ filecoin_ethgettransactioncount_1740132538183426.rpcsnap.json.zst filecoin_ethgettransactionhashbycid_1737446676698540.rpcsnap.json.zst filecoin_ethgettransactionreceipt_1741272955712904.rpcsnap.json.zst filecoin_ethgettransactionreceiptlimited_1741272955611272.rpcsnap.json.zst -filecoin_ethmaxpriorityfeepergas_1741778673582086.rpcsnap.json.zst +filecoin_ethmaxpriorityfeepergas_1758727346451988.rpcsnap.json.zst filecoin_ethnewblockfilter_1741779995902203.rpcsnap.json.zst filecoin_ethnewfilter_1741781607617949.rpcsnap.json.zst filecoin_ethnewpendingtransactionfilter_1741781890872902.rpcsnap.json.zst @@ -98,6 +98,7 @@ filecoin_filecoinaddresstoethaddress_1765363872743134.rpcsnap.json.zst # F1 addr filecoin_filecoinaddresstoethaddress_1765363872743268.rpcsnap.json.zst # F3 address filecoin_filecoinaddresstoethaddress_1765363872743313.rpcsnap.json.zst # F2 address filecoin_gasestimategaslimit_1741782110512299.rpcsnap.json.zst +filecoin_gasestimatemessagegas_1758735195199472.rpcsnap.json.zst filecoin_getactoreventsraw_1741782590255476.rpcsnap.json.zst filecoin_market_addbalance_statedecodeparams_1757426002278914.rpcsnap.json.zst filecoin_market_addbalanceexported_statedecodeparams_1757426002338931.rpcsnap.json.zst diff --git a/src/wallet/subcommands/wallet_cmd.rs b/src/wallet/subcommands/wallet_cmd.rs index ebf9d4e174e1..5b03bb96d3d9 100644 --- a/src/wallet/subcommands/wallet_cmd.rs +++ b/src/wallet/subcommands/wallet_cmd.rs @@ -516,7 +516,8 @@ impl WalletCommands { &backend.remote, (message, spec, ApiTipsetKey(None)), ) - .await?; + .await? + .message; if message.gas_premium > message.gas_fee_cap { anyhow::bail!("After estimation, gas premium is greater than gas fee cap")