From 21f1a0eb9635567792665c38f89114d091efc09b Mon Sep 17 00:00:00 2001 From: Shashank Date: Wed, 1 Apr 2026 10:00:28 +0530 Subject: [PATCH 1/7] Impl nonce fix cmd --- docs/docs/users/reference/cli.md | 25 ++- src/cli/subcommands/mpool_cmd.rs | 294 ++++++++++++++++++++++++++++++- 2 files changed, 314 insertions(+), 5 deletions(-) diff --git a/docs/docs/users/reference/cli.md b/docs/docs/users/reference/cli.md index 294d478e329c..adee7db5569c 100644 --- a/docs/docs/users/reference/cli.md +++ b/docs/docs/users/reference/cli.md @@ -617,10 +617,11 @@ 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 + help Print this message or the help of the given subcommand(s) Options: -h, --help Print help @@ -671,6 +672,22 @@ 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 --addr [OPTIONS] + +Options: + --addr Address to fill nonces for (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, in `attoFIL`. Default: twice the parent base fee from chain head + -h, --help Print help +``` + ### `forest-cli state` ``` diff --git a/src/cli/subcommands/mpool_cmd.rs b/src/cli/subcommands/mpool_cmd.rs index c83a814c2cbd..ab2c68d11149 100644 --- a/src/cli/subcommands/mpool_cmd.rs +++ b/src/cli/subcommands/mpool_cmd.rs @@ -6,12 +6,15 @@ use crate::lotus_json::{HasLotusJson as _, NotNullVec}; use crate::message::{MessageRead as _, SignedMessage}; use crate::rpc::{self, prelude::*, types::ApiTipsetKey}; 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 clap::Subcommand; +use fvm_ipld_encoding::RawBytes; use num::BigInt; +use std::ops::Range; #[derive(Debug, Subcommand)] pub enum MpoolCommands { @@ -44,6 +47,24 @@ pub enum MpoolCommands { #[arg(long)] local: bool, }, + /// Fill an on-chain nonce gap by pushing signed self-transfer messages. + NonceFix { + /// Address to fill nonces for (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)] + auto: bool, + /// First sequence to fill (inclusive); required unless `--auto`. + #[arg(long)] + start: Option, + /// End of range (exclusive); required unless `--auto`. + #[arg(long)] + end: Option, + /// Gas fee cap for filler messages, in `attoFIL`. Default: twice the parent base fee from chain head. + #[arg(long)] + gas_fee_cap: Option, + }, } fn filter_messages( @@ -69,6 +90,62 @@ fn filter_messages( Ok(filtered) } +enum NonceFixFillRangeInput { + Auto { + addr: Address, + next_on_chain_nonce: u64, + pending: Vec, + }, + Manual { + start: Option, + end: Option, + }, +} + +fn get_nonce_fix_fill_range(input: NonceFixFillRangeInput) -> anyhow::Result>> { + match input { + NonceFixFillRangeInput::Auto { + addr, + next_on_chain_nonce, + pending, + } => { + let Some(pending_nonce) = pending + .iter() + .filter(|m| m.from() == addr) + .map(|m| m.sequence()) + .filter(|&seq| seq >= next_on_chain_nonce) + .min() + else { + return Ok(None); + }; + if pending_nonce == next_on_chain_nonce { + return Ok(None); + } + Ok(Some(next_on_chain_nonce..pending_nonce)) + } + NonceFixFillRangeInput::Manual { start, end } => { + 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(Some(start..end)) + } + } +} + +fn get_nonce_fix_gas_fee_cap( + gas_fee_cap: Option<&str>, + parent_base_fee: TokenAmount, +) -> anyhow::Result { + if let Some(cap) = gas_fee_cap { + Ok(TokenAmount::from_atto( + cap.parse::() + .context("invalid --gas-fee-cap value")?, + )) + } else { + Ok(parent_base_fee * 2u64) + } +} + async fn get_actor_sequence( message: &Message, tipset: &Tipset, @@ -273,6 +350,64 @@ 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 = StateGetActor::call(&client, (addr, ApiTipsetKey(None))) + .await? + .with_context(|| format!("no on-chain actor found for {addr}"))?; + let next_nonce = actor.sequence; + let NotNullVec(pending) = + MpoolPending::call(&client, (ApiTipsetKey(None),)).await?; + get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { + addr, + next_on_chain_nonce: next_nonce, + pending, + })? + } else { + get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { start, end })? + }; + + let Some(fill_range) = fill_range else { + println!("No nonce gap found or no --end flag specified"); + return Ok(()); + }; + + let tipset = ChainHead::call(&client, ()).await?; + let parent_base_fee = tipset.block_headers().first().parent_base_fee.clone(); + let fee_cap = get_nonce_fix_gas_fee_cap(gas_fee_cap.as_deref(), 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(()) } } @@ -422,6 +557,163 @@ mod tests { } } + #[test] + fn nonce_fix_auto_no_pending() { + let keystore = KeyStore::new(KeyStoreConfig::Memory).unwrap(); + let mut wallet = Wallet::new(keystore); + let addr = wallet.generate_addr(SignatureType::Secp256k1).unwrap(); + let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { + addr, + next_on_chain_nonce: 0, + pending: vec![], + }) + .unwrap(); + assert_eq!(r, None); + } + + #[test] + fn nonce_fix_auto_other_sender() { + let keystore = KeyStore::new(KeyStoreConfig::Memory).unwrap(); + let mut wallet = Wallet::new(keystore); + let addr = wallet.generate_addr(SignatureType::Secp256k1).unwrap(); + let other = wallet.generate_addr(SignatureType::Secp256k1).unwrap(); + let target = wallet.generate_addr(SignatureType::Secp256k1).unwrap(); + let m = create_smsg(&target, &other, wallet.borrow_mut(), 10, 1000000, 1); + let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { + addr, + next_on_chain_nonce: 5, + pending: vec![m], + }) + .unwrap(); + assert_eq!(r, None); + } + + #[test] + fn nonce_fix_auto_fill_range_gap() { + 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 m = create_smsg(&target, &addr, wallet.borrow_mut(), 7, 1000000, 1); + let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { + addr, + next_on_chain_nonce: 5, + pending: vec![m], + }) + .unwrap(); + assert_eq!(r, Some(5..7)); + } + + #[test] + fn nonce_fix_auto_fill_range_min_pending_nonce() { + 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 m10 = create_smsg(&target, &addr, wallet.borrow_mut(), 10, 1000000, 1); + let m8 = create_smsg(&target, &addr, wallet.borrow_mut(), 8, 1000000, 1); + let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { + addr, + next_on_chain_nonce: 5, + pending: vec![m10, m8], + }) + .unwrap(); + assert_eq!(r, Some(5..8)); + } + + #[test] + fn nonce_fix_auto_next_nonce_exist_in_mpool() { + 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 m = create_smsg(&target, &addr, wallet.borrow_mut(), 5, 1000000, 1); + let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { + addr, + next_on_chain_nonce: 5, + pending: vec![m], + }) + .unwrap(); + assert_eq!(r, None); + } + + #[test] + fn nonce_fix_manual_fill_range_missing_start() { + let e = get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { + start: None, + end: Some(10), + }) + .unwrap_err(); + assert!( + e.to_string().contains("manual mode requires --start"), + "{e}" + ); + } + + #[test] + fn nonce_fix_manual_fill_range_missing_end() { + let e = get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { + start: Some(1), + end: None, + }) + .unwrap_err(); + assert!(e.to_string().contains("manual mode requires --end"), "{e}"); + } + + #[test] + fn nonce_fix_invalid_fill_range() { + let e = get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { + start: Some(5), + end: Some(5), + }) + .unwrap_err(); + assert!( + e.to_string().contains("--end must be greater than --start"), + "{e}" + ); + + let e = get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { + start: Some(5), + end: Some(3), + }) + .unwrap_err(); + assert!( + e.to_string().contains("--end must be greater than --start"), + "{e}" + ); + } + + #[test] + fn nonce_fix_manual_fill_range() { + let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { + start: Some(2), + end: Some(5), + }) + .unwrap(); + assert_eq!(r, Some(2..5)); + } + + #[test] + fn nonce_fix_default_fee_cap() { + let parent = TokenAmount::from_atto(100u64); + let cap = get_nonce_fix_gas_fee_cap(None, parent.clone()).unwrap(); + assert_eq!(cap, parent * 2u64); + } + + #[test] + fn nonce_fix_explicit_fee_cap() { + let parent = TokenAmount::from_atto(999u64); + let cap = get_nonce_fix_gas_fee_cap(Some("42"), parent).unwrap(); + assert_eq!(cap, TokenAmount::from_atto(42u64)); + } + + #[test] + fn nonce_fix_invalid_fee_cap() { + let parent = TokenAmount::from_atto(1u64); + let e = get_nonce_fix_gas_fee_cap(Some("not-a-number"), parent).unwrap_err(); + assert!(e.to_string().contains("invalid --gas-fee-cap value"), "{e}"); + } + #[test] fn compute_statistics() { use crate::shim::message::Message; From 6f42a0d12cc3d70ca3014e91d509cb78151f2b55 Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 21 May 2026 01:55:36 +0530 Subject: [PATCH 2/7] Impl mpool replace cmd --- .config/forest.dic | 4 +- docs/docs/users/reference/cli.md | 26 +- src/cli/subcommands/mpool_cmd.rs | 611 +++++++++++++++++++++------- src/message_pool/mod.rs | 3 +- src/message_pool/msgpool/msg_set.rs | 9 +- src/message_pool/msgpool/utils.rs | 26 +- src/rpc/methods/gas.rs | 2 +- 7 files changed, 523 insertions(+), 158 deletions(-) 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/docs/docs/users/reference/cli.md b/docs/docs/users/reference/cli.md index f44905acae5e..5aad628fa948 100644 --- a/docs/docs/users/reference/cli.md +++ b/docs/docs/users/reference/cli.md @@ -621,6 +621,7 @@ Commands: 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: @@ -677,14 +678,33 @@ Options: ``` Fill an on-chain nonce gap by pushing signed self-transfer messages -Usage: forest-cli mpool nonce-fix --addr [OPTIONS] +Usage: forest-cli mpool nonce-fix [OPTIONS] --addr Options: - --addr Address to fill nonces for (must be signable by the node's wallet) + --addr Address to fill nonce's for (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, in `attoFIL`. Default: twice the parent base fee from chain head + --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 (manual mode) + --gas-feecap Gas fee cap (manual mode) + --gas-limit Gas limit (manual mode; keeps original value if unset) -h, --help Print help ``` diff --git a/src/cli/subcommands/mpool_cmd.rs b/src/cli/subcommands/mpool_cmd.rs index ab2c68d11149..0eacf1e8e11a 100644 --- a/src/cli/subcommands/mpool_cmd.rs +++ b/src/cli/subcommands/mpool_cmd.rs @@ -2,15 +2,20 @@ // 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::compute_rbf_min_premium; +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::{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; @@ -49,21 +54,48 @@ pub enum MpoolCommands { }, /// Fill an on-chain nonce gap by pushing signed self-transfer messages. NonceFix { - /// Address to fill nonces for (must be signable by the node's wallet). + /// Address to fill nonce's for (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)] + #[arg(long, conflicts_with_all = ["start", "end"])] auto: bool, /// First sequence to fill (inclusive); required unless `--auto`. - #[arg(long)] + #[arg(long, required_unless_present = "auto")] start: Option, /// End of range (exclusive); required unless `--auto`. - #[arg(long)] + #[arg(long, required_unless_present = "auto")] end: Option, - /// Gas fee cap for filler messages, in `attoFIL`. Default: twice the parent base fee from chain head. + /// 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)] - gas_fee_cap: Option, + 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 (manual mode). + #[arg(long, value_parser = humantoken::parse)] + gas_premium: Option, + /// Gas fee cap (manual mode). + #[arg(long, value_parser = humantoken::parse)] + gas_feecap: Option, + /// Gas limit (manual mode; keeps original value if unset). + #[arg(long)] + gas_limit: Option, }, } @@ -132,17 +164,69 @@ fn get_nonce_fix_fill_range(input: NonceFixFillRangeInput) -> anyhow::Result, - parent_base_fee: TokenAmount, -) -> anyhow::Result { - if let Some(cap) = gas_fee_cap { - Ok(TokenAmount::from_atto( - cap.parse::() - .context("invalid --gas-fee-cap value")?, - )) - } else { - Ok(parent_base_fee * 2u64) +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}")) +} + +enum ReplaceGasInput { + Auto { + estimated_msg: Message, + original_premium: TokenAmount, + }, + Manual { + gas_premium: TokenAmount, + gas_feecap: TokenAmount, + gas_limit: Option, + original_msg: Message, + }, +} + +fn compute_replacement_gas(input: ReplaceGasInput) -> anyhow::Result { + match input { + ReplaceGasInput::Auto { + mut estimated_msg, + original_premium, + } => { + let min_premium = compute_rbf_min_premium(&original_premium); + 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) + } + ReplaceGasInput::Manual { + gas_premium, + gas_feecap, + gas_limit, + mut original_msg, + } => { + let min_premium = compute_rbf_min_premium(&original_msg.gas_premium); + if gas_premium < min_premium { + return Err(anyhow::anyhow!( + "gas premium is below the minimum required for RBF" + )); + } + 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) + } } } @@ -384,7 +468,7 @@ impl MpoolCommands { let tipset = ChainHead::call(&client, ()).await?; let parent_base_fee = tipset.block_headers().first().parent_base_fee.clone(); - let fee_cap = get_nonce_fix_gas_fee_cap(gas_fee_cap.as_deref(), parent_base_fee)?; + 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 ({} ~ {})", @@ -408,6 +492,81 @@ impl MpoolCommands { 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(); + msg_for_estimate.gas_limit = 0; + 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 = compute_replacement_gas(ReplaceGasInput::Auto { + estimated_msg, + original_premium: 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")?; + compute_replacement_gas(ReplaceGasInput::Manual { + 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(()) } } @@ -557,134 +716,164 @@ mod tests { } } - #[test] - fn nonce_fix_auto_no_pending() { - let keystore = KeyStore::new(KeyStoreConfig::Memory).unwrap(); - let mut wallet = Wallet::new(keystore); - let addr = wallet.generate_addr(SignatureType::Secp256k1).unwrap(); - let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { - addr, - next_on_chain_nonce: 0, - pending: vec![], - }) - .unwrap(); - assert_eq!(r, None); + struct TestAddrs { + addr: Address, + target: Address, + other: Address, } - #[test] - fn nonce_fix_auto_other_sender() { + 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 other = wallet.generate_addr(SignatureType::Secp256k1).unwrap(); let target = wallet.generate_addr(SignatureType::Secp256k1).unwrap(); - let m = create_smsg(&target, &other, wallet.borrow_mut(), 10, 1000000, 1); - let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { - addr, - next_on_chain_nonce: 5, - pending: vec![m], - }) - .unwrap(); - assert_eq!(r, None); + let other = wallet.generate_addr(SignatureType::Secp256k1).unwrap(); + ( + wallet, + TestAddrs { + addr, + target, + other, + }, + ) } - #[test] - fn nonce_fix_auto_fill_range_gap() { - 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 m = create_smsg(&target, &addr, wallet.borrow_mut(), 7, 1000000, 1); - let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { - addr, - next_on_chain_nonce: 5, - pending: vec![m], - }) - .unwrap(); - assert_eq!(r, Some(5..7)); + 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() } - #[test] - fn nonce_fix_auto_fill_range_min_pending_nonce() { - 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 m10 = create_smsg(&target, &addr, wallet.borrow_mut(), 10, 1000000, 1); - let m8 = create_smsg(&target, &addr, wallet.borrow_mut(), 8, 1000000, 1); - let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { - addr, - next_on_chain_nonce: 5, - pending: vec![m10, m8], - }) - .unwrap(); - assert_eq!(r, Some(5..8)); + 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), + } } #[test] - fn nonce_fix_auto_next_nonce_exist_in_mpool() { - 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 m = create_smsg(&target, &addr, wallet.borrow_mut(), 5, 1000000, 1); - let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { - addr, - next_on_chain_nonce: 5, - pending: vec![m], - }) - .unwrap(); - assert_eq!(r, None); - } + fn nonce_fix_fill_range_auto() { + struct Case { + name: &'static str, + next_on_chain: u64, + addr_nonces: &'static [u64], + other_sender_nonce: Option, + expected: Option>, + } - #[test] - fn nonce_fix_manual_fill_range_missing_start() { - let e = get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { - start: None, - end: Some(10), - }) - .unwrap_err(); - assert!( - e.to_string().contains("manual mode requires --start"), - "{e}" - ); - } + let cases = [ + Case { + name: "empty_pool", + next_on_chain: 0, + addr_nonces: &[], + other_sender_nonce: None, + expected: None, + }, + Case { + name: "wrong_sender", + next_on_chain: 5, + addr_nonces: &[], + other_sender_nonce: Some(10), + expected: None, + }, + Case { + name: "gap", + next_on_chain: 5, + addr_nonces: &[7], + other_sender_nonce: None, + expected: Some(5..7), + }, + Case { + name: "min_pending_nonce", + next_on_chain: 5, + addr_nonces: &[10, 8], + other_sender_nonce: None, + expected: Some(5..8), + }, + Case { + name: "next_nonce_in_mpool", + next_on_chain: 5, + addr_nonces: &[5], + other_sender_nonce: None, + expected: None, + }, + Case { + name: "ignores_stale_pending", + next_on_chain: 5, + addr_nonces: &[3, 9], + other_sender_nonce: None, + expected: Some(5..9), + }, + Case { + name: "only_stale_pending", + next_on_chain: 5, + addr_nonces: &[3], + other_sender_nonce: None, + expected: None, + }, + ]; - #[test] - fn nonce_fix_manual_fill_range_missing_end() { - let e = get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { - start: Some(1), - end: None, - }) - .unwrap_err(); - assert!(e.to_string().contains("manual mode requires --end"), "{e}"); + for case in cases { + let (mut wallet, addrs) = test_wallet(); + let mut pending = + pending_from(&mut wallet, &addrs.target, &addrs.addr, case.addr_nonces); + if let Some(nonce) = case.other_sender_nonce { + pending.push(create_smsg( + &addrs.target, + &addrs.other, + wallet.borrow_mut(), + nonce, + 1_000_000, + 1, + )); + } + let got = get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { + addr: addrs.addr, + next_on_chain_nonce: case.next_on_chain, + pending, + }) + .unwrap(); + assert_eq!(got, case.expected, "case {}", case.name); + } } #[test] - fn nonce_fix_invalid_fill_range() { - let e = get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { - start: Some(5), - end: Some(5), - }) - .unwrap_err(); - assert!( - e.to_string().contains("--end must be greater than --start"), - "{e}" - ); - - let e = get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { - start: Some(5), - end: Some(3), - }) - .unwrap_err(); - assert!( - e.to_string().contains("--end must be greater than --start"), - "{e}" - ); - } + fn nonce_fix_fill_range_manual() { + for (start, end, err) in [ + (None, Some(10), Some("manual mode requires --start")), + (Some(1), None, Some("manual mode requires --end")), + (Some(5), Some(5), Some("--end must be greater than --start")), + (Some(5), Some(3), Some("--end must be greater than --start")), + ] { + let e = get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { start, end }) + .unwrap_err(); + assert!( + e.to_string().contains(err.unwrap()), + "start={start:?} end={end:?}: {e}" + ); + } - #[test] - fn nonce_fix_manual_fill_range() { let r = get_nonce_fix_fill_range(NonceFixFillRangeInput::Manual { start: Some(2), end: Some(5), @@ -694,24 +883,13 @@ mod tests { } #[test] - fn nonce_fix_default_fee_cap() { + fn nonce_fix_gas_fee_cap() { let parent = TokenAmount::from_atto(100u64); - let cap = get_nonce_fix_gas_fee_cap(None, parent.clone()).unwrap(); - assert_eq!(cap, parent * 2u64); - } - - #[test] - fn nonce_fix_explicit_fee_cap() { - let parent = TokenAmount::from_atto(999u64); - let cap = get_nonce_fix_gas_fee_cap(Some("42"), parent).unwrap(); - assert_eq!(cap, TokenAmount::from_atto(42u64)); - } - - #[test] - fn nonce_fix_invalid_fee_cap() { - let parent = TokenAmount::from_atto(1u64); - let e = get_nonce_fix_gas_fee_cap(Some("not-a-number"), parent).unwrap_err(); - assert!(e.to_string().contains("invalid --gas-fee-cap value"), "{e}"); + 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] @@ -789,4 +967,147 @@ 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 compute_replacement_gas_auto() { + 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_min_premium(&original_premium); + let estimated = make_test_message(addr, target, 5, 2_000_000, 200, 500); + assert!(estimated.gas_premium > floor); + let result = compute_replacement_gas(ReplaceGasInput::Auto { + estimated_msg: estimated.clone(), + original_premium: 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_min_premium(&original_premium); + let estimated = make_test_message(addr, target, 5, 2_000_000, 50, 500); + assert!(estimated.gas_premium < floor); + let result = compute_replacement_gas(ReplaceGasInput::Auto { + estimated_msg: estimated, + original_premium: 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_min_premium(&original_premium); + 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 = compute_replacement_gas(ReplaceGasInput::Auto { + estimated_msg: estimated, + original_premium: 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_min_premium(&original_premium); + let mut estimated = make_test_message(addr, target, 5, 2_000_000, 50, 10); + estimated.gas_premium = floor.clone(); + let result = compute_replacement_gas(ReplaceGasInput::Auto { + estimated_msg: 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 = compute_replacement_gas(ReplaceGasInput::Auto { + estimated_msg: 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 compute_replacement_gas_manual() { + 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 = compute_replacement_gas(ReplaceGasInput::Manual { + gas_premium: TokenAmount::from_atto(200u64), + gas_feecap: TokenAmount::from_atto(600u64), + gas_limit: None, + original_msg: 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_min_premium(&original.gas_premium); + let result = compute_replacement_gas(ReplaceGasInput::Manual { + gas_premium: min_premium, + gas_feecap: TokenAmount::from_atto(300u64), + gas_limit: Some(5_000_000), + original_msg: original, + }) + .unwrap(); + assert_eq!(result.gas_limit, 5_000_000); + + let original = make_test_message(addr, target, 5, 1_000_000, 1000, 3000); + let min_premium = compute_rbf_min_premium(&original.gas_premium); + let below = min_premium - TokenAmount::from_atto(1u64); + let e = compute_replacement_gas(ReplaceGasInput::Manual { + gas_premium: below, + gas_feecap: TokenAmount::from_atto(5000u64), + gas_limit: None, + original_msg: original, + }) + .unwrap_err(); + assert!( + e.to_string() + .contains("gas premium is below the minimum required for RBF"), + "{e}" + ); + } } diff --git a/src/message_pool/mod.rs b/src/message_pool/mod.rs index b185834eb01a..b7280eb559cd 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_min_premium, }; pub use block_prob::block_probabilities; 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 ed6f7ee047e9..9d57d9690ee2 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, RBF_NUM}, +}; use crate::shim::address::Address; use crate::shim::{crypto::Signature, econ::TokenAmount, message::Message}; use crate::utils::cache::SizeTrackingCache; @@ -63,3 +66,24 @@ pub(in crate::message_pool) fn add_to_selected_msgs( ) { rmsgs.entry(m.from()).or_default().insert(m.sequence(), m); } + +pub(crate) fn compute_rbf_min_premium(premium: &TokenAmount) -> TokenAmount { + premium.clone() + (premium * RBF_NUM).div_floor(RBF_DENOM) + TokenAmount::from_atto(1u8) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_rbf_min_premium_formula() { + assert_eq!( + super::compute_rbf_min_premium(&TokenAmount::from_atto(100u64)), + TokenAmount::from_atto(126u64) // 100 + 100*64/256 + 1 + ); + assert_eq!( + super::compute_rbf_min_premium(&TokenAmount::from_atto(0u64)), + TokenAmount::from_atto(1u64) + ); + } +} diff --git a/src/rpc/methods/gas.rs b/src/rpc/methods/gas.rs index d61cc71489b1..ef7db350da22 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, From 00255b3b7f9041a738ab7eb6c0ff0f77475a94cf Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 21 May 2026 01:55:49 +0530 Subject: [PATCH 3/7] Add tests --- Cargo.toml | 11 ++-- mise.toml | 3 +- tests/calibnet_mpool_tools.rs | 67 +++++++++++++++++++++++++ tests/calibnet_wallet.rs | 2 +- tests/common/calibnet_wallet_helpers.rs | 65 ++++++++++++++++++++---- 5 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 tests/calibnet_mpool_tools.rs diff --git a/Cargo.toml b/Cargo.toml index 91e42f69dee2..56e818ea2ab8 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/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/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 f673fcfc1d81..efe047a45118 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 { From 389268db5780913b0e51419d0ead7a386e8e3c00 Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 21 May 2026 02:31:56 +0530 Subject: [PATCH 4/7] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad859aac4760..1e937efa555d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ - [#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. From 84ea344f0f83346c35b5ab9409928a930b27250b Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 21 May 2026 02:44:20 +0530 Subject: [PATCH 5/7] Use ensure --- src/cli/subcommands/mpool_cmd.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/cli/subcommands/mpool_cmd.rs b/src/cli/subcommands/mpool_cmd.rs index 0eacf1e8e11a..5f9a76f8ee03 100644 --- a/src/cli/subcommands/mpool_cmd.rs +++ b/src/cli/subcommands/mpool_cmd.rs @@ -215,11 +215,10 @@ fn compute_replacement_gas(input: ReplaceGasInput) -> anyhow::Result { mut original_msg, } => { let min_premium = compute_rbf_min_premium(&original_msg.gas_premium); - if gas_premium < min_premium { - return Err(anyhow::anyhow!( - "gas premium is below the minimum required for RBF" - )); - } + anyhow::ensure!( + gas_premium >= min_premium, + "gas premium is below the minimum required for RBF" + ); original_msg.gas_premium = gas_premium; original_msg.gas_fee_cap = gas_feecap; if let Some(limit) = gas_limit { From 0af1256d761a0352e992301edcafabe4bcb8df9b Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 21 May 2026 02:50:17 +0530 Subject: [PATCH 6/7] cleanup --- src/message_pool/msgpool/utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/message_pool/msgpool/utils.rs b/src/message_pool/msgpool/utils.rs index 9d57d9690ee2..ace08b230ac7 100644 --- a/src/message_pool/msgpool/utils.rs +++ b/src/message_pool/msgpool/utils.rs @@ -68,7 +68,7 @@ pub(in crate::message_pool) fn add_to_selected_msgs( } pub(crate) fn compute_rbf_min_premium(premium: &TokenAmount) -> TokenAmount { - premium.clone() + (premium * RBF_NUM).div_floor(RBF_DENOM) + TokenAmount::from_atto(1u8) + premium + (premium * RBF_NUM).div_floor(RBF_DENOM) + TokenAmount::from_atto(1u8) } #[cfg(test)] From 10c1b66874301365274ac518c1a67b9f472eeadd Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 21 May 2026 15:30:14 +0530 Subject: [PATCH 7/7] minor improvements --- docs/docs/users/reference/cli.md | 8 ++++---- src/cli/subcommands/mpool_cmd.rs | 29 +++++++++++++++-------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/docs/docs/users/reference/cli.md b/docs/docs/users/reference/cli.md index 5aad628fa948..cb2e90a7ce1d 100644 --- a/docs/docs/users/reference/cli.md +++ b/docs/docs/users/reference/cli.md @@ -681,7 +681,7 @@ 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's for (must be signable by the node's wallet) + --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` @@ -702,9 +702,9 @@ Options: --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 (manual mode) - --gas-feecap Gas fee cap (manual mode) - --gas-limit Gas limit (manual mode; keeps original value if unset) + --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 ``` diff --git a/src/cli/subcommands/mpool_cmd.rs b/src/cli/subcommands/mpool_cmd.rs index 5f9a76f8ee03..deb4f26a1481 100644 --- a/src/cli/subcommands/mpool_cmd.rs +++ b/src/cli/subcommands/mpool_cmd.rs @@ -54,7 +54,7 @@ pub enum MpoolCommands { }, /// Fill an on-chain nonce gap by pushing signed self-transfer messages. NonceFix { - /// Address to fill nonce's for (must be signable by the node's wallet). + /// 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`). @@ -82,19 +82,19 @@ pub enum MpoolCommands { #[arg(long, conflicts_with_all = ["from", "nonce"])] cid: Option, /// Automatically re-estimate gas, ensuring the RBF minimum premium is met. - #[arg(long)] + #[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 (manual mode). - #[arg(long, value_parser = humantoken::parse)] + /// Gas premium (required unless `--auto` is used). + #[arg(long, value_parser = humantoken::parse, required_unless_present = "auto")] gas_premium: Option, - /// Gas fee cap (manual mode). - #[arg(long, value_parser = humantoken::parse)] + /// Gas fee cap (required unless `--auto` is used). + #[arg(long, value_parser = humantoken::parse, required_unless_present = "auto")] gas_feecap: Option, - /// Gas limit (manual mode; keeps original value if unset). - #[arg(long)] + /// Gas limit (Optional; keeps original value if unset). + #[arg(long, conflicts_with = "auto")] gas_limit: Option, }, } @@ -445,12 +445,13 @@ impl MpoolCommands { let addr: Address = addr.into(); let fill_range = if auto { - let actor = StateGetActor::call(&client, (addr, ApiTipsetKey(None))) - .await? - .with_context(|| format!("no on-chain actor found for {addr}"))?; - let next_nonce = actor.sequence; - let NotNullVec(pending) = - MpoolPending::call(&client, (ApiTipsetKey(None),)).await?; + 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; get_nonce_fix_fill_range(NonceFixFillRangeInput::Auto { addr, next_on_chain_nonce: next_nonce,