Skip to main content

forest/dev/subcommands/tests_cmd/
helpers.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use std::future::Future;
5use std::io::Write as _;
6use std::process::Command;
7use std::sync::LazyLock;
8use std::time::Duration;
9
10use anyhow::{Context as _, bail};
11use cid::Cid;
12use jsonrpsee::core::ClientError;
13use serde_json::{Value, json};
14use tempfile::NamedTempFile;
15use tokio::sync::OnceCell;
16
17use crate::rpc::prelude::*;
18use crate::rpc::types::{ApiTipsetKey, MessageLookup};
19use crate::rpc::{Client, humanize_rpc_error};
20use crate::shim::address::Address;
21use crate::shim::clock::ChainEpoch;
22use crate::shim::state_tree::ActorState;
23use crate::state_manager::FAILED_TO_LOAD_MESSAGE;
24
25/// Funded preloaded address from env `FOREST_TEST_PRELOADED_ADDRESS` (`forest_wallet_init` in `scripts/tests/harness.sh`).
26pub static FOREST_TEST_PRELOADED_ADDRESS: LazyLock<String> = LazyLock::new(|| {
27    std::env::var("FOREST_TEST_PRELOADED_ADDRESS")
28        .ok()
29        .map(|s| s.trim().to_owned())
30        .filter(|s| !s.is_empty())
31        .expect("FOREST_TEST_PRELOADED_ADDRESS must be set")
32});
33
34/// Test amount to be transferred between accounts in wallet tests.
35pub const FIL_AMT: &str = "500 atto FIL";
36/// Sentinel `forest-wallet balance --exact-balance` returns for an unfunded address.
37pub const FIL_ZERO: &str = "0 FIL";
38/// Amount to seed a freshly-created delegated wallet.
39pub const DELEGATE_FUND_AMT: &str = "30 micro FIL";
40
41/// Maximum time to wait for a polled condition before failing the test.
42pub const POLL_TIMEOUT: Duration = Duration::from_secs(600);
43/// Delay between poll attempts.
44pub const POLL_WAIT_TIME: Duration = Duration::from_secs(1);
45/// Epochs a message search looks back over before giving up.
46const MESSAGE_LOOKBACK: ChainEpoch = 800;
47
48/// Selects which `forest-wallet` keystore an operation targets.
49#[derive(Copy, Clone, Debug, Eq, PartialEq)]
50pub enum Backend {
51    Local,
52    Remote,
53}
54
55impl Backend {
56    fn extra_args(self) -> &'static [&'static str] {
57        match self {
58            Self::Local => &[],
59            Self::Remote => &["--remote-wallet"],
60        }
61    }
62
63    pub fn label(self) -> &'static str {
64        match self {
65            Self::Local => "local",
66            Self::Remote => "remote",
67        }
68    }
69}
70
71/// Run `forest-wallet [--remote-wallet] <args>` and return trimmed stdout.
72pub fn wallet(backend: Backend, args: &[&str]) -> anyhow::Result<String> {
73    Ok(String::from_utf8(run_wallet_raw(backend, args)?)?
74        .trim()
75        .to_string())
76}
77
78/// Same as [`wallet`] but yields raw stdout bytes.
79pub fn run_wallet_raw(backend: Backend, args: &[&str]) -> anyhow::Result<Vec<u8>> {
80    let mut full = Vec::with_capacity(backend.extra_args().len() + args.len());
81    full.extend_from_slice(backend.extra_args());
82    full.extend_from_slice(args);
83    run("forest-wallet", &full)
84}
85
86/// Runs `program args...` and returns raw stdout, with stderr surfaced on failure.
87fn run(program: &str, args: &[&str]) -> anyhow::Result<Vec<u8>> {
88    let output = Command::new(program)
89        .args(args)
90        .output()
91        .with_context(|| format!("failed to spawn `{program}`"))?;
92    if !output.status.success() {
93        bail!(
94            "`{program} {}` failed (status={}): {}",
95            args.join(" "),
96            output.status,
97            String::from_utf8_lossy(&output.stderr)
98        );
99    }
100    Ok(output.stdout)
101}
102
103/// [`run`] with stdout as trimmed UTF-8.
104fn run_str(program: &str, args: &[&str]) -> anyhow::Result<String> {
105    Ok(String::from_utf8(run(program, args)?)?.trim().to_owned())
106}
107
108/// Export `address` from the chosen backend into a temp file ready to feed
109/// back to `forest-wallet import`.
110pub fn export_to_temp_file(address: &str, backend: Backend) -> anyhow::Result<NamedTempFile> {
111    let raw = run_wallet_raw(backend, &["export", address])?;
112    let mut file = NamedTempFile::new_in(std::env::temp_dir())
113        .context("failed to create temp file for wallet export")?;
114    file.write_all(&raw)?;
115    file.flush()?;
116    Ok(file)
117}
118
119pub fn balance(address: &str, backend: Backend) -> anyhow::Result<String> {
120    wallet(backend, &["balance", address, "--exact-balance"])
121}
122
123/// Send with `--from`. `backend` chooses the signing keystore
124/// (local file vs `--remote-wallet`).
125pub fn send_from(from: &str, to: &str, amount: &str, backend: Backend) -> anyhow::Result<String> {
126    send_from_and_maybe_wait(from, to, amount, backend, true)
127}
128
129pub fn send_from_no_wait(
130    from: &str,
131    to: &str,
132    amount: &str,
133    backend: Backend,
134) -> anyhow::Result<String> {
135    send_from_and_maybe_wait(from, to, amount, backend, false)
136}
137
138fn send_from_and_maybe_wait(
139    from: &str,
140    to: &str,
141    amount: &str,
142    backend: Backend,
143    wait: bool,
144) -> anyhow::Result<String> {
145    let mut args = vec!["send", to, amount, "--from", from];
146    if wait {
147        args.extend(["--wait-confidence", "0", "--wait-timeout", "10m"]);
148    }
149    wallet(backend, &args)
150}
151
152/// Max attempts for [`rpc_call_with_retry`].
153const RPC_RETRIES: usize = 3;
154/// Delay between [`rpc_call_with_retry`] attempts; one block-time at calibnet
155/// cadence is enough for the daemon's state snapshot to refresh.
156const RPC_RETRY_DELAY: Duration = Duration::from_secs(15);
157
158/// Poll until `try_check` returns `Some` or [`POLL_TIMEOUT`] elapses, sleeping
159/// [`POLL_WAIT_TIME`] between attempts.
160pub async fn poll<F, Fut, T>(label: &str, mut try_check: F) -> anyhow::Result<T>
161where
162    F: FnMut() -> Fut,
163    Fut: Future<Output = anyhow::Result<Option<T>>>,
164{
165    let started = tokio::time::Instant::now();
166    let mut attempt = 0u32;
167    loop {
168        attempt += 1;
169        eprintln!("Polling {label} attempt {attempt}");
170        if let Some(value) = try_check().await? {
171            return Ok(value);
172        }
173        if started.elapsed() >= POLL_TIMEOUT {
174            bail!("Timed out waiting for {label} after {POLL_TIMEOUT:?}");
175        }
176        let remaining = POLL_TIMEOUT.saturating_sub(started.elapsed());
177        tokio::time::sleep(POLL_WAIT_TIME.min(remaining)).await;
178    }
179}
180
181/// Poll until the balance reported for `address` differs from `baseline`.
182pub async fn poll_until_changed(
183    address: &str,
184    baseline: &str,
185    backend: Backend,
186) -> anyhow::Result<String> {
187    let label = format!("{} balance change for {address}", backend.label());
188    let baseline = baseline.to_string();
189    poll(&label, || async {
190        let bal = balance(address, backend)?;
191        Ok((bal != baseline).then_some(bal))
192    })
193    .await
194}
195
196/// Poll until the balance reported for `address` is no longer [`FIL_ZERO`].
197pub async fn poll_until_funded(address: &str, backend: Backend) -> anyhow::Result<String> {
198    poll_until_changed(address, FIL_ZERO, backend).await
199}
200
201pub async fn get_actor(client: &Client, addr: Address) -> anyhow::Result<Option<ActorState>> {
202    match client
203        .call(StateGetActor::request((addr, ApiTipsetKey(None)))?)
204        .await
205    {
206        Ok(actor) => Ok(actor),
207        Err(e)
208            if ["actor not found", "resolution lookup failed"]
209                .iter()
210                .any(|s| format!("{e:#}").contains(s)) =>
211        {
212            Ok(None)
213        }
214        Err(e) => Err(anyhow::anyhow!("{e:#}")),
215    }
216}
217
218/// Poll until `node`'s state has an actor at `addr` (the Lotus node trails Forest by a block on the
219/// forest-produced devnet, so a Forest-funded sender must be awaited before any `lotus --from`).
220pub async fn poll_until_actor_on(
221    node: &str,
222    addr: Address,
223    make_client: fn() -> anyhow::Result<Client>,
224) -> anyhow::Result<ActorState> {
225    poll(&format!("{node} StateGetActor {addr}"), || async {
226        get_actor(&make_client()?, addr).await
227    })
228    .await
229}
230
231/// True for a `lotus` CLI failure that clears once the node catches up to the funding block (the
232/// Lotus node trails Forest by a block on the forest-produced devnet).
233fn is_transient_lotus_error(e: &anyhow::Error) -> bool {
234    let msg = format!("{e:#}");
235    [
236        "check has failed",
237        "failed to get nonce from mempool",
238        "actor not found",
239        "resolution lookup failed",
240        "not enough funds",
241    ]
242    .iter()
243    .any(|s| msg.contains(s))
244}
245
246/// Run a `lotus` command, retrying while it fails with a transient error (see
247/// [`is_transient_lotus_error`]). Any other failure propagates immediately.
248pub async fn lotus_exec_retrying_transient(args: &[&str]) -> anyhow::Result<String> {
249    poll(&format!("lotus {}", args.join(" ")), || async {
250        match lotus_exec(args) {
251            Ok(out) => Ok(Some(out)),
252            Err(e) if is_transient_lotus_error(&e) => Ok(None),
253            Err(e) => Err(e),
254        }
255    })
256    .await
257}
258
259/// Delegated signer: create once on local, fund locally, mirror to remote
260/// for tests that query or sign.
261pub async fn funded_delegated_addr() -> &'static str {
262    static FUNDED_DELEGATED: OnceCell<String> = OnceCell::const_new();
263
264    FUNDED_DELEGATED
265        .get_or_try_init(|| async {
266            let addr = wallet(Backend::Local, &["new", "delegated"]).unwrap();
267            let fund_msg = send_from(
268                &FOREST_TEST_PRELOADED_ADDRESS,
269                &addr,
270                DELEGATE_FUND_AMT,
271                Backend::Local,
272            )
273            .unwrap();
274            eprintln!("delegated funding send to {addr} msg: {fund_msg}");
275            for backend in [Backend::Local, Backend::Remote] {
276                let funded = poll_until_funded(&addr, backend).await.unwrap();
277                eprintln!(
278                    "delegated wallet {addr} funded balance: {funded} ({})",
279                    backend.label()
280                );
281            }
282
283            let exported = export_to_temp_file(&addr, Backend::Local).unwrap();
284            let path = exported
285                .path()
286                .to_str()
287                .expect("temp path is not valid UTF-8");
288            let mirrored = wallet(Backend::Remote, &["import", path]).unwrap();
289            assert_eq!(mirrored, addr, "mirror mismatch: {mirrored} != {addr}");
290            Ok::<_, anyhow::Error>(addr)
291        })
292        .await
293        .unwrap()
294        .as_str()
295}
296
297static HTTP: LazyLock<reqwest::Client> = LazyLock::new(|| {
298    reqwest::Client::builder()
299        .timeout(Duration::from_secs(120))
300        .build()
301        .expect("failed to build reqwest client")
302});
303
304/// Cached `(token, http_url)` parsed once from `FULLNODE_API_INFO`.
305static API: LazyLock<anyhow::Result<(String, String)>> = LazyLock::new(|| {
306    let raw = std::env::var("FULLNODE_API_INFO").context("FULLNODE_API_INFO env var not set")?;
307    let (token, multiaddr) = raw
308        .split_once(':')
309        .context("FULLNODE_API_INFO must be `<token>:<multiaddr>`")?;
310    let parts: Vec<&str> = multiaddr.split('/').collect();
311    let host = parts
312        .get(2)
313        .filter(|s| !s.is_empty())
314        .with_context(|| format!("missing host in multiaddr `{multiaddr}`"))?;
315    let port = parts
316        .get(4)
317        .filter(|s| !s.is_empty())
318        .with_context(|| format!("missing port in multiaddr `{multiaddr}`"))?;
319    Ok((token.to_string(), format!("http://{host}:{port}/rpc/v1")))
320});
321
322fn api() -> anyhow::Result<&'static (String, String)> {
323    API.as_ref()
324        .map_err(|e| anyhow::anyhow!("FULLNODE_API_INFO unavailable: {e}"))
325}
326
327/// Typed client for the Lotus node on the docker devnet, whose read methods need no token.
328pub fn lotus_client() -> anyhow::Result<crate::rpc::Client> {
329    let port = std::env::var("LOTUS_RPC_PORT")
330        .context("LOTUS_RPC_PORT not set; source the devnet test harness")?;
331    Ok(crate::rpc::Client::from_url(
332        format!("http://127.0.0.1:{port}/").parse()?,
333    ))
334}
335
336/// Typed client for the Forest node under test, from `FULLNODE_API_INFO`.
337pub fn forest_client() -> anyhow::Result<crate::rpc::Client> {
338    crate::rpc::Client::default_or_from_env(None)
339}
340
341pub fn docker(args: &[&str]) -> anyhow::Result<String> {
342    run_str("docker", args).context("is the devnet up?")
343}
344
345/// Runs `lotus <args>` inside the `lotus` container on the docker devnet.
346pub fn lotus_exec(args: &[&str]) -> anyhow::Result<String> {
347    let mut full = vec!["exec", "lotus", "lotus"];
348    full.extend_from_slice(args);
349    docker(&full)
350}
351
352/// POST a JSON-RPC v1 request and return the `result` field, or `None` if
353/// the server responded without one.
354pub async fn rpc_call_opt(method: &str, params: Value) -> anyhow::Result<Option<Value>> {
355    let (token, url) = api()?;
356    let body = json!({
357        "jsonrpc": "2.0",
358        "id": 1,
359        "method": method,
360        "params": params,
361    });
362    let resp: Value = HTTP
363        .post(url)
364        .bearer_auth(token)
365        .json(&body)
366        .send()
367        .await
368        .with_context(|| format!("POST {url} for {method}"))?
369        .error_for_status()
370        .with_context(|| format!("HTTP error from {method}"))?
371        .json()
372        .await
373        .with_context(|| format!("decoding JSON-RPC response for {method}"))?;
374    if let Some(err) = resp.get("error").filter(|e| !e.is_null()) {
375        bail!("RPC error from {method}: {err}");
376    }
377    match resp.get("result") {
378        None => Ok(None),
379        Some(v) if v.is_null() => Ok(None),
380        Some(v) => Ok(Some(v.clone())),
381    }
382}
383
384/// Like [`rpc_call_opt`] but treats a missing `result` as an error and retries.
385pub async fn rpc_call_with_retry(method: &str, params: Value) -> anyhow::Result<Value> {
386    let mut attempt = 1;
387    loop {
388        let result = rpc_call_opt(method, params.clone()).await.and_then(|opt| {
389            opt.with_context(|| format!("missing `result` in response for {method}"))
390        });
391        match result {
392            Ok(v) => return Ok(v),
393            Err(e) if attempt < RPC_RETRIES => {
394                eprintln!(
395                    "error: {e:?} {method} failed on attempt {attempt}/{RPC_RETRIES}, retrying"
396                );
397                tokio::time::sleep(RPC_RETRY_DELAY).await;
398                attempt += 1;
399            }
400            Err(e) => return Err(e),
401        }
402    }
403}
404
405/// Extract a CID string from either a Lotus `{ "/": "bafy..." }` map or a
406/// plain string.
407pub fn cid_from_lotus_json_result(result: &Value) -> anyhow::Result<String> {
408    if let Some(s) = result.as_str() {
409        return Ok(s.to_string());
410    }
411    result
412        .get("/")
413        .and_then(|v| v.as_str())
414        .map(str::to_owned)
415        .with_context(|| format!("expected CID (lotus JSON or string), got {result}"))
416}
417
418/// Poll `Filecoin.StateSearchMsg` until the message is mined or [`POLL_TIMEOUT`] elapses.
419pub async fn poll_until_state_search_msg(msg_cid: &str) -> anyhow::Result<()> {
420    let label = format!("StateSearchMsg for {msg_cid}");
421    poll(&label, || async {
422        let params = json!([[], { "/": msg_cid }, MESSAGE_LOOKBACK, true]);
423        Ok((rpc_call_opt("Filecoin.StateSearchMsg", params)
424            .await?
425            .is_some())
426        .then_some(()))
427    })
428    .await
429}
430
431/// Forest and Lotus both refuse a wait for a message they have never seen, rather than waiting
432/// for one to arrive.
433fn is_unseen_message_error(e: &ClientError) -> bool {
434    matches!(e, ClientError::Call(obj) if obj.message().contains(FAILED_TO_LOAD_MESSAGE))
435}
436
437/// Wait until `cid` has been executed on the chain `client` follows. `lotus send` returns as soon
438/// as Lotus's own mpool accepts the message, so the node under test may not have it yet.
439pub async fn poll_until_message_executed(
440    client: &Client,
441    cid: Cid,
442) -> anyhow::Result<MessageLookup> {
443    // A blocking attempt outlives the loop's own deadline check, so hand each one what is left.
444    let deadline = tokio::time::Instant::now() + POLL_TIMEOUT;
445    poll(&format!("StateWaitMsg for {cid}"), || async {
446        let budget = deadline.saturating_duration_since(tokio::time::Instant::now());
447        match client
448            .call(StateWaitMsg::request((cid, 0, MESSAGE_LOOKBACK, true))?.with_timeout(budget))
449            .await
450        {
451            Ok(lookup) => Ok(Some(lookup)),
452            Err(e) if is_unseen_message_error(&e) => Ok(None),
453            Err(e) => Err(humanize_rpc_error(e.into())),
454        }
455    })
456    .await
457}
458
459pub fn forest_cli(args: &[&str]) -> anyhow::Result<String> {
460    run_str("forest-cli", args)
461}
462
463/// Next nonce for an address
464pub fn mpool_nonce(address: &str) -> anyhow::Result<u64> {
465    let out = forest_cli(&["mpool", "nonce", address])?;
466    out.parse::<u64>()
467        .with_context(|| format!("invalid mpool nonce output: {out}"))
468}
469
470/// Pending message nonces for `address` via `Filecoin.MpoolPending`.
471pub async fn pending_nonces_for(address: &str) -> anyhow::Result<Vec<u64>> {
472    let result = rpc_call_with_retry("Filecoin.MpoolPending", json!([null])).await?;
473    let entries = result
474        .as_array()
475        .with_context(|| format!("expected MpoolPending array, got {result}"))?;
476    Ok(entries
477        .iter()
478        .filter_map(|entry| {
479            let msg = entry.get("Message")?;
480            (msg.get("From")?.as_str()? == address).then_some(msg.get("Nonce")?.as_u64()?)
481        })
482        .collect())
483}
484
485/// Poll until `address` has a pending message at `nonce`.
486pub async fn poll_until_pending_nonce(address: &str, nonce: u64) -> anyhow::Result<()> {
487    let label = format!("pending nonce {nonce} for {address}");
488    let address = address.to_string();
489    poll(&label, || async {
490        let nonces = pending_nonces_for(&address).await?;
491        Ok(nonces.contains(&nonce).then_some(()))
492    })
493    .await
494}
495
496/// Resolve the ETH equivalent of a Filecoin address via
497/// `Filecoin.FilecoinAddressToEthAddress`.
498pub async fn filecoin_to_eth(address: &str) -> anyhow::Result<String> {
499    let result = rpc_call_with_retry(
500        "Filecoin.FilecoinAddressToEthAddress",
501        json!([address, "pending"]),
502    )
503    .await?;
504    result
505        .as_str()
506        .map(str::to_owned)
507        .with_context(|| format!("expected string ETH address, got {result}"))
508}
509
510pub fn block_on<F: Future + Send + 'static>(future: F) -> F::Output
511where
512    F::Output: Send + 'static,
513{
514    std::thread::spawn(|| {
515        let rt = tokio::runtime::Builder::new_multi_thread()
516            .enable_all()
517            .build()
518            .unwrap();
519        rt.block_on(future)
520    })
521    .join()
522    // Preserve the panic message instead of `unwrap`'s `Any { .. }`.
523    .unwrap_or_else(|payload| std::panic::resume_unwind(payload))
524}