diff --git a/.github/workflows/forest.yml b/.github/workflows/forest.yml index 10779abf9d2..5f81a6b87cb 100644 --- a/.github/workflows/forest.yml +++ b/.github/workflows/forest.yml @@ -225,7 +225,14 @@ jobs: - name: Migration Regression Tests run: ./scripts/tests/calibnet_migration_regression_tests.sh timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} + calibnet-wallet-check-no-ops: + name: Wallet tests + runs-on: ubuntu-slim + if: ${{ !contains(github.event.pull_request.labels.*.name, 'Wallet') }} + steps: + - run: echo "No-op job to trigger the required wallet tests." calibnet-wallet-check: + if: ${{ contains(github.event.pull_request.labels.*.name, 'Wallet') }} needs: - build-ubuntu name: Wallet tests @@ -480,6 +487,18 @@ jobs: - name: Devnet check run: ./scripts/devnet/check.sh timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} + - name: Add forest binaries to PATH + run: | + chmod +x "$GITHUB_WORKSPACE"/forest* + echo "$GITHUB_WORKSPACE" >> "$GITHUB_PATH" + - name: Devnet wallet tests + run: | + set -euo pipefail + source ./scripts/devnet/wallet_harness.sh + devnet_wallet_env_init + forest-dev tests devnet mpool + forest-dev tests devnet wallet + timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} - name: Dump docker logs if: always() uses: jwalton/gh-docker-logs@v2 @@ -574,7 +593,6 @@ jobs: - calibnet-stateless-mode-check - calibnet-stateless-rpc-check - state-migrations-check - - calibnet-wallet-check - calibnet-no-discovery-checks - calibnet-kademlia-checks - calibnet-eth-mapping-check diff --git a/mise.toml b/mise.toml index ce9cbb99d81..33ba3156f3e 100644 --- a/mise.toml +++ b/mise.toml @@ -225,6 +225,21 @@ forest-dev tests calibnet mpool forest-dev tests calibnet wallet ''' +[tasks."test:wallet-devnet"] +description = "Run wallet integration tests against a local docker devnet." +shell = "bash -c" +run = ''' +set -euo pipefail +pushd scripts/devnet +./setup.sh +./check.sh +popd +source ./scripts/devnet/wallet_harness.sh +devnet_wallet_env_init +forest-dev tests devnet mpool +forest-dev tests devnet wallet +''' + [tasks."codecov:nextest"] description = "Run codecov with nextest" run = ''' diff --git a/scripts/devnet/README.md b/scripts/devnet/README.md index e6985a04950..34f6cfc78f6 100644 --- a/scripts/devnet/README.md +++ b/scripts/devnet/README.md @@ -50,11 +50,26 @@ and setup credentials. Then run any command: ```shell export TOKEN=$(cat /forest_data/token.jwt) -export FULLNODE_API_INFO=$TOKEN:/dns/forest/tcp/1234/http +export FULLNODE_API_INFO=$TOKEN:/dns/forest/tcp/3456/http forest-cli net peers ``` +## Running the wallet integration tests + +The same wallet/mpool integration suite that runs against calibnet can be run +against the local devnet. This brings up the devnet, waits for it to sync, wires +up the host environment, and runs the tests: + +```shell +mise run test:wallet-devnet +``` + +Under the hood this sources `wallet_harness.sh`, which reads the admin token and +the funded genesis key from the running `forest` container, exports +`FULLNODE_API_INFO` (Forest RPC on port 3456) and `FOREST_TEST_PRELOADED_ADDRESS`, +then runs `forest-dev tests devnet mpool` and `forest-dev tests devnet wallet`. + ## Local devnet development If you prefer to have Forest running directly on the host, you can comment it diff --git a/scripts/devnet/wallet_harness.sh b/scripts/devnet/wallet_harness.sh new file mode 100644 index 00000000000..14b7fc84701 --- /dev/null +++ b/scripts/devnet/wallet_harness.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Sourced (not executed) helpers for the wallet/mpool suite on the docker +# devnet. Run after the devnet is up (`setup.sh`) and synced (`check.sh`). +# +# The genesis key is the Lotus miner's default wallet, so using it as the test +# sender causes nonce contention. We fund a dedicated wallet instead. + +WALLET_HARNESS_PARENT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) +source "${WALLET_HARNESS_PARENT_PATH}/.env" + +export FOREST_CLI_PATH="${FOREST_CLI_PATH:-forest-cli}" +export FOREST_WALLET_PATH="${FOREST_WALLET_PATH:-forest-wallet}" +export DEVNET_TEST_FUND_AMT="${DEVNET_TEST_FUND_AMT:-100 FIL}" + +function devnet_wallet_env_init { + local token + token=$(docker exec forest cat "${FOREST_DATA_DIR}/token.jwt") + export FULLNODE_API_INFO="${token}:/ip4/127.0.0.1/tcp/${FOREST_RPC_PORT}/http" + + local tmp="${TMPDIR:-/tmp}" + + # Derive the genesis address via a throwaway keystore (`XDG_DATA_HOME`) so it + # never lands in the real keystores. + local genesis_key_path="${tmp}/devnet_genesis_wallet.key" + docker cp "forest:${LOTUS_DATA_DIR}/genesis-sectors/pre-seal-${MINER_ACTOR_ADDRESS}.key" "${genesis_key_path}" + local genesis_addr + genesis_addr="$(XDG_DATA_HOME="$(mktemp -d)" ${FOREST_WALLET_PATH} import "${genesis_key_path}")" + + # Fresh sender the miner never touches; mirror to the remote keystore so both + # `Backend::Local` and `Backend::Remote` variants work. + local test_addr test_key_path + test_addr="$(${FOREST_WALLET_PATH} new)" + test_key_path="${tmp}/devnet_test_wallet.key" + ${FOREST_WALLET_PATH} export "${test_addr}" > "${test_key_path}" + ${FOREST_WALLET_PATH} --remote-wallet import "${test_key_path}" + export FOREST_TEST_PRELOADED_ADDRESS="${test_addr}" + + echo "Devnet wallet env initialised:" + echo " FULLNODE_API_INFO=:/ip4/127.0.0.1/tcp/${FOREST_RPC_PORT}/http" + echo " FOREST_TEST_PRELOADED_ADDRESS=${FOREST_TEST_PRELOADED_ADDRESS}" + echo " Funding ${test_addr} with ${DEVNET_TEST_FUND_AMT} from ${genesis_addr}..." + + # Fund it from the genesis key and wait for the transfer. + ${FOREST_WALLET_PATH} --remote-wallet send --wait-confidence 0 --wait-timeout 10m \ + --from "${genesis_addr}" "${test_addr}" "${DEVNET_TEST_FUND_AMT}" + + local balance + balance="$(${FOREST_WALLET_PATH} --remote-wallet balance "${test_addr}" --exact-balance)" || balance="" + if [[ -z "${balance}" || "${balance}" == "0 FIL" ]]; then + echo "ERROR: dedicated test wallet ${test_addr} was not funded in time" >&2 + return 1 + fi + + ${FOREST_CLI_PATH} chain head + echo " balance=${balance}" +} diff --git a/src/dev/subcommands/tests_cmd.rs b/src/dev/subcommands/tests_cmd.rs index 8314764cf8c..a42a93ce551 100644 --- a/src/dev/subcommands/tests_cmd.rs +++ b/src/dev/subcommands/tests_cmd.rs @@ -1,19 +1,23 @@ // Copyright 2019-2026 ChainSafe Systems // SPDX-License-Identifier: Apache-2.0, MIT -mod calibnet; +mod shared; /// Integration tests #[derive(Debug, clap::Subcommand)] pub enum TestsCommand { #[command(subcommand)] - Calibnet(calibnet::CalibnetTestsCommand), + Calibnet(shared::TestCommand), + /// Run the wallet/mpool integration suite against a local devnet. The tests + /// themselves are chain-agnostic, so the same suite is reused. + #[command(subcommand)] + Devnet(shared::TestCommand), } impl TestsCommand { pub async fn run(self) -> anyhow::Result<()> { match self { - Self::Calibnet(cmd) => cmd.run().await, + Self::Calibnet(cmd) | Self::Devnet(cmd) => cmd.run().await, } } } diff --git a/src/dev/subcommands/tests_cmd/calibnet/mpool.rs b/src/dev/subcommands/tests_cmd/calibnet/mpool.rs deleted file mode 100644 index 9eedaf88e59..00000000000 --- a/src/dev/subcommands/tests_cmd/calibnet/mpool.rs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2019-2026 ChainSafe Systems -// SPDX-License-Identifier: Apache-2.0, MIT - -//! Calibnet mpool CLI integration tests (shared preloaded address). -//! -//! Run via [`calibnet_wallet_mpool`] before [`calibnet_wallet`]; see `mise test:wallet`. -//! Each test assumes the same environment as [`calibnet_wallet`]. - -use super::helpers::*; -use libtest_mimic::{Arguments, Failed, Trial}; -use std::time::Duration; - -/// Calibnet mpool integration tests -#[derive(Debug, clap::Args)] -pub struct CalibnetMpoolTestCommand {} - -impl CalibnetMpoolTestCommand { - pub async fn run(self) -> anyhow::Result<()> { - let args = Arguments { - test_threads: Some(8), - ..Default::default() - }; - libtest_mimic::run(&args, tests()).exit(); - } -} - -fn tests() -> Vec { - vec![Trial::test( - "mpool_replace_auto_unblocks_pending", - mpool_replace_auto_unblocks_pending, - )] -} - -fn mpool_replace_auto_unblocks_pending() -> Result<(), Failed> { - // Retry for 3 times in case race condition happens. - // Chance of having race condition in nonce is low as messages are broadcasted in the network pretty fast - for i in (0..3).rev() { - if i <= 0 { - block_on(mpool_replace_auto_unblocks_pending_async()); - break; - } else if std::panic::catch_unwind(|| block_on(mpool_replace_auto_unblocks_pending_async())) - .is_err() - { - // Retry after 5s on error - std::thread::sleep(Duration::from_secs(5)); - } else { - // Succeeded - break; - } - } - Ok(()) -} - -async fn mpool_replace_auto_unblocks_pending_async() { - let addr = FOREST_TEST_PRELOADED_ADDRESS.as_str(); - let nonce = mpool_nonce(addr).unwrap(); - - let cid = send_from_no_wait(addr, addr, FIL_AMT, Backend::Local).unwrap(); - poll_until_pending_nonce(addr, nonce).await.unwrap(); - - forest_cli(&[ - "mpool", - "replace", - "--from", - addr, - "--nonce", - &nonce.to_string(), - "--auto", - ]) - .unwrap(); - - assert!( - poll_until_state_search_msg(&cid).await.is_ok(), - "mpool replace --auto should replace message {cid} from {addr} at nonce {nonce}." - ); -} diff --git a/src/dev/subcommands/tests_cmd/calibnet.rs b/src/dev/subcommands/tests_cmd/shared.rs similarity index 65% rename from src/dev/subcommands/tests_cmd/calibnet.rs rename to src/dev/subcommands/tests_cmd/shared.rs index 83dd5a7c8b4..cce34f70281 100644 --- a/src/dev/subcommands/tests_cmd/calibnet.rs +++ b/src/dev/subcommands/tests_cmd/shared.rs @@ -5,14 +5,14 @@ mod helpers; mod mpool; mod wallet; -/// Calibnet integration tests +/// Shared integration tests (used by both calibnet and devnet) #[derive(Debug, clap::Subcommand)] -pub enum CalibnetTestsCommand { - Wallet(wallet::CalibnetWalletTestCommand), - Mpool(mpool::CalibnetMpoolTestCommand), +pub enum TestCommand { + Wallet(wallet::WalletTestCommand), + Mpool(mpool::MpoolTestCommand), } -impl CalibnetTestsCommand { +impl TestCommand { pub async fn run(self) -> anyhow::Result<()> { match self { Self::Wallet(cmd) => cmd.run().await, diff --git a/src/dev/subcommands/tests_cmd/calibnet/helpers.rs b/src/dev/subcommands/tests_cmd/shared/helpers.rs similarity index 99% rename from src/dev/subcommands/tests_cmd/calibnet/helpers.rs rename to src/dev/subcommands/tests_cmd/shared/helpers.rs index c6af65eabcc..17530d29764 100644 --- a/src/dev/subcommands/tests_cmd/calibnet/helpers.rs +++ b/src/dev/subcommands/tests_cmd/shared/helpers.rs @@ -27,7 +27,7 @@ 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 to seed a freshly-created delegated wallet. -pub const DELEGATE_FUND_AMT: &str = "3 micro FIL"; +pub const DELEGATE_FUND_AMT: &str = "30 micro FIL"; /// Maximum time to wait for a polled condition before failing the test. pub const POLL_TIMEOUT: Duration = Duration::from_secs(600); @@ -136,7 +136,7 @@ fn send_from_and_maybe_wait( ) -> anyhow::Result { let mut args = vec!["send", to, amount, "--from", from]; if wait { - args.extend(["--wait-confidence", "0", "--wait-timeout", "1m"]); + args.extend(["--wait-confidence", "0", "--wait-timeout", "10m"]); } let mut attempt = 1; loop { diff --git a/src/dev/subcommands/tests_cmd/shared/mpool.rs b/src/dev/subcommands/tests_cmd/shared/mpool.rs new file mode 100644 index 00000000000..09e6e08d509 --- /dev/null +++ b/src/dev/subcommands/tests_cmd/shared/mpool.rs @@ -0,0 +1,87 @@ +// Copyright 2019-2026 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT + +//! Mpool CLI integration tests (shared preloaded address). +//! +//! Run the mpool suite before the wallet suite; see `mise test:wallet`. +//! Each test assumes the same environment as the wallet suite. + +use super::helpers::*; +use libtest_mimic::{Arguments, Trial}; + +/// Mpool integration tests +#[derive(Debug, clap::Args)] +pub struct MpoolTestCommand {} + +impl MpoolTestCommand { + pub async fn run(self) -> anyhow::Result<()> { + let args = Arguments { + test_threads: Some(1), + ..Default::default() + }; + libtest_mimic::run(&args, tests()).exit(); + } +} + +fn tests() -> Vec { + vec![ + Trial::test("mpool_nonce_fix_auto_unblocks_pending", || { + block_on(mpool_nonce_fix_auto_unblocks_pending()); + Ok(()) + }), + Trial::test("mpool_replace_auto_unblocks_pending", || { + block_on(mpool_replace_auto_unblocks_pending()); + Ok(()) + }), + ] +} + +async fn mpool_nonce_fix_auto_unblocks_pending() { + let addr = FOREST_TEST_PRELOADED_ADDRESS.as_str(); + let nonce = mpool_nonce(addr).unwrap(); + // Skip one nonce so `--auto` has a gap to fill. + let next_nonce = nonce + 1; + forest_cli(&[ + "mpool", + "nonce-fix", + "--addr", + addr, + "--start", + &next_nonce.to_string(), + "--end", + &(next_nonce + 1).to_string(), + ]) + .unwrap(); + poll_until_pending_nonce(addr, next_nonce).await.unwrap(); + + forest_cli(&["mpool", "nonce-fix", "--addr", addr, "--auto"]).unwrap(); + + assert!( + poll_until_pending_nonce(addr, nonce).await.is_ok(), + "nonce-fix --auto should fill nonce gap at {nonce} for {addr}." + ); +} + +async fn mpool_replace_auto_unblocks_pending() { + let addr = FOREST_TEST_PRELOADED_ADDRESS.as_str(); + let nonce = mpool_nonce(addr).unwrap(); + + let cid = send_from_no_wait(addr, addr, FIL_AMT, Backend::Local).unwrap(); + poll_until_pending_nonce(addr, nonce).await.unwrap(); + + forest_cli(&[ + "mpool", + "replace", + "--from", + addr, + "--nonce", + &nonce.to_string(), + "--auto", + ]) + .unwrap(); + + assert!( + poll_until_state_search_msg(&cid).await.is_ok(), + "mpool replace --auto should replace message {cid} from {addr} at nonce {nonce}." + ); +} diff --git a/src/dev/subcommands/tests_cmd/calibnet/wallet.rs b/src/dev/subcommands/tests_cmd/shared/wallet.rs similarity index 98% rename from src/dev/subcommands/tests_cmd/calibnet/wallet.rs rename to src/dev/subcommands/tests_cmd/shared/wallet.rs index 1a954fe5eaa..989c662b58d 100644 --- a/src/dev/subcommands/tests_cmd/calibnet/wallet.rs +++ b/src/dev/subcommands/tests_cmd/shared/wallet.rs @@ -4,11 +4,11 @@ use super::helpers::*; use libtest_mimic::{Arguments, Trial}; -/// Calibnet wallet integration tests +/// Wallet integration tests #[derive(Debug, clap::Args)] -pub struct CalibnetWalletTestCommand {} +pub struct WalletTestCommand {} -impl CalibnetWalletTestCommand { +impl WalletTestCommand { pub async fn run(self) -> anyhow::Result<()> { let args = Arguments { test_threads: Some(8), diff --git a/src/state_manager/message_search.rs b/src/state_manager/message_search.rs index 4c67bf51703..f3ae7533754 100644 --- a/src/state_manager/message_search.rs +++ b/src/state_manager/message_search.rs @@ -100,13 +100,12 @@ impl StateManager { .get_actor(&message_from_id, *parent_tipset.parent_state()) .map_err(|e| Error::State(e.to_string()))?; - if parent_actor_state.is_none() + if (parent_actor_state.is_none() || (current_actor_state.sequence > message_sequence - && parent_actor_state.as_ref().unwrap().sequence <= message_sequence) + && parent_actor_state.as_ref().unwrap().sequence <= message_sequence)) + && let Some(receipt) = + self.tipset_executed_message(¤t, message, allow_replaced)? { - let receipt = self - .tipset_executed_message(¤t, message, allow_replaced)? - .context("Failed to get receipt with tipset_executed_message")?; return Ok(Some((current, receipt))); }