diff --git a/.config/forest.dic b/.config/forest.dic index 9783db4b8ba8..1f1c155d5d88 100644 --- a/.config/forest.dic +++ b/.config/forest.dic @@ -1,4 +1,4 @@ -271 +273 Algorand/M API's API/SM @@ -189,6 +189,7 @@ precommit preloaded pubsub R2 +RBF README repo/S retag @@ -210,6 +211,7 @@ semver serializable serializer/SM serverless +signable Skellam skippable Sqlx diff --git a/CHANGELOG.md b/CHANGELOG.md index a5b8b4dd15bf..46fb7854dd71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,10 +33,16 @@ - [#6012](https://github.com/ChainSafe/forest/issues/6012): Stricter validation of address arguments in `forest-wallet` subcommands. +- [#7085](https://github.com/ChainSafe/forest/issues/7085): Implemented `nonce-fix` mpool cmd to fill mempool nonce gaps. + +- [#7086](https://github.com/ChainSafe/forest/issues/7086): Implemented `replace` mpool cmd to replace a message in the mempool. + ### Changed - [`#7066`](https://github.com/ChainSafe/forest/pull/7066): Disable JSON-RPC HTTP response compression by default. Set `FOREST_RPC_COMPRESS_MIN_BODY_SIZE` to a non-negative value (e.g. `1024`) to re-enable gzip compression of responses above that size. +- [#7084](https://github.com/ChainSafe/forest/pull/7084): Updated `replace-by-fee` calculation to match Lotus, reducing the minimum premium bump for replacement messages from `1.25x` to `1.10x`. Ports [filecoin-project/lotus#10416](https://github.com/filecoin-project/lotus/pull/10416). + ### Removed ### Fixed diff --git a/Cargo.toml b/Cargo.toml index 5ad864dc9ac8..fe8c1f7bc744 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -349,7 +349,7 @@ cargo-test = [] # group of tests that is recomm doctest-private = [] # see lib.rs::doctest_private benchmark-private = ["dep:criterion"] # see lib.rs::benchmark_private interop-tests-private = [] # see lib.rs::interop_tests_private -calibnet-wallet-integration = [] # see tests/calibnet_wallet.rs +calibnet-integration = [] # see tests/calibnet_*.rs (wallet, mpool_tools, etc.) sqlite = ["dep:sqlx"] # Allocator. Use at most one of these. @@ -378,9 +378,14 @@ harness = false required-features = ["benchmark-private"] [[test]] -name = "calibnet_wallet" +name = "mpool_tools" +path = "tests/calibnet_mpool_tools.rs" +required-features = ["calibnet-integration"] + +[[test]] +name = "wallet" path = "tests/calibnet_wallet.rs" -required-features = ["calibnet-wallet-integration"] +required-features = ["calibnet-integration"] [package.metadata.docs.rs] # See https://docs.rs/about/metadata diff --git a/docs/docs/users/reference/cli.md b/docs/docs/users/reference/cli.md index acfcf38cdeb6..cb2e90a7ce1d 100644 --- a/docs/docs/users/reference/cli.md +++ b/docs/docs/users/reference/cli.md @@ -617,10 +617,12 @@ Interact with the message pool Usage: forest-cli mpool Commands: - pending Get pending messages - nonce Get the current nonce for an address - stat Print mempool stats - help Print this message or the help of the given subcommand(s) + pending Get pending messages + nonce Get the current nonce for an address + stat Print mempool stats + nonce-fix Fill an on-chain nonce gap by pushing signed self-transfer messages + replace Replace a pending message in the mempool with updated gas parameters (replace-by-fee) + help Print this message or the help of the given subcommand(s) Options: -h, --help Print help @@ -671,6 +673,41 @@ Options: -h, --help Print help ``` +### `forest-cli mpool nonce-fix` + +``` +Fill an on-chain nonce gap by pushing signed self-transfer messages + +Usage: forest-cli mpool nonce-fix [OPTIONS] --addr + +Options: + --addr Address to fill nonce gaps (must be signable by the node's wallet) + --auto Derive the fill range from chain state and the mempool (ignores `--start` / `--end`) + --start First sequence to fill (inclusive); required unless `--auto` + --end End of range (exclusive); required unless `--auto` + --gas-fee-cap Gas fee cap for filler messages. Default: twice the parent base fee from chain head + -h, --help Print help +``` + +### `forest-cli mpool replace` + +``` +Replace a pending message in the mempool with updated gas parameters (replace-by-fee) + +Usage: forest-cli mpool replace [OPTIONS] + +Options: + --from Address that sent the message (required unless `--cid` is used) + --nonce Nonce of the message to replace (required unless `--cid` is used) + --cid CID of the message to replace (alternative to `--from`/`--nonce`) + --auto Automatically re-estimate gas, ensuring the RBF minimum premium is met + --max-fee Maximum total fee; only used with `--auto` + --gas-premium Gas premium (required unless `--auto` is used) + --gas-feecap Gas fee cap (required unless `--auto` is used) + --gas-limit Gas limit (Optional; keeps original value if unset) + -h, --help Print help +``` + ### `forest-cli state` ``` diff --git a/mise.toml b/mise.toml index f5ded49f30ac..03b4dbea7ebf 100644 --- a/mise.toml +++ b/mise.toml @@ -221,7 +221,8 @@ run = ''' set -euo pipefail source ./scripts/tests/harness.sh forest_wallet_init "${usage_preloaded_key?}" -cargo test --profile quick-test --features calibnet-wallet-integration --test calibnet_wallet -- --nocapture +cargo test --profile quick-test --features calibnet-integration --test mpool_tools -- --nocapture +cargo test --profile quick-test --features calibnet-integration --test wallet -- --nocapture ''' [tasks."codecov:nextest"] diff --git a/src/cli/subcommands/mpool_cmd.rs b/src/cli/subcommands/mpool_cmd.rs index c83a814c2cbd..9559e3c522f5 100644 --- a/src/cli/subcommands/mpool_cmd.rs +++ b/src/cli/subcommands/mpool_cmd.rs @@ -2,16 +2,24 @@ // SPDX-License-Identifier: Apache-2.0, MIT use crate::blocks::Tipset; +use crate::cli::humantoken; +use crate::cli_shared::cli::FeeConfig; use crate::lotus_json::{HasLotusJson as _, NotNullVec}; use crate::message::{MessageRead as _, SignedMessage}; -use crate::rpc::{self, prelude::*, types::ApiTipsetKey}; +use crate::message_pool::{REPLACE_BY_FEE_RATIO_DEFAULT, compute_rbf}; +use crate::rpc::gas::cap_gas_fee; +use crate::rpc::{self, prelude::*, types::ApiTipsetKey, types::MessageSendSpec}; use crate::shim::address::StrictAddress; -use crate::shim::message::Message; +use crate::shim::message::{METHOD_SEND, Message}; use crate::shim::{address::Address, econ::TokenAmount}; use ahash::{HashMap, HashSet}; +use anyhow::Context as _; +use cid::Cid; use clap::Subcommand; +use fvm_ipld_encoding::RawBytes; use num::BigInt; +use std::ops::Range; #[derive(Debug, Subcommand)] pub enum MpoolCommands { @@ -44,6 +52,51 @@ pub enum MpoolCommands { #[arg(long)] local: bool, }, + /// Fill an on-chain nonce gap by pushing signed self-transfer messages. + NonceFix { + /// Address to fill nonce gaps (must be signable by the node's wallet). + #[arg(long)] + addr: StrictAddress, + /// Derive the fill range from chain state and the mempool (ignores `--start` / `--end`). + #[arg(long, conflicts_with_all = ["start", "end"])] + auto: bool, + /// First sequence to fill (inclusive); required unless `--auto`. + #[arg(long, required_unless_present = "auto")] + start: Option, + /// End of range (exclusive); required unless `--auto`. + #[arg(long, required_unless_present = "auto")] + end: Option, + /// Gas fee cap for filler messages. Default: twice the parent base fee from chain head. + #[arg(long, value_parser = humantoken::parse)] + gas_fee_cap: Option, + }, + /// Replace a pending message in the mempool with updated gas parameters (replace-by-fee). + Replace { + /// Address that sent the message (required unless `--cid` is used). + #[arg(long, required_unless_present = "cid")] + from: Option, + /// Nonce of the message to replace (required unless `--cid` is used). + #[arg(long, required_unless_present = "cid")] + nonce: Option, + /// CID of the message to replace (alternative to `--from`/`--nonce`). + #[arg(long, conflicts_with_all = ["from", "nonce"])] + cid: Option, + /// Automatically re-estimate gas, ensuring the RBF minimum premium is met. + #[arg(long, conflicts_with_all = ["gas_premium", "gas_feecap", "gas_limit"])] + auto: bool, + /// Maximum total fee; only used with `--auto`. + #[arg(long, value_parser = humantoken::parse, alias = "fee-limit", requires = "auto")] + max_fee: Option, + /// Gas premium (required unless `--auto` is used). + #[arg(long, value_parser = humantoken::parse, required_unless_present = "auto")] + gas_premium: Option, + /// Gas fee cap (required unless `--auto` is used). + #[arg(long, value_parser = humantoken::parse, required_unless_present = "auto")] + gas_feecap: Option, + /// Gas limit (Optional; keeps original value if unset). + #[arg(long, conflicts_with = "auto")] + gas_limit: Option, + }, } fn filter_messages( @@ -69,6 +122,76 @@ fn filter_messages( Ok(filtered) } +fn auto_fill_range( + addr: Address, + next_on_chain_nonce: u64, + pending: &[SignedMessage], +) -> Option> { + let pending_nonce = pending + .iter() + .filter(|m| m.from() == addr) + .map(|m| m.sequence()) + .filter(|&seq| seq >= next_on_chain_nonce) + .min()?; + + if pending_nonce == next_on_chain_nonce { + return None; + } + + Some(next_on_chain_nonce..pending_nonce) +} + +fn manual_fill_range(start: Option, end: Option) -> anyhow::Result> { + let start = start.context("manual mode requires --start")?; + let end = end.context("manual mode requires --end")?; + anyhow::ensure!(end > start, "--end must be greater than --start"); + Ok(start..end) +} + +fn get_gas_fee_cap(gas_fee_cap: Option, parent_base_fee: TokenAmount) -> TokenAmount { + gas_fee_cap.unwrap_or_else(|| parent_base_fee * 2u64) +} + +fn find_pending_message( + from: Address, + nonce: u64, + pending: &[SignedMessage], +) -> anyhow::Result { + pending + .iter() + .find(|m| m.from() == from && m.sequence() == nonce) + .cloned() + .with_context(|| format!("no pending message found from {from} with nonce {nonce}")) +} + +fn auto_compute_replacement_gas( + mut estimated_msg: Message, + original_premium: TokenAmount, +) -> anyhow::Result { + let min_premium = compute_rbf(&original_premium, REPLACE_BY_FEE_RATIO_DEFAULT); + if estimated_msg.gas_premium < min_premium { + estimated_msg.gas_premium = min_premium; + } + if estimated_msg.gas_fee_cap < estimated_msg.gas_premium { + estimated_msg.gas_fee_cap = estimated_msg.gas_premium.clone(); + } + Ok(estimated_msg) +} + +fn manual_compute_replacement_gas( + gas_premium: TokenAmount, + gas_feecap: TokenAmount, + gas_limit: Option, + mut original_msg: Message, +) -> anyhow::Result { + original_msg.gas_premium = gas_premium; + original_msg.gas_fee_cap = gas_feecap; + if let Some(limit) = gas_limit { + original_msg.gas_limit = limit; + } + Ok(original_msg) +} + async fn get_actor_sequence( message: &Message, tipset: &Tipset, @@ -273,6 +396,136 @@ impl MpoolCommands { let nonce = MpoolGetNonce::call(&client, (address.into(),)).await?; println!("{nonce}"); + Ok(()) + } + Self::NonceFix { + addr, + auto, + start, + end, + gas_fee_cap, + } => { + let addr: Address = addr.into(); + + let fill_range = if auto { + let (actor, NotNullVec(pending)) = tokio::try_join!( + StateGetActor::call(&client, (addr, ApiTipsetKey(None))), + MpoolPending::call(&client, (ApiTipsetKey(None),)), + )?; + let next_nonce = actor + .with_context(|| format!("no on-chain actor found for {addr}"))? + .sequence; + auto_fill_range(addr, next_nonce, &pending) + } else { + Some(manual_fill_range(start, end)?) + }; + + let Some(fill_range) = fill_range else { + println!("No nonce gap found"); + return Ok(()); + }; + + let tipset = ChainHead::call(&client, ()).await?; + let parent_base_fee = tipset.block_headers().first().parent_base_fee.clone(); + let fee_cap = get_gas_fee_cap(gas_fee_cap, parent_base_fee); + let n = fill_range.end.saturating_sub(fill_range.start); + println!( + "Creating {n} filler messages ({} ~ {})", + fill_range.start, fill_range.end + ); + + for sequence in fill_range { + let msg = Message { + version: 0, + from: addr, + to: addr, + sequence, + value: TokenAmount::default(), + method_num: METHOD_SEND, + params: RawBytes::new(vec![]), + gas_limit: 1_000_000, + gas_fee_cap: fee_cap.clone(), + gas_premium: TokenAmount::from_atto(5u64), + }; + let smsg = WalletSignMessage::call(&client, (addr, msg)).await?; + MpoolPush::call(&client, (smsg,)).await?; + } + + Ok(()) + } + Self::Replace { + from, + nonce, + cid, + auto, + max_fee, + gas_premium, + gas_feecap, + gas_limit, + } => { + let (sender, sequence) = if let Some(msg_cid) = cid { + let api_msg = ChainGetMessage::call(&client, (msg_cid,)).await?; + (api_msg.from, api_msg.sequence) + } else { + let sender: Address = from + .context("--from is required when --cid is not provided")? + .into(); + let seq = nonce.context("--nonce is required when --cid is not provided")?; + (sender, seq) + }; + + let tipset = ChainHead::call(&client, ()).await?; + let tsk = ApiTipsetKey(Some(tipset.key().clone())); + + let NotNullVec(pending) = MpoolPending::call(&client, (tsk,)).await?; + let found = find_pending_message(sender, sequence, &pending)?; + let original_msg = found.into_message(); + + let msg_send_spec = Some(MessageSendSpec { + max_fee: max_fee.unwrap_or_default(), + msg_uuid: uuid::Uuid::nil(), + maximize_fee_cap: false, + }); + + let replacement = if auto { + let mut msg_for_estimate = original_msg.clone(); + // Keep the original gas limit when replacing a pending message. + // Re-estimating it would simulate against the message being replaced. + // See + msg_for_estimate.gas_fee_cap = TokenAmount::default(); + msg_for_estimate.gas_premium = TokenAmount::default(); + + let estimated_msg = GasEstimateMessageGas::call( + &client, + (msg_for_estimate, msg_send_spec.clone(), ApiTipsetKey(None)), + ) + .await?; + + let mut replacement = + auto_compute_replacement_gas(estimated_msg, original_msg.gas_premium)?; + cap_gas_fee( + &FeeConfig::default().max_fee, + &mut replacement, + msg_send_spec, + )?; + replacement + } else { + let gas_premium = + gas_premium.context("--gas-premium is required unless --auto is set")?; + let gas_feecap = + gas_feecap.context("--gas-feecap is required unless --auto is set")?; + manual_compute_replacement_gas( + gas_premium, + gas_feecap, + gas_limit, + original_msg, + )? + }; + + let smsg = WalletSignMessage::call(&client, (sender, replacement)).await?; + let new_cid = MpoolPush::call(&client, (smsg,)).await?; + println!("new message cid: {new_cid}"); + Ok(()) } } @@ -286,6 +539,7 @@ mod tests { use crate::message_pool::tests::create_smsg; use crate::shim::crypto::SignatureType; use itertools::Itertools as _; + use rstest::rstest; use std::borrow::BorrowMut; #[test] @@ -422,6 +676,122 @@ mod tests { } } + struct TestAddrs { + addr: Address, + target: Address, + other: Address, + } + + fn test_wallet() -> (Wallet, TestAddrs) { + let keystore = KeyStore::new(KeyStoreConfig::Memory).unwrap(); + let mut wallet = Wallet::new(keystore); + let addr = wallet.generate_addr(SignatureType::Secp256k1).unwrap(); + let target = wallet.generate_addr(SignatureType::Secp256k1).unwrap(); + let other = wallet.generate_addr(SignatureType::Secp256k1).unwrap(); + ( + wallet, + TestAddrs { + addr, + target, + other, + }, + ) + } + + fn pending_from( + wallet: &mut Wallet, + target: &Address, + from: &Address, + nonces: &[u64], + ) -> Vec { + nonces + .iter() + .map(|&nonce| create_smsg(target, from, wallet.borrow_mut(), nonce, 1_000_000, 1)) + .collect() + } + + fn make_test_message( + from: Address, + to: Address, + nonce: u64, + gas_limit: u64, + gas_premium: u64, + gas_fee_cap: u64, + ) -> Message { + Message { + version: 0, + from, + to, + sequence: nonce, + value: TokenAmount::default(), + method_num: METHOD_SEND, + params: RawBytes::new(vec![]), + gas_limit, + gas_fee_cap: TokenAmount::from_atto(gas_fee_cap), + gas_premium: TokenAmount::from_atto(gas_premium), + } + } + + #[rstest] + #[case::empty_pool(0, &[], None, None)] + #[case::wrong_sender(5, &[], Some(10), None)] + #[case::gap(5, &[7], None, Some(5..7))] + #[case::min_pending_nonce(5, &[10, 8], None, Some(5..8))] + #[case::next_nonce_in_mpool(5, &[5], None, None)] + #[case::ignores_stale_pending(5, &[3, 9], None, Some(5..9))] + #[case::only_stale_pending(5, &[3], None, None)] + fn nonce_fix_fill_range_auto( + #[case] next_on_chain: u64, + #[case] addr_nonces: &[u64], + #[case] other_sender_nonce: Option, + #[case] expected: Option>, + ) { + let (mut wallet, addrs) = test_wallet(); + let mut pending = pending_from(&mut wallet, &addrs.target, &addrs.addr, addr_nonces); + if let Some(nonce) = other_sender_nonce { + pending.push(create_smsg( + &addrs.target, + &addrs.other, + wallet.borrow_mut(), + nonce, + 1_000_000, + 1, + )); + } + assert_eq!( + auto_fill_range(addrs.addr, next_on_chain, &pending), + expected + ); + } + + #[rstest] + #[case::missing_start(None, Some(10), Err("manual mode requires --start"))] + #[case::missing_end(Some(1), None, Err("manual mode requires --end"))] + #[case::equal(Some(5), Some(5), Err("--end must be greater than --start"))] + #[case::reversed(Some(5), Some(3), Err("--end must be greater than --start"))] + #[case::ok(Some(2), Some(5), Ok(2..5))] + fn nonce_fix_fill_range_manual( + #[case] start: Option, + #[case] end: Option, + #[case] expected: Result, &str>, + ) { + let got = manual_fill_range(start, end); + match expected { + Ok(want) => assert_eq!(got.unwrap(), want), + Err(want) => assert!(got.unwrap_err().to_string().contains(want)), + } + } + + #[test] + fn nonce_fix_gas_fee_cap() { + let parent = TokenAmount::from_atto(100u64); + assert_eq!(get_gas_fee_cap(None, parent.clone()), parent.clone() * 2u64); + assert_eq!( + get_gas_fee_cap(Some(TokenAmount::from_atto(42u64)), parent), + TokenAmount::from_atto(42u64) + ); + } + #[test] fn compute_statistics() { use crate::shim::message::Message; @@ -497,4 +867,112 @@ mod tests { assert_eq!(stats, expected); } + + #[test] + fn find_pending_message_lookup() { + let (mut wallet, addrs) = test_wallet(); + let pending = pending_from(&mut wallet, &addrs.target, &addrs.addr, &[5]); + + let found = find_pending_message(addrs.addr, 5, &pending).unwrap(); + assert_eq!(found.cid(), pending[0].cid()); + + for (from, nonce) in [(addrs.addr, 99), (addrs.other, 5)] { + let err = find_pending_message(from, nonce, &pending).unwrap_err(); + assert!( + err.to_string().contains("no pending message found"), + "{err}" + ); + } + + let err = find_pending_message(addrs.addr, 5, &[]).unwrap_err(); + assert!( + err.to_string().contains("no pending message found"), + "{err}" + ); + } + + #[test] + fn test_auto_compute_replacement_gas() { + let (_wallet, addrs) = test_wallet(); + let addr = addrs.addr; + let target = addrs.target; + + // Above RBF floor: estimated premium kept. + let original_premium = TokenAmount::from_atto(100u64); + let floor = compute_rbf(&original_premium, REPLACE_BY_FEE_RATIO_DEFAULT); + let estimated = make_test_message(addr, target, 5, 2_000_000, 200, 500); + assert!(estimated.gas_premium > floor); + let result = + auto_compute_replacement_gas(estimated.clone(), original_premium.clone()).unwrap(); + assert_eq!(result.gas_premium, estimated.gas_premium); + + // Below RBF floor: premium bumped, fee cap >= premium. + let original_premium = TokenAmount::from_atto(1000u64); + let floor = compute_rbf(&original_premium, REPLACE_BY_FEE_RATIO_DEFAULT); + let estimated = make_test_message(addr, target, 5, 2_000_000, 50, 500); + assert!(estimated.gas_premium < floor); + let result = auto_compute_replacement_gas(estimated, original_premium.clone()).unwrap(); + assert_eq!(result.gas_premium, floor); + assert!(result.gas_fee_cap >= result.gas_premium); + + // Exactly at floor: unchanged. + let original_premium = TokenAmount::from_atto(100u64); + let floor = compute_rbf(&original_premium, REPLACE_BY_FEE_RATIO_DEFAULT); + let mut estimated = make_test_message(addr, target, 5, 2_000_000, 0, 500); + estimated.gas_premium = floor.clone(); + estimated.gas_fee_cap = floor.clone(); + let result = auto_compute_replacement_gas(estimated, original_premium.clone()).unwrap(); + assert_eq!(result.gas_premium, floor); + assert_eq!(result.gas_fee_cap, floor); + + // Fee cap raised when below bumped premium. + let original_premium = TokenAmount::from_atto(1000u64); + let floor = compute_rbf(&original_premium, REPLACE_BY_FEE_RATIO_DEFAULT); + let mut estimated = make_test_message(addr, target, 5, 2_000_000, 50, 10); + estimated.gas_premium = floor.clone(); + let result = auto_compute_replacement_gas(estimated, original_premium).unwrap(); + assert_eq!(result.gas_premium, floor); + assert_eq!(result.gas_fee_cap, floor); + + // cap_gas_fee after RBF bump. + let original_premium = TokenAmount::from_atto(1_000_000u64); + let mut estimated = make_test_message(addr, target, 5, 2_000_000, 50, 10_000_000_000); + estimated.gas_premium = TokenAmount::from_atto(50u64); + let mut replacement = auto_compute_replacement_gas(estimated, original_premium).unwrap(); + let max_fee = TokenAmount::from_atto(1_000_000u64); + cap_gas_fee(&max_fee, &mut replacement, None).unwrap(); + let total_fee = replacement.gas_fee_cap.clone() * replacement.gas_limit; + assert!(total_fee <= max_fee); + assert!(replacement.gas_premium <= replacement.gas_fee_cap); + } + + #[test] + fn test_manual_compute_replacement_gas() { + let (_wallet, addrs) = test_wallet(); + let addr = addrs.addr; + let target = addrs.target; + + let original = make_test_message(addr, target, 5, 1_000_000, 100, 300); + let result = manual_compute_replacement_gas( + TokenAmount::from_atto(200u64), + TokenAmount::from_atto(600u64), + None, + original.clone(), + ) + .unwrap(); + assert_eq!(result.gas_premium, TokenAmount::from_atto(200u64)); + assert_eq!(result.gas_fee_cap, TokenAmount::from_atto(600u64)); + assert_eq!(result.gas_limit, original.gas_limit); + + let original = make_test_message(addr, target, 5, 1_000_000, 100, 300); + let min_premium = compute_rbf(&original.gas_premium, REPLACE_BY_FEE_RATIO_DEFAULT); + let result = manual_compute_replacement_gas( + min_premium, + TokenAmount::from_atto(300u64), + Some(5_000_000), + original, + ) + .unwrap(); + assert_eq!(result.gas_limit, 5_000_000); + } } diff --git a/src/message_pool/config.rs b/src/message_pool/config.rs index c0211a8631f7..8f635b1aaa3d 100644 --- a/src/message_pool/config.rs +++ b/src/message_pool/config.rs @@ -13,8 +13,8 @@ use serde::{Deserialize, Serialize}; const SIZE_LIMIT_LOW: i64 = 20000; const SIZE_LIMIT_HIGH: i64 = 30000; const PRUNE_COOLDOWN: Duration = Duration::from_secs(60); // 1 minute -const REPLACE_BY_FEE_RATIO: f64 = 1.25; const GAS_LIMIT_OVERESTIMATION: f64 = 1.25; +pub const REPLACE_BY_FEE_RATIO_DEFAULT: u64 = 125; /// Configuration available for the [`crate::message_pool::MessagePool`]. /// @@ -24,7 +24,7 @@ pub struct MpoolConfig { pub priority_addrs: Vec
, pub size_limit_high: i64, pub size_limit_low: i64, - pub replace_by_fee_ratio: f64, + pub replace_by_fee_ratio: u64, pub prune_cooldown: Duration, pub gas_limit_overestimation: f64, } @@ -35,7 +35,7 @@ impl Default for MpoolConfig { priority_addrs: vec![], size_limit_high: SIZE_LIMIT_HIGH, size_limit_low: SIZE_LIMIT_LOW, - replace_by_fee_ratio: REPLACE_BY_FEE_RATIO, + replace_by_fee_ratio: REPLACE_BY_FEE_RATIO_DEFAULT, prune_cooldown: PRUNE_COOLDOWN, gas_limit_overestimation: GAS_LIMIT_OVERESTIMATION, } diff --git a/src/message_pool/mod.rs b/src/message_pool/mod.rs index b185834eb01a..58c7c5fb51ee 100644 --- a/src/message_pool/mod.rs +++ b/src/message_pool/mod.rs @@ -8,12 +8,13 @@ mod msg_chain; mod msgpool; mod nonce_tracker; -pub use self::{ +pub(crate) use self::{ config::*, errors::*, mpool_locker::MpoolLocker, msgpool::{msg_pool::MessagePool, *}, nonce_tracker::NonceTracker, + utils::compute_rbf, }; pub use block_prob::block_probabilities; diff --git a/src/message_pool/msgpool/mod.rs b/src/message_pool/msgpool/mod.rs index ca39ac696fe4..2ee1d3617826 100644 --- a/src/message_pool/msgpool/mod.rs +++ b/src/message_pool/msgpool/mod.rs @@ -20,9 +20,8 @@ pub use events::MpoolUpdate; pub(in crate::message_pool) use utils::recover_sig; -const REPLACE_BY_FEE_RATIO: f32 = 1.25; -const RBF_NUM: u64 = ((REPLACE_BY_FEE_RATIO - 1f32) * 256f32) as u64; -const RBF_DENOM: u64 = 256; +const REPLACE_BY_FEE_RATIO_MIN: u64 = 110; +const RBF_DENOM: u64 = 100; const BASE_FEE_LOWER_BOUND_FACTOR_CONSERVATIVE: i64 = 100; const MIN_GAS: u64 = 1298450; diff --git a/src/message_pool/msgpool/msg_set.rs b/src/message_pool/msgpool/msg_set.rs index 0eae120e03fc..c56dc95b42bb 100644 --- a/src/message_pool/msgpool/msg_set.rs +++ b/src/message_pool/msgpool/msg_set.rs @@ -12,8 +12,7 @@ use crate::message::{MessageRead, SignedMessage}; use crate::message_pool::errors::Error; use crate::message_pool::metrics; use crate::message_pool::msg_pool::TrustPolicy; -use crate::message_pool::msgpool::{RBF_DENOM, RBF_NUM}; -use crate::shim::econ::TokenAmount; +use crate::message_pool::msgpool::utils::compute_rbf_min_premium; /// Maximum allowed nonce gap for trusted message inserts under [`StrictnessPolicy::Strict`]. pub(in crate::message_pool) const MAX_NONCE_GAP: u64 = 4; @@ -116,10 +115,8 @@ impl MsgSet { } if m.cid() != exms.cid() { let premium = &exms.message().gas_premium; - let min_price = premium.clone() - + ((premium * RBF_NUM).div_floor(RBF_DENOM)) - + TokenAmount::from_atto(1u8); - if m.message().gas_premium <= min_price { + let min_price = compute_rbf_min_premium(premium); + if m.message().gas_premium < min_price { return Err(Error::GasPriceTooLow); } } else { diff --git a/src/message_pool/msgpool/utils.rs b/src/message_pool/msgpool/utils.rs index fc8b4e0b14ba..599866f11d20 100644 --- a/src/message_pool/msgpool/utils.rs +++ b/src/message_pool/msgpool/utils.rs @@ -3,7 +3,10 @@ use crate::chain::MINIMUM_BASE_FEE; use crate::message::{MessageRead as _, SignedMessage}; -use crate::message_pool::Error; +use crate::message_pool::{ + Error, + msgpool::{RBF_DENOM, REPLACE_BY_FEE_RATIO_MIN}, +}; use crate::shim::address::Address; use crate::shim::{crypto::Signature, econ::TokenAmount, message::Message}; use crate::utils::cache::SizeTrackingCache; @@ -63,3 +66,41 @@ pub(in crate::message_pool) fn add_to_selected_msgs( ) { rmsgs.entry(m.from()).or_default().insert(m.sequence(), m); } + +/// Computes the minimum gas premium required to replace an existing message +/// using [`REPLACE_BY_FEE_RATIO_MIN`]. +/// +/// See +pub(crate) fn compute_rbf_min_premium(premium: &TokenAmount) -> TokenAmount { + (premium * REPLACE_BY_FEE_RATIO_MIN).div_floor(RBF_DENOM) + TokenAmount::from_atto(1u8) +} + +/// Computes the gas premium required to replace an existing message +/// using provided replace-by-fee ratio. +/// +/// See +pub(crate) fn compute_rbf(premium: &TokenAmount, replace_by_fee_ratio: u64) -> TokenAmount { + (premium * replace_by_fee_ratio).div_floor(RBF_DENOM) + TokenAmount::from_atto(1u8) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_rbf() { + let replace_by_fee_ratio = 125; + assert_eq!( + super::compute_rbf(&TokenAmount::from_atto(100u64), replace_by_fee_ratio), + TokenAmount::from_atto(126u64) // 100 * 125/100 + 1 + ); + } + + #[test] + fn test_compute_rbf_min_premium() { + assert_eq!( + super::compute_rbf_min_premium(&TokenAmount::from_atto(100u64)), + TokenAmount::from_atto(111u64) // 100 * 110/100 + 1 + ); + } +} diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs index ec507edc55a3..58cbef49fcc4 100644 --- a/src/rpc/methods/gas.rs +++ b/src/rpc/methods/gas.rs @@ -331,7 +331,7 @@ pub async fn estimate_message_gas( /// 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( +pub(crate) fn cap_gas_fee( default_max_fee: &TokenAmount, msg: &mut Message, msg_spec: Option, diff --git a/tests/calibnet_mpool_tools.rs b/tests/calibnet_mpool_tools.rs new file mode 100644 index 000000000000..2c54f4101676 --- /dev/null +++ b/tests/calibnet_mpool_tools.rs @@ -0,0 +1,67 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Calibnet mpool CLI integration tests (shared preloaded address). +//! +//! Run via [`calibnet_wallet_mpool`] before [`calibnet_wallet`]; see `mise test:wallet`. +//! Each test assumes the same environment as [`calibnet_wallet`]. + +#[path = "common/calibnet_wallet_helpers.rs"] +mod helpers; + +use helpers::*; +use serial_test::serial; + +#[tokio::test] +#[serial] +async fn mpool_nonce_fix_auto_unblocks_pending() { + let addr = FOREST_TEST_PRELOADED_ADDRESS.as_str(); + let nonce = mpool_nonce(addr).unwrap(); + // Skip one nonce so `--auto` has a gap to fill. + let next_nonce = nonce + 1; + forest_cli(&[ + "mpool", + "nonce-fix", + "--addr", + addr, + "--start", + &next_nonce.to_string(), + "--end", + &(next_nonce + 1).to_string(), + ]) + .unwrap(); + poll_until_pending_nonce(addr, next_nonce).await.unwrap(); + + forest_cli(&["mpool", "nonce-fix", "--addr", addr, "--auto"]).unwrap(); + + assert!( + poll_until_pending_nonce(addr, nonce).await.is_ok(), + "nonce-fix --auto should fill nonce gap at {nonce} for {addr}." + ); +} + +#[tokio::test] +#[serial] +async fn mpool_replace_auto_unblocks_pending() { + let addr = FOREST_TEST_PRELOADED_ADDRESS.as_str(); + let nonce = mpool_nonce(addr).unwrap(); + + let cid = send_from(addr, addr, FIL_AMT, Backend::Local).unwrap(); + poll_until_pending_nonce(addr, nonce).await.unwrap(); + + forest_cli(&[ + "mpool", + "replace", + "--from", + addr, + "--nonce", + &nonce.to_string(), + "--auto", + ]) + .unwrap(); + + assert!( + poll_until_state_search_msg(&cid).await.is_ok(), + "mpool replace --auto should replace message {cid} from {addr} at nonce {nonce}." + ); +} diff --git a/tests/calibnet_wallet.rs b/tests/calibnet_wallet.rs index d78f9c2cc6db..27be9ce6861e 100644 --- a/tests/calibnet_wallet.rs +++ b/tests/calibnet_wallet.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0, MIT //! Calibnet wallet integration tests. Each test assumes: -//! - `forest-wallet` is on `PATH`, +//! - `forest-wallet` and `forest-cli` are on `PATH`, //! - a Forest daemon is running and synced to calibnet, //! - [`FOREST_TEST_PRELOADED_ADDRESS`] is funded and imported into both backends (env var of the same name; see `forest_wallet_init`), //! - `FULLNODE_API_INFO` is exported. diff --git a/tests/common/calibnet_wallet_helpers.rs b/tests/common/calibnet_wallet_helpers.rs index 3baf4ec44055..b071153e8450 100644 --- a/tests/common/calibnet_wallet_helpers.rs +++ b/tests/common/calibnet_wallet_helpers.rs @@ -167,16 +167,6 @@ where } } -/// Poll until the balance reported for `address` is no longer [`FIL_ZERO`]. -pub async fn poll_until_funded(address: &str, backend: Backend) -> anyhow::Result { - let label = format!("{} balance for {address}", backend.label()); - poll(&label, || async { - let bal = balance(address, backend)?; - Ok((bal != FIL_ZERO).then_some(bal)) - }) - .await -} - /// Poll until the balance reported for `address` differs from `baseline`. pub async fn poll_until_changed( address: &str, @@ -192,6 +182,11 @@ pub async fn poll_until_changed( .await } +/// Poll until the balance reported for `address` is no longer [`FIL_ZERO`]. +pub async fn poll_until_funded(address: &str, backend: Backend) -> anyhow::Result { + poll_until_changed(address, FIL_ZERO, backend).await +} + static FUNDED_DELEGATED: OnceCell = OnceCell::const_new(); /// Delegated signer: create once on local, fund locally, mirror to remote @@ -320,6 +315,56 @@ pub async fn poll_until_state_search_msg(msg_cid: &str) -> anyhow::Result<()> { .await } +/// Run `forest-cli ` and return trimmed stdout. +pub fn forest_cli(args: &[&str]) -> anyhow::Result { + let output = Command::new("forest-cli") + .args(args) + .output() + .context("failed to spawn `forest-cli`")?; + if !output.status.success() { + bail!( + "`forest-cli {}` failed (status={}): {}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8(output.stdout)?.trim().to_string()) +} + +/// Next nonce for an address +pub fn mpool_nonce(address: &str) -> anyhow::Result { + let out = forest_cli(&["mpool", "nonce", address])?; + out.parse::() + .with_context(|| format!("invalid mpool nonce output: {out}")) +} + +/// Pending message nonces for `address` via `Filecoin.MpoolPending`. +pub async fn pending_nonces_for(address: &str) -> anyhow::Result> { + let result = rpc_call("Filecoin.MpoolPending", json!([null])).await?; + let entries = result + .as_array() + .with_context(|| format!("expected MpoolPending array, got {result}"))?; + Ok(entries + .iter() + .filter_map(|entry| { + let msg = entry.get("Message")?; + (msg.get("From")?.as_str()? == address).then_some(msg.get("Nonce")?.as_u64()?) + }) + .collect()) +} + +/// Poll until `address` has a pending message at `nonce`. +pub async fn poll_until_pending_nonce(address: &str, nonce: u64) -> anyhow::Result<()> { + let label = format!("pending nonce {nonce} for {address}"); + let address = address.to_string(); + poll(&label, || async { + let nonces = pending_nonces_for(&address).await?; + Ok(nonces.contains(&nonce).then_some(())) + }) + .await +} + /// Resolve the ETH equivalent of a Filecoin address via /// `Filecoin.FilecoinAddressToEthAddress`. pub async fn filecoin_to_eth(address: &str) -> anyhow::Result {