diff --git a/.github/workflows/forest.yml b/.github/workflows/forest.yml index c6d2cc0a31c7..6e69bd3694e2 100644 --- a/.github/workflows/forest.yml +++ b/.github/workflows/forest.yml @@ -242,42 +242,31 @@ jobs: - name: Set permissions run: | chmod +x ~/.cargo/bin/forest* - - name: Wallet commands check - env: - CALIBNET_WALLET: "${{ secrets.CALIBNET_WALLET }}" + - name: Configure SCCache variables run: | - if [[ "$CALIBNET_WALLET" != "" ]]; then - ./scripts/tests/calibnet_wallet_check.sh "$CALIBNET_WALLET" + # 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 - 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 + - 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: - name: "forest-${{ runner.os }}" - path: ~/.cargo/bin - - name: Set permissions - run: | - chmod +x ~/.cargo/bin/forest* - - name: Delegated wallet commands check + 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 }}" run: | if [[ "$CALIBNET_WALLET" != "" ]]; then - ./scripts/tests/calibnet_delegated_wallet_check.sh "$CALIBNET_WALLET" + mise run test:wallet "$CALIBNET_WALLET" fi timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} calibnet-export-check-v1: @@ -585,7 +574,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/mise.toml b/mise.toml index b60d6d40423b..9517cc1c9414 100644 --- a/mise.toml +++ b/mise.toml @@ -206,6 +206,19 @@ mise task run test:nextest ${usage_profile?} 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)" +''' +run = ''' +set -euo 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_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 deleted file mode 100755 index ce8cb138add2..000000000000 --- a/scripts/tests/calibnet_wallet_check.sh +++ /dev/null @@ -1,225 +0,0 @@ -#!/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. - -set -euxo pipefail - -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 diff --git a/scripts/tests/harness.sh b/scripts/tests/harness.sh index 3644be3ff0af..4cc7845f7395 100644 --- a/scripts/tests/harness.sh +++ b/scripts/tests/harness.sh @@ -156,7 +156,9 @@ function forest_wallet_init { forest_init "$@" - $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 } diff --git a/tests/calibnet_wallet.rs b/tests/calibnet_wallet.rs new file mode 100644 index 000000000000..4b14a4461175 --- /dev/null +++ b/tests/calibnet_wallet.rs @@ -0,0 +1,191 @@ +// 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 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 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")?; + + 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] +#[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.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_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_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?; + + 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}"); + + 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 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(#[case] target_backend: Backend) -> anyhow::Result<()> { + let funded = funded_delegated_addr().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(()) +} + +#[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(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 { + 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(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(Backend::Remote, &["import", path])?; + anyhow::ensure!(mirrored == addr, "mirror mismatch: {mirrored} != {addr}"); + Ok::<_, anyhow::Error>(addr) + }) + .await?; + Ok(addr.as_str()) +} diff --git a/tests/common/calibnet_wallet_helpers.rs b/tests/common/calibnet_wallet_helpers.rs new file mode 100644 index 000000000000..4ee64d371252 --- /dev/null +++ b/tests/common/calibnet_wallet_helpers.rs @@ -0,0 +1,304 @@ +// 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; + +/// Funded preloaded address from env `PRELOADED_ADDRESS` (see `forest_wallet_init`). +pub static PRELOADED_ADDRESS: LazyLock = LazyLock::new(|| { + 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"; +/// 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"; + +pub const POLL_RETRIES: usize = 20; +pub const POLL_DELAY: Duration = Duration::from_secs(30); + +pub const SEARCH_MSG_RETRIES: usize = 30; +pub const SEARCH_MSG_DELAY: Duration = Duration::from_secs(5); + +/// 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"], + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Local => "local", + Self::Remote => "remote", + } + } +} + +/// 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()) +} + +/// 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()); + + 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) +} + +/// 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, 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)?; + file.flush()?; + Ok(file) +} + +pub fn balance(address: &str, backend: Backend) -> anyhow::Result { + wallet(backend, &["balance", address, "--exact-balance"]) +} + +/// 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]; + 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 +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, backend: Backend) -> anyhow::Result { + let label = format!("{} balance for {address}", backend.label()); + poll(&label, || { + let bal = balance(address, backend)?; + 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, + backend: Backend, +) -> anyhow::Result { + let label = format!("{} balance change for {address}", backend.label()); + let baseline = baseline.to_string(); + poll(&label, || { + let bal = balance(address, backend)?; + Ok((bal != baseline).then_some(bal)) + }) + .await +} + +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(|| { + 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"))) +}); + +fn api() -> anyhow::Result<&'static (String, String)> { + API.as_ref() + .map_err(|e| anyhow::anyhow!("FULLNODE_API_INFO unavailable: {e}")) +} + +/// 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) = 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())), + } +} + +/// Like [`rpc_call_opt`] but treats a missing `result` as an error. +pub async fn rpc_call(method: &str, params: Value) -> 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()); + } + result + .get("/") + .and_then(|v| v.as_str()) + .map(str::to_owned) + .with_context(|| format!("expected CID (lotus JSON or string), got {result}")) +} + +/// 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; + 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}")) +}