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 parking_lot::Mutex;
12use serde_json::{Value, json};
13use tempfile::NamedTempFile;
14use tokio::sync::OnceCell;
15
16pub 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
25pub const FIL_AMT: &str = "500 atto FIL";
27pub const FIL_ZERO: &str = "0 FIL";
29pub const DELEGATE_FUND_AMT: &str = "30 micro FIL";
31
32pub const POLL_TIMEOUT: Duration = Duration::from_secs(600);
34pub const POLL_WAIT_TIME: Duration = Duration::from_secs(1);
36
37#[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
60static LOCAL_KEYSTORE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
62
63pub 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
70pub 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
93pub 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
108pub 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
157const SEND_RETRIES: usize = 3;
159const SEND_RETRY_DELAY: Duration = Duration::from_secs(15);
162
163async 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
186pub 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
201pub async fn poll_until_funded(address: &str, backend: Backend) -> anyhow::Result<String> {
203 poll_until_changed(address, FIL_ZERO, backend).await
204}
205
206pub 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
251static 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
274pub 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
306pub 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
327pub 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
340pub 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
353pub 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
370pub 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
377pub 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
392pub 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
403pub 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}