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 serde_json::{Value, json};
12use tempfile::NamedTempFile;
13use tokio::sync::OnceCell;
14
15pub static FOREST_TEST_PRELOADED_ADDRESS: LazyLock<String> = LazyLock::new(|| {
17 std::env::var("FOREST_TEST_PRELOADED_ADDRESS")
18 .ok()
19 .map(|s| s.trim().to_owned())
20 .filter(|s| !s.is_empty())
21 .expect("FOREST_TEST_PRELOADED_ADDRESS must be set")
22});
23
24pub const FIL_AMT: &str = "500 atto FIL";
26pub const FIL_ZERO: &str = "0 FIL";
28pub const DELEGATE_FUND_AMT: &str = "30 micro FIL";
30
31pub const POLL_TIMEOUT: Duration = Duration::from_secs(600);
33pub const POLL_WAIT_TIME: Duration = Duration::from_secs(1);
35
36#[derive(Copy, Clone, Debug, Eq, PartialEq)]
38pub enum Backend {
39 Local,
40 Remote,
41}
42
43impl Backend {
44 fn extra_args(self) -> &'static [&'static str] {
45 match self {
46 Self::Local => &[],
47 Self::Remote => &["--remote-wallet"],
48 }
49 }
50
51 pub fn label(self) -> &'static str {
52 match self {
53 Self::Local => "local",
54 Self::Remote => "remote",
55 }
56 }
57}
58
59pub fn wallet(backend: Backend, args: &[&str]) -> anyhow::Result<String> {
61 Ok(String::from_utf8(run_wallet_raw(backend, args)?)?
62 .trim()
63 .to_string())
64}
65
66pub fn run_wallet_raw(backend: Backend, args: &[&str]) -> anyhow::Result<Vec<u8>> {
68 let mut full = Vec::with_capacity(backend.extra_args().len() + args.len());
69 full.extend_from_slice(backend.extra_args());
70 full.extend_from_slice(args);
71 run("forest-wallet", &full)
72}
73
74fn run(program: &str, args: &[&str]) -> anyhow::Result<Vec<u8>> {
76 let output = Command::new(program)
77 .args(args)
78 .output()
79 .with_context(|| format!("failed to spawn `{program}`"))?;
80 if !output.status.success() {
81 bail!(
82 "`{program} {}` failed (status={}): {}",
83 args.join(" "),
84 output.status,
85 String::from_utf8_lossy(&output.stderr)
86 );
87 }
88 Ok(output.stdout)
89}
90
91fn run_str(program: &str, args: &[&str]) -> anyhow::Result<String> {
93 Ok(String::from_utf8(run(program, args)?)?.trim().to_owned())
94}
95
96pub fn export_to_temp_file(address: &str, backend: Backend) -> anyhow::Result<NamedTempFile> {
99 let raw = run_wallet_raw(backend, &["export", address])?;
100 let mut file = NamedTempFile::new_in(std::env::temp_dir())
101 .context("failed to create temp file for wallet export")?;
102 file.write_all(&raw)?;
103 file.flush()?;
104 Ok(file)
105}
106
107pub fn balance(address: &str, backend: Backend) -> anyhow::Result<String> {
108 wallet(backend, &["balance", address, "--exact-balance"])
109}
110
111pub fn send_from(from: &str, to: &str, amount: &str, backend: Backend) -> anyhow::Result<String> {
114 send_from_and_maybe_wait(from, to, amount, backend, true)
115}
116
117pub fn send_from_no_wait(
118 from: &str,
119 to: &str,
120 amount: &str,
121 backend: Backend,
122) -> anyhow::Result<String> {
123 send_from_and_maybe_wait(from, to, amount, backend, false)
124}
125
126fn send_from_and_maybe_wait(
127 from: &str,
128 to: &str,
129 amount: &str,
130 backend: Backend,
131 wait: bool,
132) -> anyhow::Result<String> {
133 let mut args = vec!["send", to, amount, "--from", from];
134 if wait {
135 args.extend(["--wait-confidence", "0", "--wait-timeout", "10m"]);
136 }
137 wallet(backend, &args)
138}
139
140const RPC_RETRIES: usize = 3;
142const RPC_RETRY_DELAY: Duration = Duration::from_secs(15);
145
146async fn poll<F, Fut, T>(label: &str, mut try_check: F) -> anyhow::Result<T>
149where
150 F: FnMut() -> Fut,
151 Fut: Future<Output = anyhow::Result<Option<T>>>,
152{
153 let started = tokio::time::Instant::now();
154 let mut attempt = 0u32;
155 loop {
156 attempt += 1;
157 eprintln!("Polling {label} attempt {attempt}");
158 if let Some(value) = try_check().await? {
159 return Ok(value);
160 }
161 if started.elapsed() >= POLL_TIMEOUT {
162 bail!("Timed out waiting for {label} after {POLL_TIMEOUT:?}");
163 }
164 let remaining = POLL_TIMEOUT.saturating_sub(started.elapsed());
165 tokio::time::sleep(POLL_WAIT_TIME.min(remaining)).await;
166 }
167}
168
169pub async fn poll_until_changed(
171 address: &str,
172 baseline: &str,
173 backend: Backend,
174) -> anyhow::Result<String> {
175 let label = format!("{} balance change for {address}", backend.label());
176 let baseline = baseline.to_string();
177 poll(&label, || async {
178 let bal = balance(address, backend)?;
179 Ok((bal != baseline).then_some(bal))
180 })
181 .await
182}
183
184pub async fn poll_until_funded(address: &str, backend: Backend) -> anyhow::Result<String> {
186 poll_until_changed(address, FIL_ZERO, backend).await
187}
188
189pub async fn lotus_exec_retrying_mpool(args: &[&str]) -> anyhow::Result<String> {
193 poll(&format!("lotus {}", args.join(" ")), || async {
194 match lotus_exec(args) {
195 Ok(out) => Ok(Some(out)),
196 Err(e) if format!("{e:#}").contains("check has failed") => Ok(None),
197 Err(e) => Err(e),
198 }
199 })
200 .await
201}
202
203pub async fn funded_delegated_addr() -> &'static str {
206 static FUNDED_DELEGATED: OnceCell<String> = OnceCell::const_new();
207
208 FUNDED_DELEGATED
209 .get_or_try_init(|| async {
210 let addr = wallet(Backend::Local, &["new", "delegated"]).unwrap();
211 let fund_msg = send_from(
212 &FOREST_TEST_PRELOADED_ADDRESS,
213 &addr,
214 DELEGATE_FUND_AMT,
215 Backend::Local,
216 )
217 .unwrap();
218 eprintln!("delegated funding send to {addr} msg: {fund_msg}");
219 for backend in [Backend::Local, Backend::Remote] {
220 let funded = poll_until_funded(&addr, backend).await.unwrap();
221 eprintln!(
222 "delegated wallet {addr} funded balance: {funded} ({})",
223 backend.label()
224 );
225 }
226
227 let exported = export_to_temp_file(&addr, Backend::Local).unwrap();
228 let path = exported
229 .path()
230 .to_str()
231 .expect("temp path is not valid UTF-8");
232 let mirrored = wallet(Backend::Remote, &["import", path]).unwrap();
233 assert_eq!(mirrored, addr, "mirror mismatch: {mirrored} != {addr}");
234 Ok::<_, anyhow::Error>(addr)
235 })
236 .await
237 .unwrap()
238 .as_str()
239}
240
241static HTTP: LazyLock<reqwest::Client> = LazyLock::new(|| {
242 reqwest::Client::builder()
243 .timeout(Duration::from_secs(120))
244 .build()
245 .expect("failed to build reqwest client")
246});
247
248static API: LazyLock<anyhow::Result<(String, String)>> = LazyLock::new(|| {
250 let raw = std::env::var("FULLNODE_API_INFO").context("FULLNODE_API_INFO env var not set")?;
251 let (token, multiaddr) = raw
252 .split_once(':')
253 .context("FULLNODE_API_INFO must be `<token>:<multiaddr>`")?;
254 let parts: Vec<&str> = multiaddr.split('/').collect();
255 let host = parts
256 .get(2)
257 .filter(|s| !s.is_empty())
258 .with_context(|| format!("missing host in multiaddr `{multiaddr}`"))?;
259 let port = parts
260 .get(4)
261 .filter(|s| !s.is_empty())
262 .with_context(|| format!("missing port in multiaddr `{multiaddr}`"))?;
263 Ok((token.to_string(), format!("http://{host}:{port}/rpc/v1")))
264});
265
266fn api() -> anyhow::Result<&'static (String, String)> {
267 API.as_ref()
268 .map_err(|e| anyhow::anyhow!("FULLNODE_API_INFO unavailable: {e}"))
269}
270
271pub fn lotus_client() -> anyhow::Result<crate::rpc::Client> {
273 let port = std::env::var("LOTUS_RPC_PORT")
274 .context("LOTUS_RPC_PORT not set; source the devnet test harness")?;
275 Ok(crate::rpc::Client::from_url(
276 format!("http://127.0.0.1:{port}/").parse()?,
277 ))
278}
279
280pub fn forest_client() -> anyhow::Result<crate::rpc::Client> {
282 crate::rpc::Client::default_or_from_env(None)
283}
284
285pub fn docker(args: &[&str]) -> anyhow::Result<String> {
286 run_str("docker", args).context("is the devnet up?")
287}
288
289pub fn lotus_exec(args: &[&str]) -> anyhow::Result<String> {
291 let mut full = vec!["exec", "lotus", "lotus"];
292 full.extend_from_slice(args);
293 docker(&full)
294}
295
296pub async fn rpc_call_opt(method: &str, params: Value) -> anyhow::Result<Option<Value>> {
299 let (token, url) = api()?;
300 let body = json!({
301 "jsonrpc": "2.0",
302 "id": 1,
303 "method": method,
304 "params": params,
305 });
306 let resp: Value = HTTP
307 .post(url)
308 .bearer_auth(token)
309 .json(&body)
310 .send()
311 .await
312 .with_context(|| format!("POST {url} for {method}"))?
313 .error_for_status()
314 .with_context(|| format!("HTTP error from {method}"))?
315 .json()
316 .await
317 .with_context(|| format!("decoding JSON-RPC response for {method}"))?;
318 if let Some(err) = resp.get("error").filter(|e| !e.is_null()) {
319 bail!("RPC error from {method}: {err}");
320 }
321 match resp.get("result") {
322 None => Ok(None),
323 Some(v) if v.is_null() => Ok(None),
324 Some(v) => Ok(Some(v.clone())),
325 }
326}
327
328pub async fn rpc_call_with_retry(method: &str, params: Value) -> anyhow::Result<Value> {
330 let mut attempt = 1;
331 loop {
332 let result = rpc_call_opt(method, params.clone()).await.and_then(|opt| {
333 opt.with_context(|| format!("missing `result` in response for {method}"))
334 });
335 match result {
336 Ok(v) => return Ok(v),
337 Err(e) if attempt < RPC_RETRIES => {
338 eprintln!(
339 "error: {e:?} {method} failed on attempt {attempt}/{RPC_RETRIES}, retrying"
340 );
341 tokio::time::sleep(RPC_RETRY_DELAY).await;
342 attempt += 1;
343 }
344 Err(e) => return Err(e),
345 }
346 }
347}
348
349pub fn cid_from_lotus_json_result(result: &Value) -> anyhow::Result<String> {
352 if let Some(s) = result.as_str() {
353 return Ok(s.to_string());
354 }
355 result
356 .get("/")
357 .and_then(|v| v.as_str())
358 .map(str::to_owned)
359 .with_context(|| format!("expected CID (lotus JSON or string), got {result}"))
360}
361
362pub async fn poll_until_state_search_msg(msg_cid: &str) -> anyhow::Result<()> {
364 let label = format!("StateSearchMsg for {msg_cid}");
365 poll(&label, || async {
366 let params = json!([[], { "/": msg_cid }, 800_i64, true]);
367 Ok((rpc_call_opt("Filecoin.StateSearchMsg", params)
368 .await?
369 .is_some())
370 .then_some(()))
371 })
372 .await
373}
374
375pub fn forest_cli(args: &[&str]) -> anyhow::Result<String> {
376 run_str("forest-cli", args)
377}
378
379pub fn mpool_nonce(address: &str) -> anyhow::Result<u64> {
381 let out = forest_cli(&["mpool", "nonce", address])?;
382 out.parse::<u64>()
383 .with_context(|| format!("invalid mpool nonce output: {out}"))
384}
385
386pub async fn pending_nonces_for(address: &str) -> anyhow::Result<Vec<u64>> {
388 let result = rpc_call_with_retry("Filecoin.MpoolPending", json!([null])).await?;
389 let entries = result
390 .as_array()
391 .with_context(|| format!("expected MpoolPending array, got {result}"))?;
392 Ok(entries
393 .iter()
394 .filter_map(|entry| {
395 let msg = entry.get("Message")?;
396 (msg.get("From")?.as_str()? == address).then_some(msg.get("Nonce")?.as_u64()?)
397 })
398 .collect())
399}
400
401pub async fn poll_until_pending_nonce(address: &str, nonce: u64) -> anyhow::Result<()> {
403 let label = format!("pending nonce {nonce} for {address}");
404 let address = address.to_string();
405 poll(&label, || async {
406 let nonces = pending_nonces_for(&address).await?;
407 Ok(nonces.contains(&nonce).then_some(()))
408 })
409 .await
410}
411
412pub async fn filecoin_to_eth(address: &str) -> anyhow::Result<String> {
415 let result = rpc_call_with_retry(
416 "Filecoin.FilecoinAddressToEthAddress",
417 json!([address, "pending"]),
418 )
419 .await?;
420 result
421 .as_str()
422 .map(str::to_owned)
423 .with_context(|| format!("expected string ETH address, got {result}"))
424}
425
426pub fn block_on<F: Future + Send + 'static>(future: F) -> F::Output
427where
428 F::Output: Send + 'static,
429{
430 std::thread::spawn(|| {
431 let rt = tokio::runtime::Builder::new_multi_thread()
432 .enable_all()
433 .build()
434 .unwrap();
435 rt.block_on(future)
436 })
437 .join()
438 .unwrap_or_else(|payload| std::panic::resume_unwind(payload))
440}