From 7666f15cbd94ea1eb257f00dbba632e9884aa59b Mon Sep 17 00:00:00 2001 From: Shashank Date: Fri, 19 Jun 2026 10:12:56 +0530 Subject: [PATCH 1/4] fix wallet test --- tests/calibnet_wallet.rs | 5 +++++ tests/common/calibnet_wallet_helpers.rs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/tests/calibnet_wallet.rs b/tests/calibnet_wallet.rs index 27be9ce6861..d81e6d61cd8 100644 --- a/tests/calibnet_wallet.rs +++ b/tests/calibnet_wallet.rs @@ -63,6 +63,7 @@ async fn send_to_filecoin_address(#[case] backend: Backend) { let target = wallet(backend, &["new"]).unwrap(); let msg = send_from(&FOREST_TEST_PRELOADED_ADDRESS, &target, FIL_AMT, backend).unwrap(); eprintln!("send to {target} ({}) msg: {msg}", backend.label()); + wait_for_msg(&msg).await.unwrap(); let funded = poll_until_funded(&target, backend).await.unwrap(); eprintln!("{target} funded balance: {funded}"); } @@ -78,11 +79,13 @@ async fn send_to_eth_equivalent(#[case] backend: Backend) { "initial send to {target} ({}) msg: {initial_msg}", backend.label(), ); + wait_for_msg(&initial_msg).await.unwrap(); let baseline = poll_until_funded(&target, backend).await.unwrap(); let eth = filecoin_to_eth(&target).await.unwrap(); let eth_msg = send_from(&FOREST_TEST_PRELOADED_ADDRESS, ð, FIL_AMT, backend).unwrap(); eprintln!("send to ETH {eth} (mapped from {target}) msg: {eth_msg}"); + wait_for_msg(ð_msg).await.unwrap(); let updated = poll_until_changed(&target, &baseline, backend) .await @@ -122,6 +125,7 @@ async fn delegated_send(#[case] target_backend: Backend) { "delegated send to {target} ({}) msg: {msg}", target_backend.label(), ); + wait_for_msg(&msg).await.unwrap(); let observed = if baseline == FIL_ZERO { poll_until_funded(&target, target_backend).await.unwrap() } else { @@ -142,6 +146,7 @@ async fn delegated_remote_send() { let baseline = balance(&target, Backend::Remote).unwrap(); let msg = send_from(funded, &target, FIL_AMT, Backend::Remote).unwrap(); eprintln!("delegated --remote-wallet send to {target} msg: {msg}"); + wait_for_msg(&msg).await.unwrap(); let observed = if baseline == FIL_ZERO { poll_until_funded(&target, Backend::Remote).await.unwrap() } else { diff --git a/tests/common/calibnet_wallet_helpers.rs b/tests/common/calibnet_wallet_helpers.rs index b071153e845..44004396090 100644 --- a/tests/common/calibnet_wallet_helpers.rs +++ b/tests/common/calibnet_wallet_helpers.rs @@ -1,6 +1,10 @@ // Copyright 2019-2026 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT +//! Shared helpers for calibnet integration tests (`wallet`, `mpool_tools`). + +#![allow(dead_code)] + use std::io::Write as _; use std::process::Command; use std::sync::LazyLock; @@ -203,6 +207,7 @@ pub async fn funded_delegated_addr() -> &'static str { ) .unwrap(); eprintln!("delegated funding send to {addr} msg: {fund_msg}"); + wait_for_msg(&fund_msg).await.unwrap(); let funded = poll_until_funded(&addr, Backend::Local).await.unwrap(); eprintln!("delegated wallet {addr} funded balance: {funded}"); @@ -315,6 +320,21 @@ pub async fn poll_until_state_search_msg(msg_cid: &str) -> anyhow::Result<()> { .await } +/// Waits for a sent message to be included on-chain and confirms it executed successfully. +pub async fn wait_for_msg(msg_cid: &str) -> anyhow::Result<()> { + let params = json!([{ "/": msg_cid }, 0, -1_i64, true]); + let result = rpc_call("Filecoin.StateWaitMsg", params).await?; + let exit_code = result + .get("Receipt") + .and_then(|r| r.get("ExitCode")) + .and_then(Value::as_i64) + .with_context(|| format!("StateWaitMsg result missing Receipt.ExitCode: {result}"))?; + if exit_code != 0 { + bail!("message {msg_cid} landed on-chain but failed with exit code {exit_code}"); + } + Ok(()) +} + /// Run `forest-cli ` and return trimmed stdout. pub fn forest_cli(args: &[&str]) -> anyhow::Result { let output = Command::new("forest-cli") From 72089f79e2abb45432392c540d670a6ebc72a4ce Mon Sep 17 00:00:00 2001 From: Shashank Date: Fri, 19 Jun 2026 11:06:18 +0530 Subject: [PATCH 2/4] cleanup --- tests/calibnet_mpool_tools.rs | 2 +- tests/calibnet_wallet.rs | 2 +- tests/common/calibnet_wallet_helpers.rs | 13 ------------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/tests/calibnet_mpool_tools.rs b/tests/calibnet_mpool_tools.rs index 2c54f410167..e896a8f8cc6 100644 --- a/tests/calibnet_mpool_tools.rs +++ b/tests/calibnet_mpool_tools.rs @@ -61,7 +61,7 @@ async fn mpool_replace_auto_unblocks_pending() { .unwrap(); assert!( - poll_until_state_search_msg(&cid).await.is_ok(), + wait_for_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 d81e6d61cd8..bce02510a09 100644 --- a/tests/calibnet_wallet.rs +++ b/tests/calibnet_wallet.rs @@ -52,7 +52,7 @@ async fn market_add_balance_message_on_chain() { .await .unwrap(); let msg_cid = cid_from_lotus_json_result(&result).unwrap(); - poll_until_state_search_msg(&msg_cid).await.unwrap(); + wait_for_msg(&msg_cid).await.unwrap(); } #[rstest] diff --git a/tests/common/calibnet_wallet_helpers.rs b/tests/common/calibnet_wallet_helpers.rs index 44004396090..ea46c5c3f8f 100644 --- a/tests/common/calibnet_wallet_helpers.rs +++ b/tests/common/calibnet_wallet_helpers.rs @@ -307,19 +307,6 @@ pub fn cid_from_lotus_json_result(result: &Value) -> anyhow::Result { .with_context(|| format!("expected CID (lotus JSON or string), got {result}")) } -/// Poll `Filecoin.StateSearchMsg` until the message is mined or [`POLL_TIMEOUT`] elapses. -pub async fn poll_until_state_search_msg(msg_cid: &str) -> anyhow::Result<()> { - let label = format!("StateSearchMsg for {msg_cid}"); - poll(&label, || async { - let params = json!([[], { "/": msg_cid }, 800_i64, true]); - Ok((rpc_call_opt("Filecoin.StateSearchMsg", params) - .await? - .is_some()) - .then_some(())) - }) - .await -} - /// Waits for a sent message to be included on-chain and confirms it executed successfully. pub async fn wait_for_msg(msg_cid: &str) -> anyhow::Result<()> { let params = json!([{ "/": msg_cid }, 0, -1_i64, true]); From 4e11fd70bc6b62745f3d0fe60d80d26c8fcb358d Mon Sep 17 00:00:00 2001 From: Shashank Date: Fri, 19 Jun 2026 15:55:27 +0530 Subject: [PATCH 3/4] Increase timeout --- tests/common/calibnet_wallet_helpers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/common/calibnet_wallet_helpers.rs b/tests/common/calibnet_wallet_helpers.rs index ea46c5c3f8f..0f7788f4f5b 100644 --- a/tests/common/calibnet_wallet_helpers.rs +++ b/tests/common/calibnet_wallet_helpers.rs @@ -227,7 +227,7 @@ pub async fn funded_delegated_addr() -> &'static str { static HTTP: LazyLock = LazyLock::new(|| { reqwest::Client::builder() - .timeout(Duration::from_secs(120)) + .timeout(POLL_TIMEOUT) .build() .expect("failed to build reqwest client") }); From 17a6fc0e697c7429db74be514ac0fef27c7d081e Mon Sep 17 00:00:00 2001 From: Shashank Date: Fri, 19 Jun 2026 20:42:20 +0530 Subject: [PATCH 4/4] restructure --- tests/calibnet_mpool_tools.rs | 46 +++- tests/calibnet_wallet.rs | 161 ++++++++++---- tests/common/calibnet_wallet_helpers.rs | 280 +++++------------------- 3 files changed, 222 insertions(+), 265 deletions(-) diff --git a/tests/calibnet_mpool_tools.rs b/tests/calibnet_mpool_tools.rs index e896a8f8cc6..2f31eb467fc 100644 --- a/tests/calibnet_mpool_tools.rs +++ b/tests/calibnet_mpool_tools.rs @@ -9,9 +9,48 @@ #[path = "common/calibnet_wallet_helpers.rs"] mod helpers; +use anyhow::Context as _; use helpers::*; +use rstest::rstest; +use serde_json::json; use serial_test::serial; +/// Run `forest-cli ` and return trimmed stdout. +fn forest_cli(args: &[&str]) -> anyhow::Result { + Ok(String::from_utf8(run_command("forest-cli", args)?)? + .trim() + .to_string()) +} + +/// Next nonce for an address +fn mpool_nonce(address: &str) -> anyhow::Result { + let out = forest_cli(&["mpool", "nonce", address])?; + out.parse::() + .with_context(|| format!("invalid mpool nonce output: {out}")) +} + +/// Poll until `address` has a pending message at `nonce`. +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 result = rpc_call("Filecoin.MpoolPending", json!([null])).await?; + let pending = result + .as_array() + .with_context(|| format!("expected MpoolPending array, got {result}"))? + .iter() + .any(|entry| { + let Some(msg) = entry.get("Message") else { + return false; + }; + msg.get("From").and_then(|v| v.as_str()) == Some(address.as_str()) + && msg.get("Nonce").and_then(|v| v.as_u64()) == Some(nonce) + }); + Ok(pending.then_some(())) + }) + .await +} + #[tokio::test] #[serial] async fn mpool_nonce_fix_auto_unblocks_pending() { @@ -40,13 +79,16 @@ async fn mpool_nonce_fix_auto_unblocks_pending() { ); } +#[rstest] +#[case::local(Backend::Local)] +#[case::remote(Backend::Remote)] #[tokio::test] #[serial] -async fn mpool_replace_auto_unblocks_pending() { +async fn mpool_replace_auto_unblocks_pending(#[case] backend: Backend) { 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(); + let cid = backend.send(addr, addr, FIL_ZERO).unwrap(); poll_until_pending_nonce(addr, nonce).await.unwrap(); forest_cli(&[ diff --git a/tests/calibnet_wallet.rs b/tests/calibnet_wallet.rs index bce02510a09..a3c5dd2fda1 100644 --- a/tests/calibnet_wallet.rs +++ b/tests/calibnet_wallet.rs @@ -10,31 +10,99 @@ #[path = "common/calibnet_wallet_helpers.rs"] mod helpers; +use std::io::Write as _; + +use anyhow::Context as _; use helpers::*; use rstest::rstest; use serde_json::json; +use tempfile::NamedTempFile; +use tokio::sync::OnceCell; + +/// Test amount to be transferred between accounts in wallet tests. +const FIL_AMT: &str = "500 atto FIL"; +/// Amount to seed a freshly-created delegated wallet. +const DELEGATE_FUND_AMT: &str = "3 micro FIL"; + +static FUNDED_DELEGATED: OnceCell = OnceCell::const_new(); + +/// Delegated signer: create once on local, fund locally, mirror to remote. +async fn funded_delegated_addr() -> &'static str { + let addr = FUNDED_DELEGATED + .get_or_try_init(|| async { + let addr = Backend::Local.run(&["new", "delegated"]).unwrap(); + let fund_msg = Backend::Local + .send(&FOREST_TEST_PRELOADED_ADDRESS, &addr, DELEGATE_FUND_AMT) + .unwrap(); + eprintln!("delegated funding send to {addr} msg: {fund_msg}"); + wait_for_msg(&fund_msg).await.unwrap(); + let funded = poll_until_funded(&addr, Backend::Local).await.unwrap(); + eprintln!("delegated wallet {addr} funded balance: {funded}"); + + let mirrored = import_wallet(&addr, Backend::Local, Backend::Remote).unwrap(); + assert_eq!(mirrored, addr, "mirror mismatch: {mirrored} != {addr}"); + Ok::<_, anyhow::Error>(addr) + }) + .await + .unwrap(); + addr.as_str() +} + +impl Backend { + /// Exact on-chain balance of `address`. + fn balance(self, address: &str) -> anyhow::Result { + self.run(&["balance", address, "--exact-balance"]) + } +} + +/// Poll until the balance reported for `address` differs from `baseline`. +async fn poll_until_changed( + address: &str, + baseline: &str, + backend: Backend, +) -> anyhow::Result { + let label = format!("{backend:?} balance change for {address}"); + let baseline = baseline.to_string(); + poll(&label, || async { + let bal = backend.balance(address)?; + Ok((bal != baseline).then_some(bal)) + }) + .await +} + +/// Poll until the balance reported for `address` is no longer [`FIL_ZERO`]. +async fn poll_until_funded(address: &str, backend: Backend) -> anyhow::Result { + poll_until_changed(address, FIL_ZERO, backend).await +} + +/// Import an address from one backend into another. +fn import_wallet(address: &str, from: Backend, to: Backend) -> anyhow::Result { + let raw = from.run_raw(&["export", address])?; + let mut file = NamedTempFile::new_in(std::env::temp_dir()) + .context("failed to create temp file for wallet export")?; + file.write_all(&raw)?; + file.flush()?; + let path = file + .path() + .to_str() + .context("temp path is not valid UTF-8")?; + + if to == from { + to.run(&["delete", address])?; + } + to.run(&["import", path]) +} #[rstest] #[case::local(Backend::Local)] #[case::remote(Backend::Remote)] #[tokio::test] async fn export_import_roundtrip(#[case] backend: Backend) { - let addr = wallet(backend, &["new"]).unwrap(); - let exported = export_to_temp_file(&addr, backend).unwrap(); - let path = exported - .path() - .to_str() - .expect("temp path is not valid UTF-8"); - - let deleted = wallet(backend, &["delete", &addr]).unwrap(); - eprintln!("delete output ({}): {deleted}", backend.label()); - - let imported = wallet(backend, &["import", path]).unwrap(); + let addr = backend.run(&["new"]).unwrap(); + let imported = import_wallet(&addr, backend, backend).unwrap(); assert_eq!( - imported, - addr, - "round-trip mismatch on {} backend: {imported} != {addr}", - backend.label(), + imported, addr, + "round-trip mismatch on {backend:?}: {imported} != {addr}", ); } @@ -51,8 +119,11 @@ async fn market_add_balance_message_on_chain() { ) .await .unwrap(); - let msg_cid = cid_from_lotus_json_result(&result).unwrap(); - wait_for_msg(&msg_cid).await.unwrap(); + let msg_cid = result + .get("/") + .and_then(|v| v.as_str()) + .expect("MarketAddBalance should return a CID"); + wait_for_msg(msg_cid).await.unwrap(); } #[rstest] @@ -60,9 +131,11 @@ async fn market_add_balance_message_on_chain() { #[case::remote(Backend::Remote)] #[tokio::test] async fn send_to_filecoin_address(#[case] backend: Backend) { - let target = wallet(backend, &["new"]).unwrap(); - let msg = send_from(&FOREST_TEST_PRELOADED_ADDRESS, &target, FIL_AMT, backend).unwrap(); - eprintln!("send to {target} ({}) msg: {msg}", backend.label()); + let target = backend.run(&["new"]).unwrap(); + let msg = backend + .send(&FOREST_TEST_PRELOADED_ADDRESS, &target, FIL_AMT) + .unwrap(); + eprintln!("send to {target} ({backend:?}) msg: {msg}"); wait_for_msg(&msg).await.unwrap(); let funded = poll_until_funded(&target, backend).await.unwrap(); eprintln!("{target} funded balance: {funded}"); @@ -73,17 +146,24 @@ async fn send_to_filecoin_address(#[case] backend: Backend) { #[case::remote(Backend::Remote)] #[tokio::test] async fn send_to_eth_equivalent(#[case] backend: Backend) { - let target = wallet(backend, &["new"]).unwrap(); - let initial_msg = send_from(&FOREST_TEST_PRELOADED_ADDRESS, &target, FIL_AMT, backend).unwrap(); - eprintln!( - "initial send to {target} ({}) msg: {initial_msg}", - backend.label(), - ); + let target = backend.run(&["new"]).unwrap(); + let initial_msg = backend + .send(&FOREST_TEST_PRELOADED_ADDRESS, &target, FIL_AMT) + .unwrap(); + eprintln!("initial send to {target} ({backend:?}) msg: {initial_msg}"); wait_for_msg(&initial_msg).await.unwrap(); let baseline = poll_until_funded(&target, backend).await.unwrap(); - let eth = filecoin_to_eth(&target).await.unwrap(); - let eth_msg = send_from(&FOREST_TEST_PRELOADED_ADDRESS, ð, FIL_AMT, backend).unwrap(); + let eth_result = rpc_call( + "Filecoin.FilecoinAddressToEthAddress", + json!([&target, "pending"]), + ) + .await + .unwrap(); + let eth = eth_result.as_str().expect("expected string ETH address"); + let eth_msg = backend + .send(&FOREST_TEST_PRELOADED_ADDRESS, eth, FIL_AMT) + .unwrap(); eprintln!("send to ETH {eth} (mapped from {target}) msg: {eth_msg}"); wait_for_msg(ð_msg).await.unwrap(); @@ -101,10 +181,10 @@ async fn send_to_eth_equivalent(#[case] backend: Backend) { #[case::remote(Backend::Remote)] #[tokio::test] async fn wallet_delete(#[case] backend: Backend) { - let addr = wallet(backend, &["new"]).unwrap(); - let deleted = wallet(backend, &["delete", &addr]).unwrap(); - eprintln!("delete output ({}): {deleted}", backend.label()); - let listing = wallet(backend, &["list"]).unwrap(); + let addr = backend.run(&["new"]).unwrap(); + let deleted = backend.run(&["delete", &addr]).unwrap(); + eprintln!("delete output ({backend:?}): {deleted}"); + let listing = backend.run(&["list"]).unwrap(); assert!( !listing.contains(&addr), "deleted wallet {addr} still appears in `list`:\n{listing}", @@ -117,14 +197,11 @@ async fn wallet_delete(#[case] backend: Backend) { #[tokio::test] async fn delegated_send(#[case] target_backend: Backend) { let funded = funded_delegated_addr().await; - let target = wallet(target_backend, &["new", "delegated"]).unwrap(); + let target = target_backend.run(&["new", "delegated"]).unwrap(); // Baseline `FIL_ZERO` ⇒ first credit; otherwise expect a balance delta. - let baseline = balance(&target, target_backend).unwrap(); - let msg = send_from(funded, &target, FIL_AMT, Backend::Local).unwrap(); - eprintln!( - "delegated send to {target} ({}) msg: {msg}", - target_backend.label(), - ); + let baseline = target_backend.balance(&target).unwrap(); + let msg = Backend::Local.send(funded, &target, FIL_AMT).unwrap(); + eprintln!("delegated send to {target} ({target_backend:?}) msg: {msg}"); wait_for_msg(&msg).await.unwrap(); let observed = if baseline == FIL_ZERO { poll_until_funded(&target, target_backend).await.unwrap() @@ -142,9 +219,9 @@ async fn delegated_send(#[case] target_backend: Backend) { #[tokio::test] async fn delegated_remote_send() { let funded = funded_delegated_addr().await; - let target = wallet(Backend::Remote, &["new", "delegated"]).unwrap(); - let baseline = balance(&target, Backend::Remote).unwrap(); - let msg = send_from(funded, &target, FIL_AMT, Backend::Remote).unwrap(); + let target = Backend::Remote.run(&["new", "delegated"]).unwrap(); + let baseline = Backend::Remote.balance(&target).unwrap(); + let msg = Backend::Remote.send(funded, &target, FIL_AMT).unwrap(); eprintln!("delegated --remote-wallet send to {target} msg: {msg}"); wait_for_msg(&msg).await.unwrap(); let observed = if baseline == FIL_ZERO { diff --git a/tests/common/calibnet_wallet_helpers.rs b/tests/common/calibnet_wallet_helpers.rs index 0f7788f4f5b..7e035d00ce2 100644 --- a/tests/common/calibnet_wallet_helpers.rs +++ b/tests/common/calibnet_wallet_helpers.rs @@ -3,9 +3,6 @@ //! Shared helpers for calibnet integration tests (`wallet`, `mpool_tools`). -#![allow(dead_code)] - -use std::io::Write as _; use std::process::Command; use std::sync::LazyLock; use std::time::Duration; @@ -13,8 +10,6 @@ use std::time::Duration; use anyhow::{Context as _, bail}; use parking_lot::Mutex; use serde_json::{Value, json}; -use tempfile::NamedTempFile; -use tokio::sync::OnceCell; /// Funded preloaded address from env `FOREST_TEST_PRELOADED_ADDRESS` (`forest_wallet_init` in `scripts/tests/harness.sh`). pub static FOREST_TEST_PRELOADED_ADDRESS: LazyLock = LazyLock::new(|| { @@ -25,17 +20,34 @@ pub static FOREST_TEST_PRELOADED_ADDRESS: LazyLock = LazyLock::new(|| { .expect("FOREST_TEST_PRELOADED_ADDRESS must be set") }); -/// Test amount to be transferred between accounts in wallet tests. -pub const FIL_AMT: &str = "500 atto FIL"; /// Sentinel `forest-wallet balance --exact-balance` returns for an unfunded address. pub const FIL_ZERO: &str = "0 FIL"; -/// Amount to seed a freshly-created delegated wallet. -pub const DELEGATE_FUND_AMT: &str = "3 micro FIL"; - /// Maximum time to wait for a polled condition before failing the test. pub const POLL_TIMEOUT: Duration = Duration::from_secs(600); /// Delay between poll attempts. pub const POLL_WAIT_TIME: Duration = Duration::from_secs(1); +/// Max attempts for [`Backend::send`]. +const SEND_RETRIES: usize = 3; +/// Delay between [`Backend::send`] retries; one block-time at calibnet cadence +/// is enough for the daemon's gas-price snapshot to refresh. +const SEND_RETRY_DELAY: Duration = Duration::from_secs(15); + +/// Run a `PATH` binary, returning raw stdout +pub fn run_command(program: &str, args: &[&str]) -> anyhow::Result> { + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("failed to spawn `{program}`"))?; + if !output.status.success() { + bail!( + "`{program} {}` failed (status={}): {}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(output.stdout) +} /// Selects which `forest-wallet` keystore an operation targets. #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -44,6 +56,9 @@ pub enum Backend { Remote, } +/// Serializes local keystore file access. +static LOCAL_KEYSTORE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + impl Backend { fn extra_args(self) -> &'static [&'static str] { match self { @@ -52,95 +67,49 @@ impl Backend { } } - pub fn label(self) -> &'static str { - match self { - Self::Local => "local", - Self::Remote => "remote", - } - } -} - -/// Serializes local keystore file access. -static LOCAL_KEYSTORE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); - -/// Run `forest-wallet [--remote-wallet] ` and return trimmed stdout. -pub fn wallet(backend: Backend, args: &[&str]) -> anyhow::Result { - Ok(String::from_utf8(run_wallet_raw(backend, args)?)? - .trim() - .to_string()) -} - -/// Same as [`wallet`] but yields raw stdout bytes (used by `export`). -pub fn run_wallet_raw(backend: Backend, args: &[&str]) -> anyhow::Result> { - let _guard = (backend == Backend::Local).then(|| LOCAL_KEYSTORE_LOCK.lock()); + /// Run `forest-wallet [--remote-wallet] `, returning raw stdout. + pub fn run_raw(self, args: &[&str]) -> anyhow::Result> { + let _guard = (self == Self::Local).then(|| LOCAL_KEYSTORE_LOCK.lock()); - let mut full = Vec::with_capacity(backend.extra_args().len() + args.len()); - full.extend_from_slice(backend.extra_args()); - full.extend_from_slice(args); + let mut full = Vec::with_capacity(self.extra_args().len() + args.len()); + full.extend_from_slice(self.extra_args()); + full.extend_from_slice(args); - let output = Command::new("forest-wallet") - .args(&full) - .output() - .context("failed to spawn `forest-wallet`")?; - if !output.status.success() { - bail!( - "`forest-wallet {}` failed (status={}): {}", - full.join(" "), - output.status, - String::from_utf8_lossy(&output.stderr) - ); + run_command("forest-wallet", &full) } - Ok(output.stdout) -} - -/// Export `address` from the chosen backend into a temp file ready to feed -/// back to `forest-wallet import`. -pub fn export_to_temp_file(address: &str, backend: Backend) -> anyhow::Result { - let raw = run_wallet_raw(backend, &["export", address])?; - let mut file = NamedTempFile::new_in(std::env::temp_dir()) - .context("failed to create temp file for wallet export")?; - file.write_all(&raw)?; - file.flush()?; - Ok(file) -} -pub fn balance(address: &str, backend: Backend) -> anyhow::Result { - wallet(backend, &["balance", address, "--exact-balance"]) -} + /// Run `forest-wallet [--remote-wallet] ` and return trimmed stdout. + pub fn run(self, args: &[&str]) -> anyhow::Result { + Ok(String::from_utf8(self.run_raw(args)?)?.trim().to_string()) + } -/// Send with `--from`. `backend` chooses the signing keystore -/// (local file vs `--remote-wallet`). -/// -/// Retries on the transient `gas price is lower than min gas price` mpool -/// error: the local CLI path estimates gas, then submits via `MpoolPush`, -/// so a concurrent push that bumps the mempool's fee floor between -/// estimate and push rejects an otherwise-valid message. Retry re-runs -/// fee estimation so gas fields match whatever minimum gas price applies -/// at the next submission. -pub fn send_from(from: &str, to: &str, amount: &str, backend: Backend) -> anyhow::Result { - let args = ["send", "--from", from, to, amount]; - let mut attempt = 1; - loop { - match wallet(backend, &args) { - Ok(out) => return Ok(out), - Err(e) if attempt < SEND_RETRIES && is_min_gas_price_error(&e) => { - eprintln!( - "send {from} -> {to} hit min-gas-price floor on attempt {attempt}/{SEND_RETRIES}, retrying" - ); - std::thread::sleep(SEND_RETRY_DELAY); - attempt += 1; + /// Send `amount` from `from` to `to`, signing with this keystore. + /// + /// Retries on the transient `gas price is lower than min gas price` mpool + /// error: the local CLI path estimates gas, then submits via `MpoolPush`, + /// so a concurrent push that bumps the mempool's fee floor between + /// estimate and push rejects an otherwise-valid message. Retry re-runs + /// fee estimation so gas fields match whatever minimum gas price applies + /// at the next submission. + pub fn send(self, from: &str, to: &str, amount: &str) -> anyhow::Result { + let args = ["send", "--from", from, to, amount]; + let mut attempt = 1; + loop { + match self.run(&args) { + Ok(out) => return Ok(out), + Err(e) if attempt < SEND_RETRIES && is_min_gas_price_error(&e) => { + eprintln!( + "send {from} -> {to} hit min-gas-price floor on attempt {attempt}/{SEND_RETRIES}, retrying" + ); + std::thread::sleep(SEND_RETRY_DELAY); + attempt += 1; + } + Err(e) => return Err(e), } - Err(e) => return Err(e), } } } -/// Max attempts for [`send_from`]. -const SEND_RETRIES: usize = 3; -/// Delay between [`send_from`] retries; one block-time at calibnet cadence -/// is enough for the daemon's gas-price snapshot to refresh. -const SEND_RETRY_DELAY: Duration = Duration::from_secs(15); - fn is_min_gas_price_error(err: &anyhow::Error) -> bool { err.chain().any(|e| { e.to_string() @@ -150,7 +119,7 @@ fn is_min_gas_price_error(err: &anyhow::Error) -> bool { /// Poll until `try_check` returns `Some` or [`POLL_TIMEOUT`] elapses, sleeping /// [`POLL_WAIT_TIME`] between attempts. -async fn poll(label: &str, mut try_check: F) -> anyhow::Result +pub async fn poll(label: &str, mut try_check: F) -> anyhow::Result where F: FnMut() -> Fut, Fut: std::future::Future>>, @@ -171,60 +140,6 @@ where } } -/// Poll until the balance reported for `address` differs from `baseline`. -pub async fn poll_until_changed( - address: &str, - baseline: &str, - backend: Backend, -) -> anyhow::Result { - let label = format!("{} balance change for {address}", backend.label()); - let baseline = baseline.to_string(); - poll(&label, || async { - let bal = balance(address, backend)?; - Ok((bal != baseline).then_some(bal)) - }) - .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 -/// for tests that query or sign. -pub async fn funded_delegated_addr() -> &'static str { - let addr = FUNDED_DELEGATED - .get_or_try_init(|| async { - let addr = wallet(Backend::Local, &["new", "delegated"]).unwrap(); - let fund_msg = send_from( - &FOREST_TEST_PRELOADED_ADDRESS, - &addr, - DELEGATE_FUND_AMT, - Backend::Local, - ) - .unwrap(); - eprintln!("delegated funding send to {addr} msg: {fund_msg}"); - wait_for_msg(&fund_msg).await.unwrap(); - let funded = poll_until_funded(&addr, Backend::Local).await.unwrap(); - eprintln!("delegated wallet {addr} funded balance: {funded}"); - - let exported = export_to_temp_file(&addr, Backend::Local).unwrap(); - let path = exported - .path() - .to_str() - .expect("temp path is not valid UTF-8"); - let mirrored = wallet(Backend::Remote, &["import", path]).unwrap(); - assert_eq!(mirrored, addr, "mirror mismatch: {mirrored} != {addr}"); - Ok::<_, anyhow::Error>(addr) - }) - .await - .unwrap(); - addr.as_str() -} - static HTTP: LazyLock = LazyLock::new(|| { reqwest::Client::builder() .timeout(POLL_TIMEOUT) @@ -294,19 +209,6 @@ pub async fn rpc_call(method: &str, params: Value) -> anyhow::Result { .with_context(|| format!("missing `result` in response for {method}")) } -/// Extract a CID string from either a Lotus `{ "/": "bafy..." }` map or a -/// plain string. -pub fn cid_from_lotus_json_result(result: &Value) -> anyhow::Result { - if let Some(s) = result.as_str() { - return Ok(s.to_string()); - } - result - .get("/") - .and_then(|v| v.as_str()) - .map(str::to_owned) - .with_context(|| format!("expected CID (lotus JSON or string), got {result}")) -} - /// Waits for a sent message to be included on-chain and confirms it executed successfully. pub async fn wait_for_msg(msg_cid: &str) -> anyhow::Result<()> { let params = json!([{ "/": msg_cid }, 0, -1_i64, true]); @@ -321,67 +223,3 @@ pub async fn wait_for_msg(msg_cid: &str) -> anyhow::Result<()> { } Ok(()) } - -/// 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 { - let result = rpc_call( - "Filecoin.FilecoinAddressToEthAddress", - json!([address, "pending"]), - ) - .await?; - result - .as_str() - .map(str::to_owned) - .with_context(|| format!("expected string ETH address, got {result}")) -}