From 21f50e528b0457506cf48cde576d78acd8d87564 Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 30 Apr 2026 05:54:15 +0530 Subject: [PATCH 1/9] refactor from bash to rust --- .github/workflows/forest.yml | 31 -- .../tests/calibnet_delegated_wallet_check.sh | 118 ------- scripts/tests/calibnet_wallet_check.sh | 221 +------------ tests/calibnet_wallet.rs | 204 ++++++++++++ tests/common/calibnet_wallet_helpers.rs | 290 ++++++++++++++++++ 5 files changed, 498 insertions(+), 366 deletions(-) delete mode 100755 scripts/tests/calibnet_delegated_wallet_check.sh create mode 100644 tests/calibnet_wallet.rs create mode 100644 tests/common/calibnet_wallet_helpers.rs diff --git a/.github/workflows/forest.yml b/.github/workflows/forest.yml index fa76db579265..d596f3ef8f5b 100644 --- a/.github/workflows/forest.yml +++ b/.github/workflows/forest.yml @@ -250,36 +250,6 @@ jobs: ./scripts/tests/calibnet_wallet_check.sh "$CALIBNET_WALLET" fi timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} - calibnet-delegated-wallet-check: - concurrency: - group: calibnet-wallet-tests - cancel-in-progress: false - needs: - - build-ubuntu - - calibnet-wallet-check - name: Delegated wallet tests - runs-on: ubuntu-24.04 - steps: - - uses: actions/cache@v5 - with: - path: "${{ env.FIL_PROOFS_PARAMETER_CACHE }}" - key: proof-params-keys - - uses: actions/checkout@v6 - - uses: actions/download-artifact@v8 - with: - name: "forest-${{ runner.os }}" - path: ~/.cargo/bin - - name: Set permissions - run: | - chmod +x ~/.cargo/bin/forest* - - name: Delegated wallet commands check - env: - CALIBNET_WALLET: "${{ secrets.CALIBNET_WALLET }}" - run: | - if [[ "$CALIBNET_WALLET" != "" ]]; then - ./scripts/tests/calibnet_delegated_wallet_check.sh "$CALIBNET_WALLET" - fi - timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} calibnet-export-check-v1: if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (contains(github.event.pull_request.labels.*.name, 'Release') || contains(github.event.pull_request.labels.*.name, 'Snapshot'))) }} needs: @@ -585,7 +555,6 @@ jobs: - calibnet-stateless-rpc-check - state-migrations-check - calibnet-wallet-check - - calibnet-delegated-wallet-check - calibnet-no-discovery-checks - calibnet-kademlia-checks - calibnet-eth-mapping-check diff --git a/scripts/tests/calibnet_delegated_wallet_check.sh b/scripts/tests/calibnet_delegated_wallet_check.sh deleted file mode 100755 index 7bbb3396f242..000000000000 --- a/scripts/tests/calibnet_delegated_wallet_check.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env bash -# This script checks delegated wallet features of the forest node and the forest-cli. -# It requires both `forest` and `forest-cli` to be in the PATH. - -set -euxo pipefail - -source "$(dirname "$0")/harness.sh" - -forest_wallet_init "$@" - -# Amount to send (note: `send` command defaults to FIL if no units are specified) -FIL_AMT="500 atto FIL" - -# Amount for an empty wallet -FIL_ZERO="0 FIL" - -# The preloaded address -ADDR_ONE=$($FOREST_WALLET_PATH list | tail -1 | cut -d ' ' -f2) - -sleep 5s - -: Begin delegated wallet tests - -# The following steps do basic delegated wallet handling tests. - -echo "Creating delegated wallet DELEGATE_ADDR_ONE" -DELEGATE_ADDR_ONE=$($FOREST_WALLET_PATH new delegated) -echo "$DELEGATE_ADDR_ONE" -$FOREST_WALLET_PATH export "$DELEGATE_ADDR_ONE" > delegated_wallet.key -$FOREST_WALLET_PATH --remote-wallet import delegated_wallet.key - -# Fund delegated wallet from preloaded wallet -DELEGATE_FUND_AMT="3 micro FIL" -$FOREST_WALLET_PATH set-default "$ADDR_ONE" -MSG_DELEGATE_FUND=$($FOREST_WALLET_PATH send "$DELEGATE_ADDR_ONE" "$DELEGATE_FUND_AMT") -: "$MSG_DELEGATE_FUND" - -DELEGATE_ADDR_ONE_BALANCE=$FIL_ZERO -i=0 -while [[ $i != 20 && $DELEGATE_ADDR_ONE_BALANCE == "$FIL_ZERO" ]]; do - i=$((i+1)) - : "Checking DELEGATE_ADDR_ONE balance $i/20" - sleep 30s - DELEGATE_ADDR_ONE_BALANCE=$($FOREST_WALLET_PATH balance "$DELEGATE_ADDR_ONE" --exact-balance) -done -if [[ $DELEGATE_ADDR_ONE_BALANCE == "$FIL_ZERO" ]]; then - echo "Timed out waiting for $DELEGATE_ADDR_ONE balance to update after 20 retries" - exit 1 -fi - -echo "Creating delegated wallet DELEGATE_ADDR_TWO" -DELEGATE_ADDR_TWO=$($FOREST_WALLET_PATH new delegated) -echo "$DELEGATE_ADDR_TWO" -$FOREST_WALLET_PATH set-default "$DELEGATE_ADDR_ONE" - -echo "Creating delegated (remote) wallet DELEGATE_ADDR_THREE" -DELEGATE_ADDR_THREE=$($FOREST_WALLET_PATH --remote-wallet new delegated) -echo "$DELEGATE_ADDR_THREE" -$FOREST_WALLET_PATH --remote-wallet set-default "$DELEGATE_ADDR_ONE" - -$FOREST_WALLET_PATH list -$FOREST_WALLET_PATH --remote-wallet list - -MSG_DELEGATE_TWO=$($FOREST_WALLET_PATH send "$DELEGATE_ADDR_TWO" "$FIL_AMT") -: "$MSG_DELEGATE_TWO" - -MSG_DELEGATE_THREE=$($FOREST_WALLET_PATH send "$DELEGATE_ADDR_THREE" "$FIL_AMT") -: "$MSG_DELEGATE_THREE" - -DELEGATE_ADDR_TWO_BALANCE=$FIL_ZERO -i=0 -while [[ $i != 20 && $DELEGATE_ADDR_TWO_BALANCE == "$FIL_ZERO" ]]; do - i=$((i+1)) - : "Checking DELEGATE_ADDR_TWO balance $i/20" - sleep 30s - DELEGATE_ADDR_TWO_BALANCE=$($FOREST_WALLET_PATH balance "$DELEGATE_ADDR_TWO" --exact-balance) -done -if [[ $DELEGATE_ADDR_TWO_BALANCE == "$FIL_ZERO" ]]; then - echo "Timed out waiting for $DELEGATE_ADDR_TWO balance to update after 20 retries" - exit 1 -fi - -DELEGATE_ADDR_THREE_BALANCE=$FIL_ZERO -i=0 -while [[ $i != 20 && $DELEGATE_ADDR_THREE_BALANCE == "$FIL_ZERO" ]]; do - i=$((i+1)) - : "Checking DELEGATE_ADDR_THREE balance $i/20" - sleep 30s - DELEGATE_ADDR_THREE_BALANCE=$($FOREST_WALLET_PATH --remote-wallet balance "$DELEGATE_ADDR_THREE" --exact-balance) -done -if [[ $DELEGATE_ADDR_THREE_BALANCE == "$FIL_ZERO" ]]; then - echo "Timed out waiting for $DELEGATE_ADDR_THREE balance to update after 20 retries" - exit 1 -fi - -$FOREST_WALLET_PATH list -$FOREST_WALLET_PATH --remote-wallet list - -MSG_DELEGATE_FOUR=$($FOREST_WALLET_PATH --remote-wallet send "$DELEGATE_ADDR_THREE" "$FIL_AMT") -: "$MSG_DELEGATE_FOUR" - -DELEGATE_ADDR_REMOTE_THREE_BALANCE=$DELEGATE_ADDR_THREE_BALANCE -i=0 -while [[ $i != 20 && $DELEGATE_ADDR_REMOTE_THREE_BALANCE == "$DELEGATE_ADDR_THREE_BALANCE" ]]; do - i=$((i+1)) - : "Checking DELEGATE_ADDR_THREE balance $i/20" - sleep 30s - DELEGATE_ADDR_REMOTE_THREE_BALANCE=$($FOREST_WALLET_PATH --remote-wallet balance "$DELEGATE_ADDR_THREE" --exact-balance) -done -if [[ $DELEGATE_ADDR_REMOTE_THREE_BALANCE == "$DELEGATE_ADDR_THREE_BALANCE" ]]; then - echo "Timed out waiting for $DELEGATE_ADDR_THREE balance to update after 20 retries" - exit 1 -fi - -$FOREST_WALLET_PATH list -$FOREST_WALLET_PATH --remote-wallet list - -: End delegated wallet tests diff --git a/scripts/tests/calibnet_wallet_check.sh b/scripts/tests/calibnet_wallet_check.sh index ce8cb138add2..ee26e3681ec6 100755 --- a/scripts/tests/calibnet_wallet_check.sh +++ b/scripts/tests/calibnet_wallet_check.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# This script checks wallet features of the forest node and the forest-cli. -# It also checks some RPC methods that need a remote wallet. -# It requires both `forest` and `forest-cli` to be in the PATH. +# Drives the calibnet wallet integration tests in `tests/calibnet_wallet.rs`. +# `harness.sh::forest_wallet_init` brings up the daemon, imports the +# preloaded wallet into both backends, and exports `FULLNODE_API_INFO`. set -euxo pipefail @@ -9,217 +9,4 @@ source "$(dirname "$0")/harness.sh" forest_wallet_init "$@" -# Test commented out due to it being flaky. See the tracking issue: https://github.com/ChainSafe/forest/issues/4849 -# : Begin Filecoin.MarketAddBalance test -# -# FOREST_URL='http://127.0.0.1:2345/rpc/v1' -# -# # Amount to add to the Market actor (in attoFIL) -# MARKET_FIL_AMT="23" -# -# # The preloaded address -# REMOTE_ADDR=$($FOREST_WALLET_PATH --remote-wallet list | tail -1 | cut -d ' ' -f2) -# -# JSON=$(curl -s -X POST "$FOREST_URL" \ -# --header 'Accept: application/json' \ -# --header 'Content-Type: application/json' \ -# --header "Authorization: Bearer $ADMIN_TOKEN" \ -# --data "$(jq -n --arg addr "$REMOTE_ADDR" --arg amt "$MARKET_FIL_AMT" '{jsonrpc: "2.0", id: 1, method: "Filecoin.MarketAddBalance", params: [$addr, $addr, $amt]}')") -# -# echo "$JSON" -# -# if [[ $(echo "$JSON" | jq -e '.result') == "null" ]]; then -# echo "Error while sending message." -# exit 1 -# fi -# -# MSG_CID=$(echo "$JSON" | jq -r '.result["/"]') -# echo "Message cid: $MSG_CID" -# -# # Try 30 times (in other words wait for 5 tipsets) -# for i in {1..30} -# do -# sleep 5s -# echo "Attempt $i:" -# -# JSON=$(curl -s -X POST "$FOREST_URL" \ -# --header 'Content-Type: application/json' \ -# --data "$(jq -n --arg cid "$MSG_CID" '{jsonrpc: "2.0", id: 1, method: "Filecoin.StateSearchMsg", params: [[], {"/": $cid}, 800, true]}')") -# -# echo "$JSON" -# -# # Check if the message has been mined. -# if echo "$JSON" | jq -e '.result' > /dev/null; then -# echo "Message found, exiting." -# break -# fi -# -# echo -e "\n" -# done -# -# if [[ $(echo "$JSON" | jq -e '.result') == "null" ]]; then -# echo "Error while sending message." -# exit 1 -# fi - -: Begin wallet tests - -# The following steps do basic wallet handling tests. - -# Amount to send to 2nd address (note: `send` command defaults to FIL if no units are specified) -FIL_AMT="500 atto FIL" - -# Amount for an empty wallet -FIL_ZERO="0 FIL" - -# The preloaded address -ADDR_ONE=$($FOREST_WALLET_PATH list | tail -1 | cut -d ' ' -f2) - -sleep 5s - -$FOREST_WALLET_PATH export "$ADDR_ONE" > preloaded_wallet.test.key -$FOREST_WALLET_PATH delete "$ADDR_ONE" -$FOREST_WALLET_PATH --remote-wallet delete "$ADDR_ONE" -ROUNDTRIP_ADDR=$($FOREST_WALLET_PATH import preloaded_wallet.test.key) -if [[ "$ADDR_ONE" != "$ROUNDTRIP_ADDR" ]]; then - echo "Wallet address should be the same after a roundtrip" - exit 1 -fi - -ROUNDTRIP_ADDR=$($FOREST_WALLET_PATH --remote-wallet import preloaded_wallet.test.key) -if [[ "$ADDR_ONE" != "$ROUNDTRIP_ADDR" ]]; then - echo "Wallet address should be the same after a roundtrip" - exit 1 -fi - -wget -O metrics.log http://localhost:6116/metrics - -sleep 5s - -# Show balances -$FOREST_WALLET_PATH list - -FOREST_URL="http://127.0.0.1:2345/rpc/v1" - -echo "Creating a new address to send FIL to" -ADDR_TWO=$($FOREST_WALLET_PATH new) -echo "$ADDR_TWO" -$FOREST_WALLET_PATH set-default "$ADDR_ONE" - -echo "Creating a new (remote) address to send FIL to" -ADDR_THREE=$($FOREST_WALLET_PATH --remote-wallet new) -echo "$ADDR_THREE" -$FOREST_WALLET_PATH --remote-wallet set-default "$ADDR_ONE" - -$FOREST_WALLET_PATH list -$FOREST_WALLET_PATH --remote-wallet list - -MSG=$($FOREST_WALLET_PATH send "$ADDR_TWO" "$FIL_AMT") -: "$MSG" - -MSG_REMOTE=$($FOREST_WALLET_PATH --remote-wallet send "$ADDR_THREE" "$FIL_AMT") -: "$MSG_REMOTE" - -ADDR_TWO_BALANCE=$FIL_ZERO -i=0 -while [[ $i != 20 && $ADDR_TWO_BALANCE == "$FIL_ZERO" ]]; do - i=$((i+1)) - : "Checking balance $i/20" - sleep 30s - ADDR_TWO_BALANCE=$($FOREST_WALLET_PATH balance "$ADDR_TWO" --exact-balance) -done -if [[ $ADDR_TWO_BALANCE == "$FIL_ZERO" ]]; then - echo "Timed out waiting for $ADDR_TWO balance to update after 20 retries" - exit 1 -fi - -ADDR_THREE_BALANCE=$FIL_ZERO -i=0 -while [[ $i != 20 && $ADDR_THREE_BALANCE == "$FIL_ZERO" ]]; do - i=$((i+1)) - - : "Checking balance $i/20" - sleep 30s - ADDR_THREE_BALANCE=$($FOREST_WALLET_PATH --remote-wallet balance "$ADDR_THREE" --exact-balance) -done -if [[ $ADDR_THREE_BALANCE == "$FIL_ZERO" ]]; then - echo "Timed out waiting for $ADDR_THREE balance to update after 20 retries" - exit 1 -fi - -ETH_ADDR_TWO=$(curl -s -X POST "$FOREST_URL" \ - -H 'Content-Type: application/json' \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - --data "$(jq -n --arg addr "$ADDR_TWO" '{jsonrpc: "2.0", id: 1, method: "Filecoin.FilecoinAddressToEthAddress", params: [$addr, "pending"]}')" \ - | jq -r '.result') -echo "ETH address: $ETH_ADDR_TWO" - -ETH_ADDR_THREE=$(curl -s -X POST "$FOREST_URL" \ - -H 'Content-Type: application/json' \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - --data "$(jq -n --arg addr "$ADDR_THREE" '{jsonrpc: "2.0", id: 1, method: "Filecoin.FilecoinAddressToEthAddress", params: [$addr, "pending"]}')" \ - | jq -r '.result') -echo "ETH address: $ETH_ADDR_THREE" - -MSG_ETH=$($FOREST_WALLET_PATH send "$ETH_ADDR_TWO" "$FIL_AMT") -: "$MSG_ETH" - -MSG_ETH_REMOTE=$($FOREST_WALLET_PATH --remote-wallet send "$ETH_ADDR_THREE" "$FIL_AMT") -: "$MSG_ETH_REMOTE" - -ETH_ADDR_TWO_BALANCE=$ADDR_TWO_BALANCE -i=0 -while [[ $i != 20 && $ETH_ADDR_TWO_BALANCE == "$ADDR_TWO_BALANCE" ]]; do - i=$((i+1)) - - : "Checking balance $i/20" - sleep 30s - ETH_ADDR_TWO_BALANCE=$($FOREST_WALLET_PATH balance "$ADDR_TWO" --exact-balance) -done -if [[ $ETH_ADDR_TWO_BALANCE == "$ADDR_TWO_BALANCE" ]]; then - echo "Timed out waiting for $ETH_ADDR_TWO balance to update after 20 retries" - exit 1 -fi - -ETH_ADDR_THREE_BALANCE=$ADDR_THREE_BALANCE -i=0 -while [[ $i != 20 && $ETH_ADDR_THREE_BALANCE == "$ADDR_THREE_BALANCE" ]]; do - i=$((i+1)) - - : "Checking balance $i/20" - sleep 30s - ETH_ADDR_THREE_BALANCE=$($FOREST_WALLET_PATH --remote-wallet balance "$ADDR_THREE" --exact-balance) -done -if [[ $ETH_ADDR_THREE_BALANCE == "$ADDR_THREE_BALANCE" ]]; then - echo "Timed out waiting for $ETH_ADDR_THREE balance to update after 20 retries" - exit 1 -fi - -# wallet list should contain address two with transferred FIL amount -$FOREST_WALLET_PATH list -$FOREST_WALLET_PATH --remote-wallet list - -# wallet delete tests -ADDR_DEL=$(forest-wallet new) - -forest-wallet delete "$ADDR_DEL" - -# Validate that the wallet no longer exists. -forest-wallet list | grep --null-data --invert-match "${ADDR_DEL}" - -# wallet delete tests -ADDR_DEL=$(forest-wallet --remote-wallet new) - -forest-wallet --remote-wallet delete "$ADDR_DEL" - -# Validate that the wallet no longer exists. -forest-wallet --remote-wallet list | grep --null-data --invert-match "${ADDR_DEL}" - -# TODO: Uncomment this check once the send command is fixed -# # `$ADDR_TWO_BALANCE` is unitless (`list` command formats "500" as "500 atto FIL"), -# # so we need to truncate units from `$FIL_AMT` for proper comparison -# FIL_AMT=$(echo "$FIL_AMT"| cut -d ' ' -f 1) -# if [ "$ADDR_TWO_BALANCE" != "$FIL_AMT" ]; then -# echo "FIL amount should match" -# exit 1 -# fi +cargo test --profile quick-test --test calibnet_wallet -- --ignored --nocapture diff --git a/tests/calibnet_wallet.rs b/tests/calibnet_wallet.rs new file mode 100644 index 000000000000..4da4b536d38b --- /dev/null +++ b/tests/calibnet_wallet.rs @@ -0,0 +1,204 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Calibnet wallet integration tests. Each `#[tokio::test]` is `#[ignore]` +//! and assumes: +//! - `forest-wallet` is on `PATH`, +//! - a Forest daemon is running and synced to calibnet, +//! - [`PRELOADED_ADDRESS`] is funded and imported into both backends, +//! - `FULLNODE_API_INFO` is exported. + +#[path = "common/calibnet_wallet_helpers.rs"] +mod helpers; + +use anyhow::Context as _; +use helpers::*; +use serde_json::json; +use tokio::sync::OnceCell; + +static FUNDED_DELEGATED: OnceCell = OnceCell::const_new(); + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn local_export_import_roundtrip() -> anyhow::Result<()> { + let addr = wallet(&["new"])?; + assert_export_import_roundtrip(&addr, false) +} + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn remote_export_import_roundtrip() -> anyhow::Result<()> { + let addr = wallet_remote(&["new"])?; + assert_export_import_roundtrip(&addr, true) +} + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn market_add_balance_message_on_chain() -> anyhow::Result<()> { + const ATTO_FIL: &str = "23"; + let result = rpc_call( + "Filecoin.MarketAddBalance", + json!([PRELOADED_ADDRESS, PRELOADED_ADDRESS, ATTO_FIL]), + ) + .await?; + let msg_cid = cid_from_lotus_json_result(&result)?; + poll_until_state_search_msg(&msg_cid).await +} + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn send_to_local_filecoin_address() -> anyhow::Result<()> { + let target = wallet(&["new"])?; + let _ = send_from(PRELOADED_ADDRESS, &target, FIL_AMT, true)?; + let _ = poll_until_funded(&target, false).await?; + Ok(()) +} + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn send_to_remote_filecoin_address() -> anyhow::Result<()> { + let target = wallet_remote(&["new"])?; + let _ = send_from(PRELOADED_ADDRESS, &target, FIL_AMT, true)?; + let _ = poll_until_funded(&target, true).await?; + Ok(()) +} + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn send_to_local_eth_equivalent() -> anyhow::Result<()> { + send_to_eth_equivalent(false).await +} + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn send_to_remote_eth_equivalent() -> anyhow::Result<()> { + send_to_eth_equivalent(true).await +} + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn local_wallet_delete() -> anyhow::Result<()> { + assert_create_delete(false) +} + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn remote_wallet_delete() -> anyhow::Result<()> { + assert_create_delete(true) +} + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn delegated_send_local_to_local() -> anyhow::Result<()> { + let funded = funded_delegated_addr().await?; + let target = wallet(&["new", "delegated"])?; + let _ = send_from(funded, &target, FIL_AMT, true)?; + let _ = poll_until_funded(&target, false).await?; + Ok(()) +} + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn delegated_send_local_to_remote() -> anyhow::Result<()> { + let funded = funded_delegated_addr().await?; + let target = wallet_remote(&["new", "delegated"])?; + let _ = send_from(funded, &target, FIL_AMT, true)?; + let _ = poll_until_funded(&target, true).await?; + Ok(()) +} + +#[tokio::test] +#[ignore = "requires a running calibnet Forest daemon"] +async fn delegated_remote_send() -> anyhow::Result<()> { + let funded = funded_delegated_addr().await?; + let target = wallet_remote(&["new", "delegated"])?; + let baseline = balance(&target, true)?; + let _ = send_from(funded, &target, FIL_AMT, true)?; + if baseline == FIL_ZERO { + let _ = poll_until_funded(&target, true).await?; + } else { + let _ = poll_until_changed(&target, &baseline, true).await?; + } + Ok(()) +} + +async fn funded_delegated_addr() -> anyhow::Result<&'static str> { + let addr = FUNDED_DELEGATED + .get_or_try_init(|| async { + let addr = wallet(&["new", "delegated"])?; + let _ = send_from(PRELOADED_ADDRESS, &addr, DELEGATE_FUND_AMT, true)?; + let _ = poll_until_funded(&addr, false).await?; + let exported = export_to_temp_file(&addr, false)?; + let path = exported + .path() + .to_str() + .context("temp path is not valid UTF-8")?; + let mirrored = wallet_remote(&["import", path])?; + anyhow::ensure!(mirrored == addr, "mirror mismatch: {mirrored} != {addr}"); + Ok::<_, anyhow::Error>(addr) + }) + .await?; + Ok(addr.as_str()) +} + +async fn send_to_eth_equivalent(remote: bool) -> anyhow::Result<()> { + let target = if remote { + wallet_remote(&["new"])? + } else { + wallet(&["new"])? + }; + let _ = send_from(PRELOADED_ADDRESS, &target, FIL_AMT, true)?; + let baseline = poll_until_funded(&target, remote).await?; + + let eth = filecoin_to_eth(&target).await?; + let _ = send_from(PRELOADED_ADDRESS, ð, FIL_AMT, true)?; + let _ = poll_until_changed(&target, &baseline, remote).await?; + Ok(()) +} + +fn assert_export_import_roundtrip(address: &str, remote: bool) -> anyhow::Result<()> { + let exported = export_to_temp_file(address, remote)?; + let path = exported + .path() + .to_str() + .context("temp path is not valid UTF-8")?; + + let delete_args = ["delete", address]; + let import_args = ["import", path]; + let imported = if remote { + let _ = wallet_remote(&delete_args)?; + wallet_remote(&import_args)? + } else { + let _ = wallet(&delete_args)?; + wallet(&import_args)? + }; + anyhow::ensure!( + imported == address, + "round-trip mismatch on {} backend: {imported} != {address}", + if remote { "remote" } else { "local" } + ); + Ok(()) +} + +fn assert_create_delete(remote: bool) -> anyhow::Result<()> { + let new_args = ["new"]; + let addr = if remote { + wallet_remote(&new_args)? + } else { + wallet(&new_args)? + }; + + let delete_args = ["delete", &addr]; + let listing = if remote { + let _ = wallet_remote(&delete_args)?; + wallet_remote(&["list"])? + } else { + let _ = wallet(&delete_args)?; + wallet(&["list"])? + }; + anyhow::ensure!( + !listing.contains(&addr), + "deleted wallet {addr} still appears in `list`:\n{listing}" + ); + Ok(()) +} diff --git a/tests/common/calibnet_wallet_helpers.rs b/tests/common/calibnet_wallet_helpers.rs new file mode 100644 index 000000000000..eb1f8edefbad --- /dev/null +++ b/tests/common/calibnet_wallet_helpers.rs @@ -0,0 +1,290 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Helpers for the calibnet wallet integration tests in +//! [`tests/calibnet_wallet.rs`](../../calibnet_wallet.rs). + +#![allow(dead_code)] + +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; + +/// Calibnet address used as the funded source in every test. Imported into +/// both backends by the harness before this binary runs. +pub const PRELOADED_ADDRESS: &str = "t147upkwsnjhyabxuusawz3x42cselvdnp7j26kxy"; + +/// Default amount transferred in value-transfer assertions. +pub const FIL_AMT: &str = "500 atto FIL"; +/// Sentinel `forest-wallet balance --exact-balance` returns for an unfunded address. +pub const FIL_ZERO: &str = "0 FIL"; +/// Amount used to seed a freshly-created delegated wallet. +pub const DELEGATE_FUND_AMT: &str = "3 micro FIL"; + +/// Maximum number of times to retry a balance poll before timing out. +pub const POLL_RETRIES: usize = 20; +/// Delay between balance-poll attempts. +pub const POLL_DELAY: Duration = Duration::from_secs(30); + +/// Retries for `Filecoin.StateSearchMsg` polling. +pub const SEARCH_MSG_RETRIES: usize = 30; +/// Delay between `StateSearchMsg` attempts. +pub const SEARCH_MSG_DELAY: Duration = Duration::from_secs(5); + +/// Serializes `forest-wallet` invocations against the local keystore so +/// concurrent tests don't lose entries through last-writer-wins on the +/// keystore file. The daemon already serializes its own keystore handlers, +/// so remote invocations skip this lock. +static LOCAL_KEYSTORE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + +/// Run `forest-wallet ` against the local keystore and return trimmed +/// stdout on success. +pub fn wallet(args: &[&str]) -> anyhow::Result { + Ok(String::from_utf8(run_local_raw(args)?)?.trim().to_string()) +} + +/// Run `forest-wallet --remote-wallet ` and return trimmed stdout on +/// success. +pub fn wallet_remote(args: &[&str]) -> anyhow::Result { + let mut full = Vec::with_capacity(args.len() + 1); + full.push("--remote-wallet"); + full.extend_from_slice(args); + Ok(String::from_utf8(run_raw("forest-wallet", &full)?)? + .trim() + .to_string()) +} + +fn run_local_raw(args: &[&str]) -> anyhow::Result> { + let _guard = LOCAL_KEYSTORE_LOCK.lock(); + run_raw("forest-wallet", args) +} + +fn run_raw(bin: &str, args: &[&str]) -> anyhow::Result> { + let output = Command::new(bin) + .args(args) + .output() + .with_context(|| format!("failed to spawn `{bin}`"))?; + if !output.status.success() { + bail!( + "`{bin} {}` failed (status={}): {}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(output.stdout) +} + +/// Export `address` from the chosen backend into a temp file ready to feed +/// back to `forest-wallet import`. +pub fn export_to_temp_file(address: &str, remote: bool) -> anyhow::Result { + let raw = if remote { + run_raw("forest-wallet", &["--remote-wallet", "export", address])? + } else { + run_local_raw(&["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) +} + +/// Run `forest-wallet [--remote-wallet] balance --exact-balance`. +pub fn balance(address: &str, remote: bool) -> anyhow::Result { + let args = ["balance", address, "--exact-balance"]; + if remote { + wallet_remote(&args) + } else { + wallet(&args) + } +} + +/// Run `forest-wallet [--remote-wallet] send --from `. +/// Always passing `--from` keeps tests independent of the shared +/// `set-default` slot. +pub fn send_from(from: &str, to: &str, amount: &str, remote: bool) -> anyhow::Result { + let args = ["send", "--from", from, to, amount]; + if remote { + wallet_remote(&args) + } else { + wallet(&args) + } +} + +/// Poll `check` up to [`POLL_RETRIES`] times with [`POLL_DELAY`] between +/// attempts; return the satisfying value or a labelled timeout error. +pub async fn poll(what: &str, mut check: F) -> anyhow::Result +where + F: FnMut() -> anyhow::Result>, +{ + for i in 1..=POLL_RETRIES { + eprintln!("Polling {what} {i}/{POLL_RETRIES}"); + tokio::time::sleep(POLL_DELAY).await; + if let Some(value) = check()? { + return Ok(value); + } + } + bail!("Timed out waiting for {what} after {POLL_RETRIES} retries") +} + +/// Poll until the balance reported for `address` is no longer [`FIL_ZERO`]. +pub async fn poll_until_funded(address: &str, remote: bool) -> anyhow::Result { + let label = format!( + "{} balance for {address}", + if remote { "remote" } else { "local" } + ); + poll(&label, || { + let bal = balance(address, remote)?; + Ok((bal != FIL_ZERO).then_some(bal)) + }) + .await +} + +/// Poll until the balance reported for `address` differs from `baseline`. +pub async fn poll_until_changed( + address: &str, + baseline: &str, + remote: bool, +) -> anyhow::Result { + let label = format!( + "{} balance change for {address}", + if remote { "remote" } else { "local" } + ); + let baseline = baseline.to_string(); + poll(&label, || { + let bal = balance(address, remote)?; + Ok((bal != baseline).then_some(bal)) + }) + .await +} + +/// Parse `FULLNODE_API_INFO` (`:/ip4//tcp//http`) into +/// `(token, http_url)` where `http_url` is the v1 RPC endpoint. +pub fn parse_fullnode_api_info() -> anyhow::Result<(String, String)> { + 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 `:`")?; + 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"))) +} + +/// POST a JSON-RPC v1 request to the daemon and return the `result` field. +pub async fn rpc_call(method: &str, params: Value) -> anyhow::Result { + let (token, url) = parse_fullnode_api_info()?; + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }); + let resp: Value = reqwest::Client::new() + .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}"); + } + resp.get("result") + .cloned() + .with_context(|| format!("missing `result` in response for {method}")) +} + +/// Same as [`rpc_call`], but maps missing or null `result` to `Ok(None)`. +pub async fn rpc_call_opt(method: &str, params: Value) -> anyhow::Result> { + let (token, url) = parse_fullnode_api_info()?; + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }); + let resp: Value = reqwest::Client::new() + .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())), + } +} + +/// Extracts a CID string from a JSON-RPC value (Lotus `{ "/": "bafy..." }` or plain string). +pub fn cid_from_lotus_json_result(result: &Value) -> anyhow::Result { + 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}")) +} + +/// Polls `Filecoin.StateSearchMsg` until the message is found or retries are exhausted (same cadence as +/// `scripts/tests/calibnet_wallet_check.sh`). +pub async fn poll_until_state_search_msg(msg_cid: &str) -> anyhow::Result<()> { + for i in 1..=SEARCH_MSG_RETRIES { + tokio::time::sleep(SEARCH_MSG_DELAY).await; + eprintln!("StateSearchMsg polling {msg_cid} attempt {i}/{SEARCH_MSG_RETRIES}"); + let params = json!([[], { "/": msg_cid }, 800_i64, true]); + if rpc_call_opt("Filecoin.StateSearchMsg", params) + .await? + .is_some() + { + return Ok(()); + } + } + bail!( + "timed out waiting for message {msg_cid} via StateSearchMsg after {SEARCH_MSG_RETRIES} retries" + ) +} + +/// Resolve the ETH equivalent of a Filecoin address via +/// `Filecoin.FilecoinAddressToEthAddress`. +pub async fn filecoin_to_eth(address: &str) -> anyhow::Result { + 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}")) +} From b942e69731e534bc5a7372490c6d1e917a44c6cf Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 30 Apr 2026 06:35:57 +0530 Subject: [PATCH 2/9] use configured wallet --- tests/common/calibnet_wallet_helpers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/common/calibnet_wallet_helpers.rs b/tests/common/calibnet_wallet_helpers.rs index eb1f8edefbad..b7a152cf077b 100644 --- a/tests/common/calibnet_wallet_helpers.rs +++ b/tests/common/calibnet_wallet_helpers.rs @@ -18,7 +18,7 @@ use tempfile::NamedTempFile; /// Calibnet address used as the funded source in every test. Imported into /// both backends by the harness before this binary runs. -pub const PRELOADED_ADDRESS: &str = "t147upkwsnjhyabxuusawz3x42cselvdnp7j26kxy"; +pub const PRELOADED_ADDRESS: &str = "t1ac6ndwj6nghqbmtbovvnwcqo577p6ox2pt52q2y"; /// Default amount transferred in value-transfer assertions. pub const FIL_AMT: &str = "500 atto FIL"; From e23f77c4c5bc09b7c30c87e933a6f3e7ef7e116d Mon Sep 17 00:00:00 2001 From: Shashank Date: Wed, 6 May 2026 03:37:01 +0530 Subject: [PATCH 3/9] fix workflow --- .github/workflows/forest.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/forest.yml b/.github/workflows/forest.yml index 54d2cedc5b40..b5c851060168 100644 --- a/.github/workflows/forest.yml +++ b/.github/workflows/forest.yml @@ -242,6 +242,25 @@ jobs: - name: Set permissions run: | chmod +x ~/.cargo/bin/forest* + - name: Configure SCCache variables + run: | + # External PRs do not have access to 'vars' or 'secrets'. + if [[ "${{secrets.AWS_ACCESS_KEY_ID}}" != "" ]]; then + { + echo "SCCACHE_ENDPOINT=${{ vars.SCCACHE_ENDPOINT}}" + echo "SCCACHE_BUCKET=${{ vars.SCCACHE_BUCKET}}" + echo "SCCACHE_REGION=${{ vars.SCCACHE_REGION}}" + } >> "$GITHUB_ENV" + fi + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.10 + timeout-minutes: ${{ fromJSON(env.CACHE_TIMEOUT_MINUTES) }} + continue-on-error: true + - uses: actions/setup-go@v6 + with: + go-version-file: "go.work" + cache-dependency-path: "**/go.sum" + - uses: jdx/mise-action@v4 - name: Wallet commands check env: CALIBNET_WALLET: "${{ secrets.CALIBNET_WALLET }}" From fd4c011e7b3eea602932374c8258abc7003bdb56 Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 7 May 2026 09:53:51 +0530 Subject: [PATCH 4/9] refactor and cleanup --- .github/workflows/forest.yml | 2 +- mise.toml | 12 ++ scripts/tests/calibnet_wallet_check.sh | 12 -- scripts/tests/harness.sh | 5 +- tests/calibnet_wallet.rs | 235 +++++++++++------------- tests/common/calibnet_wallet_helpers.rs | 220 +++++++++++----------- 6 files changed, 241 insertions(+), 245 deletions(-) delete mode 100755 scripts/tests/calibnet_wallet_check.sh diff --git a/.github/workflows/forest.yml b/.github/workflows/forest.yml index b5c851060168..6e69bd3694e2 100644 --- a/.github/workflows/forest.yml +++ b/.github/workflows/forest.yml @@ -266,7 +266,7 @@ jobs: CALIBNET_WALLET: "${{ secrets.CALIBNET_WALLET }}" run: | if [[ "$CALIBNET_WALLET" != "" ]]; then - ./scripts/tests/calibnet_wallet_check.sh "$CALIBNET_WALLET" + mise run test:wallet "$CALIBNET_WALLET" fi timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} calibnet-export-check-v1: diff --git a/mise.toml b/mise.toml index b60d6d40423b..bf7a037d5052 100644 --- a/mise.toml +++ b/mise.toml @@ -206,6 +206,18 @@ mise task run test:nextest ${usage_profile?} mise task run test:cargo ${usage_profile?} ''' +[tasks."test:wallet"] +description = "Run calibnet wallet integration tests." +usage = ''' +arg "" help="Hex-encoded preloaded calibnet wallet key (e.g. $CALIBNET_WALLET secret)" +''' +run = ''' +set -euxo pipefail +source ./scripts/tests/harness.sh +forest_wallet_init "${usage_preloaded_key?}" +cargo test --profile quick-test --test calibnet_wallet -- --ignored --nocapture +''' + [tasks."codecov:nextest"] description = "Run codecov with nextest" run = ''' diff --git a/scripts/tests/calibnet_wallet_check.sh b/scripts/tests/calibnet_wallet_check.sh deleted file mode 100755 index ee26e3681ec6..000000000000 --- a/scripts/tests/calibnet_wallet_check.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# Drives the calibnet wallet integration tests in `tests/calibnet_wallet.rs`. -# `harness.sh::forest_wallet_init` brings up the daemon, imports the -# preloaded wallet into both backends, and exports `FULLNODE_API_INFO`. - -set -euxo pipefail - -source "$(dirname "$0")/harness.sh" - -forest_wallet_init "$@" - -cargo test --profile quick-test --test calibnet_wallet -- --ignored --nocapture diff --git a/scripts/tests/harness.sh b/scripts/tests/harness.sh index 3644be3ff0af..fc352d771b20 100644 --- a/scripts/tests/harness.sh +++ b/scripts/tests/harness.sh @@ -156,8 +156,11 @@ function forest_wallet_init { forest_init "$@" - $FOREST_WALLET_PATH import preloaded_wallet.key + PRELOADED_ADDRESS=$($FOREST_WALLET_PATH import preloaded_wallet.key) $FOREST_WALLET_PATH --remote-wallet import preloaded_wallet.key + + $FOREST_WALLET_PATH set-default "$PRELOADED_ADDRESS" + $FOREST_WALLET_PATH --remote-wallet set-default "$PRELOADED_ADDRESS" } function forest_print_logs_and_metrics { diff --git a/tests/calibnet_wallet.rs b/tests/calibnet_wallet.rs index 4da4b536d38b..4b14a4461175 100644 --- a/tests/calibnet_wallet.rs +++ b/tests/calibnet_wallet.rs @@ -13,23 +13,36 @@ mod helpers; use anyhow::Context as _; use helpers::*; +use rstest::rstest; use serde_json::json; use tokio::sync::OnceCell; +/// Funded delegated wallet shared across the delegated tests. static FUNDED_DELEGATED: OnceCell = OnceCell::const_new(); +#[rstest] +#[case::local(Backend::Local)] +#[case::remote(Backend::Remote)] #[tokio::test] #[ignore = "requires a running calibnet Forest daemon"] -async fn local_export_import_roundtrip() -> anyhow::Result<()> { - let addr = wallet(&["new"])?; - assert_export_import_roundtrip(&addr, false) -} +async fn export_import_roundtrip(#[case] backend: Backend) -> anyhow::Result<()> { + let addr = wallet(backend, &["new"])?; + let exported = export_to_temp_file(&addr, backend)?; + let path = exported + .path() + .to_str() + .context("temp path is not valid UTF-8")?; -#[tokio::test] -#[ignore = "requires a running calibnet Forest daemon"] -async fn remote_export_import_roundtrip() -> anyhow::Result<()> { - let addr = wallet_remote(&["new"])?; - assert_export_import_roundtrip(&addr, true) + let deleted = wallet(backend, &["delete", &addr])?; + eprintln!("delete output ({}): {deleted}", backend.label()); + + let imported = wallet(backend, &["import", path])?; + anyhow::ensure!( + imported == addr, + "round-trip mismatch on {} backend: {imported} != {addr}", + backend.label(), + ); + Ok(()) } #[tokio::test] @@ -38,72 +51,98 @@ async fn market_add_balance_message_on_chain() -> anyhow::Result<()> { const ATTO_FIL: &str = "23"; let result = rpc_call( "Filecoin.MarketAddBalance", - json!([PRELOADED_ADDRESS, PRELOADED_ADDRESS, ATTO_FIL]), + json!([ + PRELOADED_ADDRESS.as_str(), + PRELOADED_ADDRESS.as_str(), + ATTO_FIL, + ]), ) .await?; let msg_cid = cid_from_lotus_json_result(&result)?; poll_until_state_search_msg(&msg_cid).await } +#[rstest] +#[case::local(Backend::Local)] +#[case::remote(Backend::Remote)] #[tokio::test] #[ignore = "requires a running calibnet Forest daemon"] -async fn send_to_local_filecoin_address() -> anyhow::Result<()> { - let target = wallet(&["new"])?; - let _ = send_from(PRELOADED_ADDRESS, &target, FIL_AMT, true)?; - let _ = poll_until_funded(&target, false).await?; +async fn send_to_filecoin_address(#[case] backend: Backend) -> anyhow::Result<()> { + let target = wallet(backend, &["new"])?; + let msg = send_from(&PRELOADED_ADDRESS, &target, FIL_AMT, backend)?; + eprintln!("send to {target} ({}) msg: {msg}", backend.label()); + let funded = poll_until_funded(&target, backend).await?; + eprintln!("{target} funded balance: {funded}"); Ok(()) } +#[rstest] +#[case::local(Backend::Local)] +#[case::remote(Backend::Remote)] #[tokio::test] #[ignore = "requires a running calibnet Forest daemon"] -async fn send_to_remote_filecoin_address() -> anyhow::Result<()> { - let target = wallet_remote(&["new"])?; - let _ = send_from(PRELOADED_ADDRESS, &target, FIL_AMT, true)?; - let _ = poll_until_funded(&target, true).await?; - Ok(()) -} - -#[tokio::test] -#[ignore = "requires a running calibnet Forest daemon"] -async fn send_to_local_eth_equivalent() -> anyhow::Result<()> { - send_to_eth_equivalent(false).await -} - -#[tokio::test] -#[ignore = "requires a running calibnet Forest daemon"] -async fn send_to_remote_eth_equivalent() -> anyhow::Result<()> { - send_to_eth_equivalent(true).await -} +async fn send_to_eth_equivalent(#[case] backend: Backend) -> anyhow::Result<()> { + let target = wallet(backend, &["new"])?; + let initial_msg = send_from(&PRELOADED_ADDRESS, &target, FIL_AMT, backend)?; + eprintln!( + "initial send to {target} ({}) msg: {initial_msg}", + backend.label(), + ); + let baseline = poll_until_funded(&target, backend).await?; -#[tokio::test] -#[ignore = "requires a running calibnet Forest daemon"] -async fn local_wallet_delete() -> anyhow::Result<()> { - assert_create_delete(false) -} + let eth = filecoin_to_eth(&target).await?; + let eth_msg = send_from(&PRELOADED_ADDRESS, ð, FIL_AMT, backend)?; + eprintln!("send to ETH {eth} (mapped from {target}) msg: {eth_msg}"); -#[tokio::test] -#[ignore = "requires a running calibnet Forest daemon"] -async fn remote_wallet_delete() -> anyhow::Result<()> { - assert_create_delete(true) + let updated = poll_until_changed(&target, &baseline, backend).await?; + anyhow::ensure!( + updated != baseline, + "{target} balance unchanged after ETH-equivalent send: {updated}", + ); + Ok(()) } +#[rstest] +#[case::local(Backend::Local)] +#[case::remote(Backend::Remote)] #[tokio::test] #[ignore = "requires a running calibnet Forest daemon"] -async fn delegated_send_local_to_local() -> anyhow::Result<()> { - let funded = funded_delegated_addr().await?; - let target = wallet(&["new", "delegated"])?; - let _ = send_from(funded, &target, FIL_AMT, true)?; - let _ = poll_until_funded(&target, false).await?; +async fn wallet_delete(#[case] backend: Backend) -> anyhow::Result<()> { + let addr = wallet(backend, &["new"])?; + let deleted = wallet(backend, &["delete", &addr])?; + eprintln!("delete output ({}): {deleted}", backend.label()); + let listing = wallet(backend, &["list"])?; + anyhow::ensure!( + !listing.contains(&addr), + "deleted wallet {addr} still appears in `list`:\n{listing}", + ); Ok(()) } +#[rstest] +#[case::local(Backend::Local)] +#[case::remote(Backend::Remote)] #[tokio::test] #[ignore = "requires a running calibnet Forest daemon"] -async fn delegated_send_local_to_remote() -> anyhow::Result<()> { +async fn delegated_send(#[case] target_backend: Backend) -> anyhow::Result<()> { let funded = funded_delegated_addr().await?; - let target = wallet_remote(&["new", "delegated"])?; - let _ = send_from(funded, &target, FIL_AMT, true)?; - let _ = poll_until_funded(&target, true).await?; + let target = wallet(target_backend, &["new", "delegated"])?; + // Baseline `FIL_ZERO` ⇒ first credit; otherwise expect a balance delta. + let baseline = balance(&target, target_backend)?; + let msg = send_from(funded, &target, FIL_AMT, Backend::Local)?; + eprintln!( + "delegated send to {target} ({}) msg: {msg}", + target_backend.label(), + ); + let observed = if baseline == FIL_ZERO { + poll_until_funded(&target, target_backend).await? + } else { + poll_until_changed(&target, &baseline, target_backend).await? + }; + anyhow::ensure!( + observed != baseline, + "{target} balance unchanged after delegated send: {observed}", + ); Ok(()) } @@ -111,94 +150,42 @@ async fn delegated_send_local_to_remote() -> anyhow::Result<()> { #[ignore = "requires a running calibnet Forest daemon"] async fn delegated_remote_send() -> anyhow::Result<()> { let funded = funded_delegated_addr().await?; - let target = wallet_remote(&["new", "delegated"])?; - let baseline = balance(&target, true)?; - let _ = send_from(funded, &target, FIL_AMT, true)?; - if baseline == FIL_ZERO { - let _ = poll_until_funded(&target, true).await?; + let target = wallet(Backend::Remote, &["new", "delegated"])?; + let baseline = balance(&target, Backend::Remote)?; + let msg = send_from(funded, &target, FIL_AMT, Backend::Remote)?; + eprintln!("delegated --remote-wallet send to {target} msg: {msg}"); + let observed = if baseline == FIL_ZERO { + poll_until_funded(&target, Backend::Remote).await? } else { - let _ = poll_until_changed(&target, &baseline, true).await?; - } + poll_until_changed(&target, &baseline, Backend::Remote).await? + }; + anyhow::ensure!( + observed != baseline, + "{target} balance unchanged after delegated --remote-wallet send: {observed}", + ); Ok(()) } +/// Delegated signer: create once on local, fund locally, mirror to remote +/// for tests that query or sign. async fn funded_delegated_addr() -> anyhow::Result<&'static str> { let addr = FUNDED_DELEGATED .get_or_try_init(|| async { - let addr = wallet(&["new", "delegated"])?; - let _ = send_from(PRELOADED_ADDRESS, &addr, DELEGATE_FUND_AMT, true)?; - let _ = poll_until_funded(&addr, false).await?; - let exported = export_to_temp_file(&addr, false)?; + let addr = wallet(Backend::Local, &["new", "delegated"])?; + let fund_msg = send_from(&PRELOADED_ADDRESS, &addr, DELEGATE_FUND_AMT, Backend::Local)?; + eprintln!("delegated funding send to {addr} msg: {fund_msg}"); + let funded = poll_until_funded(&addr, Backend::Local).await?; + eprintln!("delegated wallet {addr} funded balance: {funded}"); + + let exported = export_to_temp_file(&addr, Backend::Local)?; let path = exported .path() .to_str() .context("temp path is not valid UTF-8")?; - let mirrored = wallet_remote(&["import", path])?; + let mirrored = wallet(Backend::Remote, &["import", path])?; anyhow::ensure!(mirrored == addr, "mirror mismatch: {mirrored} != {addr}"); Ok::<_, anyhow::Error>(addr) }) .await?; Ok(addr.as_str()) } - -async fn send_to_eth_equivalent(remote: bool) -> anyhow::Result<()> { - let target = if remote { - wallet_remote(&["new"])? - } else { - wallet(&["new"])? - }; - let _ = send_from(PRELOADED_ADDRESS, &target, FIL_AMT, true)?; - let baseline = poll_until_funded(&target, remote).await?; - - let eth = filecoin_to_eth(&target).await?; - let _ = send_from(PRELOADED_ADDRESS, ð, FIL_AMT, true)?; - let _ = poll_until_changed(&target, &baseline, remote).await?; - Ok(()) -} - -fn assert_export_import_roundtrip(address: &str, remote: bool) -> anyhow::Result<()> { - let exported = export_to_temp_file(address, remote)?; - let path = exported - .path() - .to_str() - .context("temp path is not valid UTF-8")?; - - let delete_args = ["delete", address]; - let import_args = ["import", path]; - let imported = if remote { - let _ = wallet_remote(&delete_args)?; - wallet_remote(&import_args)? - } else { - let _ = wallet(&delete_args)?; - wallet(&import_args)? - }; - anyhow::ensure!( - imported == address, - "round-trip mismatch on {} backend: {imported} != {address}", - if remote { "remote" } else { "local" } - ); - Ok(()) -} - -fn assert_create_delete(remote: bool) -> anyhow::Result<()> { - let new_args = ["new"]; - let addr = if remote { - wallet_remote(&new_args)? - } else { - wallet(&new_args)? - }; - - let delete_args = ["delete", &addr]; - let listing = if remote { - let _ = wallet_remote(&delete_args)?; - wallet_remote(&["list"])? - } else { - let _ = wallet(&delete_args)?; - wallet(&["list"])? - }; - anyhow::ensure!( - !listing.contains(&addr), - "deleted wallet {addr} still appears in `list`:\n{listing}" - ); - Ok(()) -} diff --git a/tests/common/calibnet_wallet_helpers.rs b/tests/common/calibnet_wallet_helpers.rs index b7a152cf077b..a7f88d693e2c 100644 --- a/tests/common/calibnet_wallet_helpers.rs +++ b/tests/common/calibnet_wallet_helpers.rs @@ -16,64 +16,73 @@ use parking_lot::Mutex; use serde_json::{Value, json}; use tempfile::NamedTempFile; -/// Calibnet address used as the funded source in every test. Imported into -/// both backends by the harness before this binary runs. -pub const PRELOADED_ADDRESS: &str = "t1ac6ndwj6nghqbmtbovvnwcqo577p6ox2pt52q2y"; +/// Preloaded address from `forest-wallet default` (harness `set-default`s it after import). +pub static PRELOADED_ADDRESS: LazyLock = LazyLock::new(|| { + wallet(Backend::Local, &["default"]) + .expect("`forest-wallet default` failed — run harness `forest_wallet_init`") +}); -/// Default amount transferred in value-transfer assertions. pub const FIL_AMT: &str = "500 atto FIL"; /// Sentinel `forest-wallet balance --exact-balance` returns for an unfunded address. pub const FIL_ZERO: &str = "0 FIL"; /// Amount used to seed a freshly-created delegated wallet. pub const DELEGATE_FUND_AMT: &str = "3 micro FIL"; -/// Maximum number of times to retry a balance poll before timing out. pub const POLL_RETRIES: usize = 20; -/// Delay between balance-poll attempts. pub const POLL_DELAY: Duration = Duration::from_secs(30); -/// Retries for `Filecoin.StateSearchMsg` polling. pub const SEARCH_MSG_RETRIES: usize = 30; -/// Delay between `StateSearchMsg` attempts. pub const SEARCH_MSG_DELAY: Duration = Duration::from_secs(5); -/// Serializes `forest-wallet` invocations against the local keystore so -/// concurrent tests don't lose entries through last-writer-wins on the -/// keystore file. The daemon already serializes its own keystore handlers, -/// so remote invocations skip this lock. -static LOCAL_KEYSTORE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +/// Selects which `forest-wallet` keystore an operation targets. +#[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"], + } + } -/// Run `forest-wallet ` against the local keystore and return trimmed -/// stdout on success. -pub fn wallet(args: &[&str]) -> anyhow::Result { - Ok(String::from_utf8(run_local_raw(args)?)?.trim().to_string()) + pub fn label(self) -> &'static str { + match self { + Self::Local => "local", + Self::Remote => "remote", + } + } } -/// Run `forest-wallet --remote-wallet ` and return trimmed stdout on -/// success. -pub fn wallet_remote(args: &[&str]) -> anyhow::Result { - let mut full = Vec::with_capacity(args.len() + 1); - full.push("--remote-wallet"); - full.extend_from_slice(args); - Ok(String::from_utf8(run_raw("forest-wallet", &full)?)? +/// Serializes local keystore file access. +static LOCAL_KEYSTORE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + +/// Run `forest-wallet [--remote-wallet] ` and return trimmed stdout. +pub fn wallet(backend: Backend, args: &[&str]) -> anyhow::Result { + Ok(String::from_utf8(run_wallet_raw(backend, args)?)? .trim() .to_string()) } -fn run_local_raw(args: &[&str]) -> anyhow::Result> { - let _guard = LOCAL_KEYSTORE_LOCK.lock(); - run_raw("forest-wallet", args) -} +/// Same as [`wallet`] but yields raw stdout bytes (used by `export`). +pub fn run_wallet_raw(backend: Backend, args: &[&str]) -> anyhow::Result> { + let _guard = (backend == Backend::Local).then(|| LOCAL_KEYSTORE_LOCK.lock()); -fn run_raw(bin: &str, args: &[&str]) -> anyhow::Result> { - let output = Command::new(bin) - .args(args) + 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() - .with_context(|| format!("failed to spawn `{bin}`"))?; + .context("failed to spawn `forest-wallet`")?; if !output.status.success() { bail!( - "`{bin} {}` failed (status={}): {}", - args.join(" "), + "`forest-wallet {}` failed (status={}): {}", + full.join(" "), output.status, String::from_utf8_lossy(&output.stderr) ); @@ -83,12 +92,8 @@ fn run_raw(bin: &str, args: &[&str]) -> anyhow::Result> { /// Export `address` from the chosen backend into a temp file ready to feed /// back to `forest-wallet import`. -pub fn export_to_temp_file(address: &str, remote: bool) -> anyhow::Result { - let raw = if remote { - run_raw("forest-wallet", &["--remote-wallet", "export", address])? - } else { - run_local_raw(&["export", address])? - }; +pub fn export_to_temp_file(address: &str, backend: Backend) -> anyhow::Result { + 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)?; @@ -96,28 +101,50 @@ pub fn export_to_temp_file(address: &str, remote: bool) -> anyhow::Result --exact-balance`. -pub fn balance(address: &str, remote: bool) -> anyhow::Result { - let args = ["balance", address, "--exact-balance"]; - if remote { - wallet_remote(&args) - } else { - wallet(&args) - } +pub fn balance(address: &str, backend: Backend) -> anyhow::Result { + wallet(backend, &["balance", address, "--exact-balance"]) } -/// Run `forest-wallet [--remote-wallet] send --from `. -/// Always passing `--from` keeps tests independent of the shared -/// `set-default` slot. -pub fn send_from(from: &str, to: &str, amount: &str, remote: bool) -> anyhow::Result { +/// Send with `--from`. `backend` chooses the signing keystore +/// (local file vs `--remote-wallet`). +/// +/// Retries on the transient `gas price is lower than min gas price` mpool +/// error: the local CLI path estimates gas, then submits via `MpoolPush`, +/// so a concurrent push that bumps the mempool's fee floor between +/// estimate and push rejects an otherwise-valid message. Retry re-runs +/// fee estimation so gas fields match whatever minimum gas price applies +/// at the next submission. +pub fn send_from(from: &str, to: &str, amount: &str, backend: Backend) -> anyhow::Result { let args = ["send", "--from", from, to, amount]; - if remote { - wallet_remote(&args) - } else { - wallet(&args) + let mut attempt = 1; + loop { + match wallet(backend, &args) { + Ok(out) => return Ok(out), + Err(e) if attempt < SEND_RETRIES && is_min_gas_price_error(&e) => { + eprintln!( + "send {from} -> {to} hit min-gas-price floor on attempt {attempt}/{SEND_RETRIES}, retrying" + ); + std::thread::sleep(SEND_RETRY_DELAY); + attempt += 1; + } + Err(e) => return Err(e), + } } } +/// Max attempts for [`send_from`]. +const SEND_RETRIES: usize = 3; +/// Delay between [`send_from`] retries; one block-time at calibnet cadence +/// is enough for the daemon's gas-price snapshot to refresh. +const SEND_RETRY_DELAY: Duration = Duration::from_secs(15); + +fn is_min_gas_price_error(err: &anyhow::Error) -> bool { + err.chain().any(|e| { + e.to_string() + .contains("gas price is lower than min gas price") + }) +} + /// Poll `check` up to [`POLL_RETRIES`] times with [`POLL_DELAY`] between /// attempts; return the satisfying value or a labelled timeout error. pub async fn poll(what: &str, mut check: F) -> anyhow::Result @@ -135,13 +162,10 @@ where } /// Poll until the balance reported for `address` is no longer [`FIL_ZERO`]. -pub async fn poll_until_funded(address: &str, remote: bool) -> anyhow::Result { - let label = format!( - "{} balance for {address}", - if remote { "remote" } else { "local" } - ); +pub async fn poll_until_funded(address: &str, backend: Backend) -> anyhow::Result { + let label = format!("{} balance for {address}", backend.label()); poll(&label, || { - let bal = balance(address, remote)?; + let bal = balance(address, backend)?; Ok((bal != FIL_ZERO).then_some(bal)) }) .await @@ -151,23 +175,21 @@ pub async fn poll_until_funded(address: &str, remote: bool) -> anyhow::Result anyhow::Result { - let label = format!( - "{} balance change for {address}", - if remote { "remote" } else { "local" } - ); + let label = format!("{} balance change for {address}", backend.label()); let baseline = baseline.to_string(); poll(&label, || { - let bal = balance(address, remote)?; + let bal = balance(address, backend)?; Ok((bal != baseline).then_some(bal)) }) .await } -/// Parse `FULLNODE_API_INFO` (`:/ip4//tcp//http`) into -/// `(token, http_url)` where `http_url` is the v1 RPC endpoint. -pub fn parse_fullnode_api_info() -> anyhow::Result<(String, String)> { +static HTTP: LazyLock = LazyLock::new(reqwest::Client::new); + +/// Cached `(token, http_url)` parsed once from `FULLNODE_API_INFO`. +static API: LazyLock> = LazyLock::new(|| { let raw = std::env::var("FULLNODE_API_INFO").context("FULLNODE_API_INFO env var not set")?; let (token, multiaddr) = raw .split_once(':') @@ -182,49 +204,26 @@ pub fn parse_fullnode_api_info() -> anyhow::Result<(String, String)> { .filter(|s| !s.is_empty()) .with_context(|| format!("missing port in multiaddr `{multiaddr}`"))?; Ok((token.to_string(), format!("http://{host}:{port}/rpc/v1"))) -} +}); -/// POST a JSON-RPC v1 request to the daemon and return the `result` field. -pub async fn rpc_call(method: &str, params: Value) -> anyhow::Result { - let (token, url) = parse_fullnode_api_info()?; - let body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": method, - "params": params, - }); - let resp: Value = reqwest::Client::new() - .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}"); - } - resp.get("result") - .cloned() - .with_context(|| format!("missing `result` in response for {method}")) +fn api() -> anyhow::Result<&'static (String, String)> { + API.as_ref() + .map_err(|e| anyhow::anyhow!("FULLNODE_API_INFO unavailable: {e}")) } -/// Same as [`rpc_call`], but maps missing or null `result` to `Ok(None)`. +/// POST a JSON-RPC v1 request and return the `result` field, or `None` if +/// the server responded without one. pub async fn rpc_call_opt(method: &str, params: Value) -> anyhow::Result> { - let (token, url) = parse_fullnode_api_info()?; + let (token, url) = api()?; let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params, }); - let resp: Value = reqwest::Client::new() - .post(&url) - .bearer_auth(&token) + let resp: Value = HTTP + .post(url) + .bearer_auth(token) .json(&body) .send() .await @@ -244,7 +243,15 @@ pub async fn rpc_call_opt(method: &str, params: Value) -> anyhow::Result anyhow::Result { + rpc_call_opt(method, params) + .await? + .with_context(|| format!("missing `result` in response for {method}")) +} + +/// Extract a CID string from either a Lotus `{ "/": "bafy..." }` map or a +/// plain string. pub fn cid_from_lotus_json_result(result: &Value) -> anyhow::Result { if let Some(s) = result.as_str() { return Ok(s.to_string()); @@ -256,8 +263,7 @@ pub fn cid_from_lotus_json_result(result: &Value) -> anyhow::Result { .with_context(|| format!("expected CID (lotus JSON or string), got {result}")) } -/// Polls `Filecoin.StateSearchMsg` until the message is found or retries are exhausted (same cadence as -/// `scripts/tests/calibnet_wallet_check.sh`). +/// Poll `Filecoin.StateSearchMsg` until the message is mined or retries exhaust. pub async fn poll_until_state_search_msg(msg_cid: &str) -> anyhow::Result<()> { for i in 1..=SEARCH_MSG_RETRIES { tokio::time::sleep(SEARCH_MSG_DELAY).await; From 6c601fcd54af97c9c100f29d8623893c093fbd3e Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 7 May 2026 12:15:19 +0530 Subject: [PATCH 5/9] fix test:wallet --- mise.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/mise.toml b/mise.toml index bf7a037d5052..7e7c038940cb 100644 --- a/mise.toml +++ b/mise.toml @@ -208,6 +208,7 @@ mise task run test:cargo ${usage_profile?} [tasks."test:wallet"] description = "Run calibnet wallet integration tests." +shell = "bash -c" usage = ''' arg "" help="Hex-encoded preloaded calibnet wallet key (e.g. $CALIBNET_WALLET secret)" ''' From 90cd13060ab0b80b3c78831546c468be9db32af2 Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 7 May 2026 12:55:48 +0530 Subject: [PATCH 6/9] fix PRELOADED_ADDRESS read --- scripts/tests/harness.sh | 5 +---- tests/common/calibnet_wallet_helpers.rs | 9 ++++++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/tests/harness.sh b/scripts/tests/harness.sh index fc352d771b20..7db52675293c 100644 --- a/scripts/tests/harness.sh +++ b/scripts/tests/harness.sh @@ -156,11 +156,8 @@ function forest_wallet_init { forest_init "$@" - PRELOADED_ADDRESS=$($FOREST_WALLET_PATH import preloaded_wallet.key) + export PRELOADED_ADDRESS="$($FOREST_WALLET_PATH import preloaded_wallet.key)" $FOREST_WALLET_PATH --remote-wallet import preloaded_wallet.key - - $FOREST_WALLET_PATH set-default "$PRELOADED_ADDRESS" - $FOREST_WALLET_PATH --remote-wallet set-default "$PRELOADED_ADDRESS" } function forest_print_logs_and_metrics { diff --git a/tests/common/calibnet_wallet_helpers.rs b/tests/common/calibnet_wallet_helpers.rs index a7f88d693e2c..73d436c3f0f6 100644 --- a/tests/common/calibnet_wallet_helpers.rs +++ b/tests/common/calibnet_wallet_helpers.rs @@ -16,10 +16,13 @@ use parking_lot::Mutex; use serde_json::{Value, json}; use tempfile::NamedTempFile; -/// Preloaded address from `forest-wallet default` (harness `set-default`s it after import). +/// Funded preloaded address from env `PRELOADED_ADDRESS` (see `forest_wallet_init`). pub static PRELOADED_ADDRESS: LazyLock = LazyLock::new(|| { - wallet(Backend::Local, &["default"]) - .expect("`forest-wallet default` failed — run harness `forest_wallet_init`") + std::env::var("PRELOADED_ADDRESS") + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .expect("PRELOADED_ADDRESS must be set") }); pub const FIL_AMT: &str = "500 atto FIL"; From 9095e83ec2d2f311f3fcf95dadb9ad68f15b5e3b Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 7 May 2026 13:56:48 +0530 Subject: [PATCH 7/9] client with timeout --- tests/common/calibnet_wallet_helpers.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/common/calibnet_wallet_helpers.rs b/tests/common/calibnet_wallet_helpers.rs index 73d436c3f0f6..4ee64d371252 100644 --- a/tests/common/calibnet_wallet_helpers.rs +++ b/tests/common/calibnet_wallet_helpers.rs @@ -189,7 +189,12 @@ pub async fn poll_until_changed( .await } -static HTTP: LazyLock = LazyLock::new(reqwest::Client::new); +static HTTP: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .expect("failed to build reqwest client") +}); /// Cached `(token, http_url)` parsed once from `FULLNODE_API_INFO`. static API: LazyLock> = LazyLock::new(|| { From 65e952c15fab668ec218e4e24cc19eda4221d0cf Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 7 May 2026 14:15:09 +0530 Subject: [PATCH 8/9] fix --- scripts/tests/harness.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/tests/harness.sh b/scripts/tests/harness.sh index 7db52675293c..4cc7845f7395 100644 --- a/scripts/tests/harness.sh +++ b/scripts/tests/harness.sh @@ -156,7 +156,9 @@ function forest_wallet_init { forest_init "$@" - export PRELOADED_ADDRESS="$($FOREST_WALLET_PATH import preloaded_wallet.key)" + PRELOADED_ADDRESS="$($FOREST_WALLET_PATH import preloaded_wallet.key)" + export PRELOADED_ADDRESS + $FOREST_WALLET_PATH --remote-wallet import preloaded_wallet.key } From b06c190df5aca42bb9ad72ccd7a2e97aa1ee65d1 Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 7 May 2026 15:07:06 +0530 Subject: [PATCH 9/9] Remove -x --- mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index 7e7c038940cb..9517cc1c9414 100644 --- a/mise.toml +++ b/mise.toml @@ -213,7 +213,7 @@ usage = ''' arg "" help="Hex-encoded preloaded calibnet wallet key (e.g. $CALIBNET_WALLET secret)" ''' run = ''' -set -euxo pipefail +set -euo pipefail source ./scripts/tests/harness.sh forest_wallet_init "${usage_preloaded_key?}" cargo test --profile quick-test --test calibnet_wallet -- --ignored --nocapture