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 parking_lot::Mutex;
12use serde_json::{Value, json};
13use tempfile::NamedTempFile;
14use tokio::sync::OnceCell;
15
16/// Funded preloaded address from env `FOREST_TEST_PRELOADED_ADDRESS` (`forest_wallet_init` in `scripts/tests/harness.sh`).
17pub static FOREST_TEST_PRELOADED_ADDRESS: LazyLock<String> = LazyLock::new(|| {
18    std::env::var("FOREST_TEST_PRELOADED_ADDRESS")
19        .ok()
20        .map(|s| s.trim().to_owned())
21        .filter(|s| !s.is_empty())
22        .expect("FOREST_TEST_PRELOADED_ADDRESS must be set")
23});
24
25/// Test amount to be transferred between accounts in wallet tests.
26pub const FIL_AMT: &str = "500 atto FIL";
27/// Sentinel `forest-wallet balance --exact-balance` returns for an unfunded address.
28pub const FIL_ZERO: &str = "0 FIL";
29/// Amount to seed a freshly-created delegated wallet.
30pub const DELEGATE_FUND_AMT: &str = "30 micro FIL";
31
32/// Maximum time to wait for a polled condition before failing the test.
33pub const POLL_TIMEOUT: Duration = Duration::from_secs(600);
34/// Delay between poll attempts.
35pub const POLL_WAIT_TIME: Duration = Duration::from_secs(1);
36
37/// Selects which `forest-wallet` keystore an operation targets.
38#[derive(Copy, Clone, Debug, Eq, PartialEq)]
39pub enum Backend {
40    Local,
41    Remote,
42}
43
44impl Backend {
45    fn extra_args(self) -> &'static [&'static str] {
46        match self {
47            Self::Local => &[],
48            Self::Remote => &["--remote-wallet"],
49        }
50    }
51
52    pub fn label(self) -> &'static str {
53        match self {
54            Self::Local => "local",
55            Self::Remote => "remote",
56        }
57    }
58}
59
60/// Serializes local keystore file access.
61static LOCAL_KEYSTORE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
62
63/// Run `forest-wallet [--remote-wallet] <args>` and return trimmed stdout.
64pub fn wallet(backend: Backend, args: &[&str]) -> anyhow::Result<String> {
65    Ok(String::from_utf8(run_wallet_raw(backend, args)?)?
66        .trim()
67        .to_string())
68}
69
70/// Same as [`wallet`] but yields raw stdout bytes (used by `export`).
71pub fn run_wallet_raw(backend: Backend, args: &[&str]) -> anyhow::Result<Vec<u8>> {
72    let _guard = (backend == Backend::Local).then(|| LOCAL_KEYSTORE_LOCK.lock());
73
74    let mut full = Vec::with_capacity(backend.extra_args().len() + args.len());
75    full.extend_from_slice(backend.extra_args());
76    full.extend_from_slice(args);
77
78    let output = Command::new("forest-wallet")
79        .args(&full)
80        .output()
81        .context("failed to spawn `forest-wallet`")?;
82    if !output.status.success() {
83        bail!(
84            "`forest-wallet {}` failed (status={}): {}",
85            full.join(" "),
86            output.status,
87            String::from_utf8_lossy(&output.stderr)
88        );
89    }
90    Ok(output.stdout)
91}
92
93/// Export `address` from the chosen backend into a temp file ready to feed
94/// back to `forest-wallet import`.
95pub fn export_to_temp_file(address: &str, backend: Backend) -> anyhow::Result<NamedTempFile> {
96    let raw = run_wallet_raw(backend, &["export", address])?;
97    let mut file = NamedTempFile::new_in(std::env::temp_dir())
98        .context("failed to create temp file for wallet export")?;
99    file.write_all(&raw)?;
100    file.flush()?;
101    Ok(file)
102}
103
104pub fn balance(address: &str, backend: Backend) -> anyhow::Result<String> {
105    wallet(backend, &["balance", address, "--exact-balance"])
106}
107
108/// Send with `--from`. `backend` chooses the signing keystore
109/// (local file vs `--remote-wallet`).
110///
111/// Retries on the transient `gas price is lower than min gas price` mpool
112/// error: the local CLI path estimates gas, then submits via `MpoolPush`,
113/// so a concurrent push that bumps the mempool's fee floor between
114/// estimate and push rejects an otherwise-valid message. Retry re-runs
115/// fee estimation so gas fields match whatever minimum gas price applies
116/// at the next submission.
117pub fn send_from(from: &str, to: &str, amount: &str, backend: Backend) -> anyhow::Result<String> {
118    send_from_and_maybe_wait(from, to, amount, backend, true)
119}
120
121pub fn send_from_no_wait(
122    from: &str,
123    to: &str,
124    amount: &str,
125    backend: Backend,
126) -> anyhow::Result<String> {
127    send_from_and_maybe_wait(from, to, amount, backend, false)
128}
129
130fn send_from_and_maybe_wait(
131    from: &str,
132    to: &str,
133    amount: &str,
134    backend: Backend,
135    wait: bool,
136) -> anyhow::Result<String> {
137    let mut args = vec!["send", to, amount, "--from", from];
138    if wait {
139        args.extend(["--wait-confidence", "0", "--wait-timeout", "10m"]);
140    }
141    let mut attempt = 1;
142    loop {
143        match wallet(backend, &args) {
144            Ok(out) => return Ok(out),
145            Err(e) if attempt < SEND_RETRIES => {
146                eprintln!(
147                    "error: {e:?} send {from} -> {to} failed on attempt {attempt}/{SEND_RETRIES}, retrying"
148                );
149                std::thread::sleep(SEND_RETRY_DELAY);
150                attempt += 1;
151            }
152            Err(e) => return Err(e),
153        }
154    }
155}
156
157/// Max attempts for [`send_from`].
158const SEND_RETRIES: usize = 3;
159/// Delay between [`send_from`] retries; one block-time at calibnet cadence
160/// is enough for the daemon's gas-price snapshot to refresh.
161const SEND_RETRY_DELAY: Duration = Duration::from_secs(15);
162
163/// Poll until `try_check` returns `Some` or [`POLL_TIMEOUT`] elapses, sleeping
164/// [`POLL_WAIT_TIME`] between attempts.
165async fn poll<F, Fut, T>(label: &str, mut try_check: F) -> anyhow::Result<T>
166where
167    F: FnMut() -> Fut,
168    Fut: Future<Output = anyhow::Result<Option<T>>>,
169{
170    let started = tokio::time::Instant::now();
171    let mut attempt = 0u32;
172    loop {
173        attempt += 1;
174        eprintln!("Polling {label} attempt {attempt}");
175        if let Some(value) = try_check().await? {
176            return Ok(value);
177        }
178        if started.elapsed() >= POLL_TIMEOUT {
179            bail!("Timed out waiting for {label} after {POLL_TIMEOUT:?}");
180        }
181        let remaining = POLL_TIMEOUT.saturating_sub(started.elapsed());
182        tokio::time::sleep(POLL_WAIT_TIME.min(remaining)).await;
183    }
184}
185
186/// Poll until the balance reported for `address` differs from `baseline`.
187pub async fn poll_until_changed(
188    address: &str,
189    baseline: &str,
190    backend: Backend,
191) -> anyhow::Result<String> {
192    let label = format!("{} balance change for {address}", backend.label());
193    let baseline = baseline.to_string();
194    poll(&label, || async {
195        let bal = balance(address, backend)?;
196        Ok((bal != baseline).then_some(bal))
197    })
198    .await
199}
200
201/// Poll until the balance reported for `address` is no longer [`FIL_ZERO`].
202pub async fn poll_until_funded(address: &str, backend: Backend) -> anyhow::Result<String> {
203    poll_until_changed(address, FIL_ZERO, backend).await
204}
205
206/// Delegated signer: create once on local, fund locally, mirror to remote
207/// for tests that query or sign.
208pub async fn funded_delegated_addr() -> &'static str {
209    static FUNDED_DELEGATED: OnceCell<String> = OnceCell::const_new();
210
211    FUNDED_DELEGATED
212        .get_or_try_init(|| async {
213            let addr = wallet(Backend::Local, &["new", "delegated"]).unwrap();
214            let fund_msg = send_from(
215                &FOREST_TEST_PRELOADED_ADDRESS,
216                &addr,
217                DELEGATE_FUND_AMT,
218                Backend::Local,
219            )
220            .unwrap();
221            eprintln!("delegated funding send to {addr} msg: {fund_msg}");
222            for backend in [Backend::Local, Backend::Remote] {
223                let funded = poll_until_funded(&addr, backend).await.unwrap();
224                eprintln!(
225                    "delegated wallet {addr} funded balance: {funded} ({})",
226                    backend.label()
227                );
228            }
229
230            let exported = export_to_temp_file(&addr, Backend::Local).unwrap();
231            let path = exported
232                .path()
233                .to_str()
234                .expect("temp path is not valid UTF-8");
235            let mirrored = wallet(Backend::Remote, &["import", path]).unwrap();
236            assert_eq!(mirrored, addr, "mirror mismatch: {mirrored} != {addr}");
237            Ok::<_, anyhow::Error>(addr)
238        })
239        .await
240        .unwrap()
241        .as_str()
242}
243
244static HTTP: LazyLock<reqwest::Client> = LazyLock::new(|| {
245    reqwest::Client::builder()
246        .timeout(Duration::from_secs(120))
247        .build()
248        .expect("failed to build reqwest client")
249});
250
251/// Cached `(token, http_url)` parsed once from `FULLNODE_API_INFO`.
252static API: LazyLock<anyhow::Result<(String, String)>> = LazyLock::new(|| {
253    let raw = std::env::var("FULLNODE_API_INFO").context("FULLNODE_API_INFO env var not set")?;
254    let (token, multiaddr) = raw
255        .split_once(':')
256        .context("FULLNODE_API_INFO must be `<token>:<multiaddr>`")?;
257    let parts: Vec<&str> = multiaddr.split('/').collect();
258    let host = parts
259        .get(2)
260        .filter(|s| !s.is_empty())
261        .with_context(|| format!("missing host in multiaddr `{multiaddr}`"))?;
262    let port = parts
263        .get(4)
264        .filter(|s| !s.is_empty())
265        .with_context(|| format!("missing port in multiaddr `{multiaddr}`"))?;
266    Ok((token.to_string(), format!("http://{host}:{port}/rpc/v1")))
267});
268
269fn api() -> anyhow::Result<&'static (String, String)> {
270    API.as_ref()
271        .map_err(|e| anyhow::anyhow!("FULLNODE_API_INFO unavailable: {e}"))
272}
273
274/// POST a JSON-RPC v1 request and return the `result` field, or `None` if
275/// the server responded without one.
276pub async fn rpc_call_opt(method: &str, params: Value) -> anyhow::Result<Option<Value>> {
277    let (token, url) = api()?;
278    let body = json!({
279        "jsonrpc": "2.0",
280        "id": 1,
281        "method": method,
282        "params": params,
283    });
284    let resp: Value = HTTP
285        .post(url)
286        .bearer_auth(token)
287        .json(&body)
288        .send()
289        .await
290        .with_context(|| format!("POST {url} for {method}"))?
291        .error_for_status()
292        .with_context(|| format!("HTTP error from {method}"))?
293        .json()
294        .await
295        .with_context(|| format!("decoding JSON-RPC response for {method}"))?;
296    if let Some(err) = resp.get("error").filter(|e| !e.is_null()) {
297        bail!("RPC error from {method}: {err}");
298    }
299    match resp.get("result") {
300        None => Ok(None),
301        Some(v) if v.is_null() => Ok(None),
302        Some(v) => Ok(Some(v.clone())),
303    }
304}
305
306/// Like [`rpc_call_opt`] but treats a missing `result` as an error and retries.
307pub async fn rpc_call_with_retry(method: &str, params: Value) -> anyhow::Result<Value> {
308    let mut attempt = 1;
309    loop {
310        let result = rpc_call_opt(method, params.clone()).await.and_then(|opt| {
311            opt.with_context(|| format!("missing `result` in response for {method}"))
312        });
313        match result {
314            Ok(v) => return Ok(v),
315            Err(e) if attempt < SEND_RETRIES => {
316                eprintln!(
317                    "error: {e:?} {method} failed on attempt {attempt}/{SEND_RETRIES}, retrying"
318                );
319                tokio::time::sleep(SEND_RETRY_DELAY).await;
320                attempt += 1;
321            }
322            Err(e) => return Err(e),
323        }
324    }
325}
326
327/// Extract a CID string from either a Lotus `{ "/": "bafy..." }` map or a
328/// plain string.
329pub fn cid_from_lotus_json_result(result: &Value) -> anyhow::Result<String> {
330    if let Some(s) = result.as_str() {
331        return Ok(s.to_string());
332    }
333    result
334        .get("/")
335        .and_then(|v| v.as_str())
336        .map(str::to_owned)
337        .with_context(|| format!("expected CID (lotus JSON or string), got {result}"))
338}
339
340/// Poll `Filecoin.StateSearchMsg` until the message is mined or [`POLL_TIMEOUT`] elapses.
341pub async fn poll_until_state_search_msg(msg_cid: &str) -> anyhow::Result<()> {
342    let label = format!("StateSearchMsg for {msg_cid}");
343    poll(&label, || async {
344        let params = json!([[], { "/": msg_cid }, 800_i64, true]);
345        Ok((rpc_call_opt("Filecoin.StateSearchMsg", params)
346            .await?
347            .is_some())
348        .then_some(()))
349    })
350    .await
351}
352
353/// Run `forest-cli <args>` and return trimmed stdout.
354pub fn forest_cli(args: &[&str]) -> anyhow::Result<String> {
355    let output = Command::new("forest-cli")
356        .args(args)
357        .output()
358        .context("failed to spawn `forest-cli`")?;
359    if !output.status.success() {
360        bail!(
361            "`forest-cli {}` failed (status={}): {}",
362            args.join(" "),
363            output.status,
364            String::from_utf8_lossy(&output.stderr)
365        );
366    }
367    Ok(String::from_utf8(output.stdout)?.trim().to_string())
368}
369
370/// Next nonce for an address
371pub fn mpool_nonce(address: &str) -> anyhow::Result<u64> {
372    let out = forest_cli(&["mpool", "nonce", address])?;
373    out.parse::<u64>()
374        .with_context(|| format!("invalid mpool nonce output: {out}"))
375}
376
377/// Pending message nonces for `address` via `Filecoin.MpoolPending`.
378pub async fn pending_nonces_for(address: &str) -> anyhow::Result<Vec<u64>> {
379    let result = rpc_call_with_retry("Filecoin.MpoolPending", json!([null])).await?;
380    let entries = result
381        .as_array()
382        .with_context(|| format!("expected MpoolPending array, got {result}"))?;
383    Ok(entries
384        .iter()
385        .filter_map(|entry| {
386            let msg = entry.get("Message")?;
387            (msg.get("From")?.as_str()? == address).then_some(msg.get("Nonce")?.as_u64()?)
388        })
389        .collect())
390}
391
392/// Poll until `address` has a pending message at `nonce`.
393pub async fn poll_until_pending_nonce(address: &str, nonce: u64) -> anyhow::Result<()> {
394    let label = format!("pending nonce {nonce} for {address}");
395    let address = address.to_string();
396    poll(&label, || async {
397        let nonces = pending_nonces_for(&address).await?;
398        Ok(nonces.contains(&nonce).then_some(()))
399    })
400    .await
401}
402
403/// Resolve the ETH equivalent of a Filecoin address via
404/// `Filecoin.FilecoinAddressToEthAddress`.
405pub async fn filecoin_to_eth(address: &str) -> anyhow::Result<String> {
406    let result = rpc_call_with_retry(
407        "Filecoin.FilecoinAddressToEthAddress",
408        json!([address, "pending"]),
409    )
410    .await?;
411    result
412        .as_str()
413        .map(str::to_owned)
414        .with_context(|| format!("expected string ETH address, got {result}"))
415}
416
417pub fn block_on<F: Future + Send + Sync + 'static>(future: F) -> F::Output
418where
419    F::Output: Send + Sync + 'static,
420{
421    std::thread::spawn(|| {
422        let rt = tokio::runtime::Builder::new_multi_thread()
423            .enable_all()
424            .build()
425            .unwrap();
426        rt.block_on(future)
427    })
428    .join()
429    .unwrap()
430}