use std::future::Future;
use std::io::Write as _;
use std::process::Command;
use std::sync::LazyLock;
use std::time::Duration;
use anyhow::{Context as _, bail};
use parking_lot::Mutex;
use serde_json::{Value, json};
use tempfile::NamedTempFile;
use tokio::sync::OnceCell;
pub static FOREST_TEST_PRELOADED_ADDRESS: LazyLock<String> = LazyLock::new(|| {
std::env::var("FOREST_TEST_PRELOADED_ADDRESS")
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.expect("FOREST_TEST_PRELOADED_ADDRESS must be set")
});
pub const FIL_AMT: &str = "500 atto FIL";
pub const FIL_ZERO: &str = "0 FIL";
pub const DELEGATE_FUND_AMT: &str = "3 micro FIL";
pub const POLL_TIMEOUT: Duration = Duration::from_secs(600);
pub const POLL_WAIT_TIME: Duration = Duration::from_secs(1);
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Backend {
Local,
Remote,
}
impl Backend {
fn extra_args(self) -> &'static [&'static str] {
match self {
Self::Local => &[],
Self::Remote => &["--remote-wallet"],
}
}
pub fn label(self) -> &'static str {
match self {
Self::Local => "local",
Self::Remote => "remote",
}
}
}
static LOCAL_KEYSTORE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
pub fn wallet(backend: Backend, args: &[&str]) -> anyhow::Result<String> {
Ok(String::from_utf8(run_wallet_raw(backend, args)?)?
.trim()
.to_string())
}
pub fn run_wallet_raw(backend: Backend, args: &[&str]) -> anyhow::Result<Vec<u8>> {
let _guard = (backend == Backend::Local).then(|| LOCAL_KEYSTORE_LOCK.lock());
let mut full = Vec::with_capacity(backend.extra_args().len() + args.len());
full.extend_from_slice(backend.extra_args());
full.extend_from_slice(args);
let output = Command::new("forest-wallet")
.args(&full)
.output()
.context("failed to spawn `forest-wallet`")?;
if !output.status.success() {
bail!(
"`forest-wallet {}` failed (status={}): {}",
full.join(" "),
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(output.stdout)
}
pub fn export_to_temp_file(address: &str, backend: Backend) -> anyhow::Result<NamedTempFile> {
let raw = run_wallet_raw(backend, &["export", address])?;
let mut file = NamedTempFile::new_in(std::env::temp_dir())
.context("failed to create temp file for wallet export")?;
file.write_all(&raw)?;
file.flush()?;
Ok(file)
}
pub fn balance(address: &str, backend: Backend) -> anyhow::Result<String> {
wallet(backend, &["balance", address, "--exact-balance"])
}
pub fn send_from(from: &str, to: &str, amount: &str, backend: Backend) -> anyhow::Result<String> {
send_from_and_maybe_wait(from, to, amount, backend, true)
}
pub fn send_from_no_wait(
from: &str,
to: &str,
amount: &str,
backend: Backend,
) -> anyhow::Result<String> {
send_from_and_maybe_wait(from, to, amount, backend, false)
}
fn send_from_and_maybe_wait(
from: &str,
to: &str,
amount: &str,
backend: Backend,
wait: bool,
) -> anyhow::Result<String> {
let mut args = vec!["send", to, amount, "--from", from];
if wait {
args.extend(["--wait-confidence", "0", "--wait-timeout", "1m"]);
}
let mut attempt = 1;
loop {
match wallet(backend, &args) {
Ok(out) => return Ok(out),
Err(_) if attempt < SEND_RETRIES => {
eprintln!(
"send {from} -> {to} failed on attempt {attempt}/{SEND_RETRIES}, retrying"
);
std::thread::sleep(SEND_RETRY_DELAY);
attempt += 1;
}
Err(e) => return Err(e),
}
}
}
const SEND_RETRIES: usize = 3;
const SEND_RETRY_DELAY: Duration = Duration::from_secs(15);
async fn poll<F, Fut, T>(label: &str, mut try_check: F) -> anyhow::Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = anyhow::Result<Option<T>>>,
{
let started = tokio::time::Instant::now();
let mut attempt = 0u32;
loop {
attempt += 1;
eprintln!("Polling {label} attempt {attempt}");
if let Some(value) = try_check().await? {
return Ok(value);
}
if started.elapsed() >= POLL_TIMEOUT {
bail!("Timed out waiting for {label} after {POLL_TIMEOUT:?}");
}
let remaining = POLL_TIMEOUT.saturating_sub(started.elapsed());
tokio::time::sleep(POLL_WAIT_TIME.min(remaining)).await;
}
}
pub async fn poll_until_changed(
address: &str,
baseline: &str,
backend: Backend,
) -> anyhow::Result<String> {
let label = format!("{} balance change for {address}", backend.label());
let baseline = baseline.to_string();
poll(&label, || async {
let bal = balance(address, backend)?;
Ok((bal != baseline).then_some(bal))
})
.await
}
pub async fn poll_until_funded(address: &str, backend: Backend) -> anyhow::Result<String> {
poll_until_changed(address, FIL_ZERO, backend).await
}
pub async fn funded_delegated_addr() -> &'static str {
static FUNDED_DELEGATED: OnceCell<String> = OnceCell::const_new();
FUNDED_DELEGATED
.get_or_try_init(|| async {
let addr = wallet(Backend::Local, &["new", "delegated"]).unwrap();
let fund_msg = send_from(
&FOREST_TEST_PRELOADED_ADDRESS,
&addr,
DELEGATE_FUND_AMT,
Backend::Local,
)
.unwrap();
eprintln!("delegated funding send to {addr} msg: {fund_msg}");
for backend in [Backend::Local, Backend::Remote] {
let funded = poll_until_funded(&addr, backend).await.unwrap();
eprintln!(
"delegated wallet {addr} funded balance: {funded} ({})",
backend.label()
);
}
let exported = export_to_temp_file(&addr, Backend::Local).unwrap();
let path = exported
.path()
.to_str()
.expect("temp path is not valid UTF-8");
let mirrored = wallet(Backend::Remote, &["import", path]).unwrap();
assert_eq!(mirrored, addr, "mirror mismatch: {mirrored} != {addr}");
Ok::<_, anyhow::Error>(addr)
})
.await
.unwrap()
.as_str()
}
static HTTP: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.expect("failed to build reqwest client")
});
static API: LazyLock<anyhow::Result<(String, String)>> = LazyLock::new(|| {
let raw = std::env::var("FULLNODE_API_INFO").context("FULLNODE_API_INFO env var not set")?;
let (token, multiaddr) = raw
.split_once(':')
.context("FULLNODE_API_INFO must be `<token>:<multiaddr>`")?;
let parts: Vec<&str> = multiaddr.split('/').collect();
let host = parts
.get(2)
.filter(|s| !s.is_empty())
.with_context(|| format!("missing host in multiaddr `{multiaddr}`"))?;
let port = parts
.get(4)
.filter(|s| !s.is_empty())
.with_context(|| format!("missing port in multiaddr `{multiaddr}`"))?;
Ok((token.to_string(), format!("http://{host}:{port}/rpc/v1")))
});
fn api() -> anyhow::Result<&'static (String, String)> {
API.as_ref()
.map_err(|e| anyhow::anyhow!("FULLNODE_API_INFO unavailable: {e}"))
}
pub async fn rpc_call_opt(method: &str, params: Value) -> anyhow::Result<Option<Value>> {
let (token, url) = api()?;
let body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
});
let resp: Value = HTTP
.post(url)
.bearer_auth(token)
.json(&body)
.send()
.await
.with_context(|| format!("POST {url} for {method}"))?
.error_for_status()
.with_context(|| format!("HTTP error from {method}"))?
.json()
.await
.with_context(|| format!("decoding JSON-RPC response for {method}"))?;
if let Some(err) = resp.get("error").filter(|e| !e.is_null()) {
bail!("RPC error from {method}: {err}");
}
match resp.get("result") {
None => Ok(None),
Some(v) if v.is_null() => Ok(None),
Some(v) => Ok(Some(v.clone())),
}
}
pub async fn rpc_call(method: &str, params: Value) -> anyhow::Result<Value> {
rpc_call_opt(method, params)
.await?
.with_context(|| format!("missing `result` in response for {method}"))
}
pub fn cid_from_lotus_json_result(result: &Value) -> anyhow::Result<String> {
if let Some(s) = result.as_str() {
return Ok(s.to_string());
}
result
.get("/")
.and_then(|v| v.as_str())
.map(str::to_owned)
.with_context(|| format!("expected CID (lotus JSON or string), got {result}"))
}
pub async fn poll_until_state_search_msg(msg_cid: &str) -> anyhow::Result<()> {
let label = format!("StateSearchMsg for {msg_cid}");
poll(&label, || async {
let params = json!([[], { "/": msg_cid }, 800_i64, true]);
Ok((rpc_call_opt("Filecoin.StateSearchMsg", params)
.await?
.is_some())
.then_some(()))
})
.await
}
pub fn forest_cli(args: &[&str]) -> anyhow::Result<String> {
let output = Command::new("forest-cli")
.args(args)
.output()
.context("failed to spawn `forest-cli`")?;
if !output.status.success() {
bail!(
"`forest-cli {}` failed (status={}): {}",
args.join(" "),
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)?.trim().to_string())
}
pub fn mpool_nonce(address: &str) -> anyhow::Result<u64> {
let out = forest_cli(&["mpool", "nonce", address])?;
out.parse::<u64>()
.with_context(|| format!("invalid mpool nonce output: {out}"))
}
pub async fn pending_nonces_for(address: &str) -> anyhow::Result<Vec<u64>> {
let result = rpc_call("Filecoin.MpoolPending", json!([null])).await?;
let entries = result
.as_array()
.with_context(|| format!("expected MpoolPending array, got {result}"))?;
Ok(entries
.iter()
.filter_map(|entry| {
let msg = entry.get("Message")?;
(msg.get("From")?.as_str()? == address).then_some(msg.get("Nonce")?.as_u64()?)
})
.collect())
}
pub async fn poll_until_pending_nonce(address: &str, nonce: u64) -> anyhow::Result<()> {
let label = format!("pending nonce {nonce} for {address}");
let address = address.to_string();
poll(&label, || async {
let nonces = pending_nonces_for(&address).await?;
Ok(nonces.contains(&nonce).then_some(()))
})
.await
}
pub async fn filecoin_to_eth(address: &str) -> anyhow::Result<String> {
let result = rpc_call(
"Filecoin.FilecoinAddressToEthAddress",
json!([address, "pending"]),
)
.await?;
result
.as_str()
.map(str::to_owned)
.with_context(|| format!("expected string ETH address, got {result}"))
}
pub fn block_on<F: Future + Send + Sync + 'static>(future: F) -> F::Output
where
F::Output: Send + Sync + 'static,
{
std::thread::spawn(|| {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(future)
})
.join()
.unwrap()
}