forest/dev/subcommands/tests_cmd/
helpers.rs1use 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
25pub 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
34pub const FIL_AMT: &str = "500 atto FIL";
36pub const FIL_ZERO: &str = "0 FIL";
38pub const DELEGATE_FUND_AMT: &str = "30 micro FIL";
40
41pub const POLL_TIMEOUT: Duration = Duration::from_secs(600);
43pub const POLL_WAIT_TIME: Duration = Duration::from_secs(1);
45const MESSAGE_LOOKBACK: ChainEpoch = 800;
47
48#[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
71pub 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
78pub 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
86fn 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
103fn run_str(program: &str, args: &[&str]) -> anyhow::Result<String> {
105 Ok(String::from_utf8(run(program, args)?)?.trim().to_owned())
106}
107
108pub 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
123pub 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
152const RPC_RETRIES: usize = 3;
154const RPC_RETRY_DELAY: Duration = Duration::from_secs(15);
157
158pub 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
181pub 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
196pub 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
218pub 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
231fn 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
246pub 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
259pub 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
304static 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
327pub 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
336pub 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
345pub 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
352pub 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
384pub 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
405pub 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
418pub 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
431fn is_unseen_message_error(e: &ClientError) -> bool {
434 matches!(e, ClientError::Call(obj) if obj.message().contains(FAILED_TO_LOAD_MESSAGE))
435}
436
437pub async fn poll_until_message_executed(
440 client: &Client,
441 cid: Cid,
442) -> anyhow::Result<MessageLookup> {
443 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
463pub 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
470pub 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
485pub 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
496pub 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 .unwrap_or_else(|payload| std::panic::resume_unwind(payload))
524}