diff --git a/.config/forest.dic b/.config/forest.dic index 04d06fb1666a..e503ebf36b94 100644 --- a/.config/forest.dic +++ b/.config/forest.dic @@ -16,6 +16,7 @@ blockstore/SM BLS butterflynet calibnet +calldata callee canonicalization CAR/SM @@ -51,6 +52,7 @@ Ethereum eth exa EVM +f4 F3 FFI FIL diff --git a/.github/workflows/forest.yml b/.github/workflows/forest.yml index 7ec3b2b03bea..2d5fde98efea 100644 --- a/.github/workflows/forest.yml +++ b/.github/workflows/forest.yml @@ -396,6 +396,38 @@ jobs: chmod +x ~/.cargo/bin/forest* - run: ./scripts/tests/calibnet_eth_mapping_check.sh timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} + calibnet-api-test-stateful: + needs: + - build-ubuntu + name: Calibnet api test-stateful check + runs-on: ubuntu-24.04 + steps: + - run: lscpu + - uses: actions/cache@v4 + with: + path: "${{ env.FIL_PROOFS_PARAMETER_CACHE }}" + key: proof-params-keys + - name: Checkout Sources + uses: actions/checkout@v4 + - uses: actions/download-artifact@v5 + with: + name: "forest-${{ runner.os }}" + path: ~/.cargo/bin + - uses: actions/download-artifact@v4 + with: + name: "forest-${{ runner.os }}" + path: ~/.cargo/bin + - name: Set permissions + run: | + chmod +x ~/.cargo/bin/forest* + - name: Api test stateful check + env: + CALIBNET_WALLET: "${{ secrets.CALIBNET_WALLET }}" + run: | + if [[ "$CALIBNET_WALLET" != "" ]]; then + ./scripts/tests/calibnet_api_test_stateful_check.sh "$CALIBNET_WALLET" + fi + timeout-minutes: ${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }} db-migration-checks: needs: - build-ubuntu @@ -603,6 +635,7 @@ jobs: - calibnet-no-discovery-checks - calibnet-kademlia-checks - calibnet-eth-mapping-check + - calibnet-api-test-stateful - db-migration-checks - local-devnet-check # - local-devnet-curio-check diff --git a/Cargo.lock b/Cargo.lock index fde64272a705..3977e8ba1673 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3269,6 +3269,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-test", + "tokio-tungstenite", "tokio-util", "toml 0.8.23", "tower 0.5.2", @@ -9269,6 +9270,18 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.16" @@ -9587,6 +9600,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.15", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -9759,6 +9789,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 9df402e0e60e..808ca372470f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -208,6 +208,7 @@ tera = { version = "1", default-features = false } thiserror = "2" tokio = { version = "1", features = ['full'] } tokio-stream = { version = "0.1", features = ["fs", "io-util"] } +tokio-tungstenite = "0.27.0" tokio-util = { version = "0.7", features = ["compat", "io-util"] } toml = "0.8" tower = { version = "0.5", features = ["util"] } diff --git a/documentation/src/developer_documentation/rpc_stateful_tests.md b/documentation/src/developer_documentation/rpc_stateful_tests.md new file mode 100644 index 000000000000..ba657f83e528 --- /dev/null +++ b/documentation/src/developer_documentation/rpc_stateful_tests.md @@ -0,0 +1,76 @@ +# RPC Stateful Tests + +Some methods in the Filecoin Ethereum JSON-RPC API require stateful interactions for meaningful testing. These tests validate both **schema compatibility** and **method semantics**, especially for RPC endpoints that rely on internal node state. + +This includes: + +- All subscription-based methods +- Filter-related methods (e.g., `eth_newFilter`, `eth_getFilterLogs`) + +## Prerequisites + +Before running the tests, perform the following setup steps: + +1. Run a Lotus or Forest node (calibnet recommended). Make sure `FULLNODE_API_INFO` is defined. +2. Create an f4 address, fund it, and deploy a test smart contract (the deployed contract must emit an event with a known topic when invoked). +3. The f4 address must hold enough FIL to invoke the contract. + + Run the test suite with: + `forest-tool api test-stateful --to --from --payload --topic ` + + where: + + - `CONTRACT_ADDR`: f4 address of the deployed smart contract + - `FROM_ADDR`: f4 address invoking the contract + - `INVOKE_PAYLOAD`: Calldata that will trigger the contract's event + - `TOPIC`: The event topic expected to be emitted during invocation + +## Example output + +```console +export FULLNODE_API_INFO=":/ip4/127.0.0.1/tcp/1234/http" +forest-tool api test-stateful \ + --to t410f2jhqlciub25ad3immo5kug2fluj625xiex6lbyi \ + --from t410f5uudc3yoiodsva73rxyx5sxeiaadpaplsu6mofy \ + --payload 40c10f19000000000000000000000000ed28316f0e43872a83fb8df17ecae440003781eb00000000000000000000000000000000000000000000000006f05b59d3b20000 \ + --topic 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef +running 7 tests +test eth_newFilter install/uninstall ... ok +test eth_newFilter under limit ... ok +test eth_newFilter just under limit ... ok +test eth_newFilter over limit ... ok +test eth_newBlockFilter works ... ok +test eth_newPendingTransactionFilter works ... ok +test eth_getFilterLogs works ... ok +test result: ok. 7 passed; 0 failed; 0 ignored; 0 filtered out +``` + +The goal is to ensure that Forest now passes all the existing scenarios. These scenarios are not exhaustive, and additional ones can be added as needed. + +## Adding a new test + +To extend test coverage for another RPC method or cover more semantics: + +1. Add the RPC method to Forest if not yet implemented, following the guidance in [RPC compatibility guide](./rpc_api_compatibility.md). +2. Create a new test scenario in: +[`stateful_tests.rs`](../../../src/tool/subcommands/api_cmd/stateful_tests.rs) +3. Your internal test function should return `Ok(())` on success. Use `anyhow::Result` for error handling. + + Ensure the test behaves consistently on both Lotus and Forest nodes. + +## Example test function +```rust +pub async fn test_eth_method(client: Arc) -> anyhow::Result<()> { + // Setup call to the method + // Assert intermediate states + // State cleanup + // Return Ok when the sequence completes successfully + Ok(()) +} +``` + +## Notes + +The current test framework assumes a running node and a valid wallet. + +Consider implementing `forest-tool evm deploy` and `forest-tool evm invoke` subcommands to simplify contract deployment and test invocation. diff --git a/scripts/tests/calibnet_api_test_stateful_check.sh b/scripts/tests/calibnet_api_test_stateful_check.sh new file mode 100755 index 000000000000..798557f6b37f --- /dev/null +++ b/scripts/tests/calibnet_api_test_stateful_check.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# This script tests RPC API stateful tests on a live forest node. +# It requires both `forest`, `forest-wallet` and `forest-tool` to be in the PATH. + +set -euxo pipefail + +source "$(dirname "$0")/harness.sh" + + +forest_init "$@" + +# This is the address of a Calibnet pre-deployed simple ERC20 contract. +# You can find the hex and source code in 'src/tool/subcommands/api_cmd/contracts/erc20'. +TO_ADDRESS="t410fp6e7drelxau7nf76tcn6gva22t5jafefhevubwi" + +FROM_ADDRESS="t410f2avianksmit2cl2bqk53qant7nm7rdmk63twa5y" + +# This payload corresponds to minting new tokens for the 'FROM_ADDRESS' and will trigger a log event upon success. +# To compute the calldata, you can use the 'cast calldata' subcommand with the following arguments: +# cast calldata "mint(address,uint256)" 0x7f89f1c48bb829f697fe989be3541ad4fa901485 1000000000000000000 +# +# Note that 0x7f89f1c48bb829f697fe989be3541ad4fa901485 is the Ethereum address corresponding to the contract f4 address. +PAYLOAD="0x40c10f190000000000000000000000007f89f1c48bb829f697fe989be3541ad4fa9014850000000000000000000000000000000000000000000000000de0b6b3a7640000" + +# This topic is derived using the keccak256 hash of the event signature 'Mint(address,uint256)' +# To compute the topic, you can use the 'cast keccak256' subcommand with the following argument: +# cast keccak256 "Mint(address,uint256)" +TOPIC="0x0f6798a560793a54c3bcfe86a93cde1e73087d944c0ea20544137d4121396885" + +$FOREST_TOOL_PATH api test-stateful \ + --to "$TO_ADDRESS" \ + --from "$FROM_ADDRESS" \ + --payload "$PAYLOAD" \ + --topic "$TOPIC" diff --git a/scripts/tests/harness.sh b/scripts/tests/harness.sh index f8e9806c9eb6..8e34f46b4307 100644 --- a/scripts/tests/harness.sh +++ b/scripts/tests/harness.sh @@ -139,6 +139,12 @@ function forest_init { forest_wait_api forest_wait_for_sync forest_check_db_stats + + DATA_DIR=$( $FOREST_CLI_PATH config dump | grep "data_dir" | cut -d' ' -f3- | tr -d '"' ) + ADMIN_TOKEN=$(cat "${DATA_DIR}/token") + FULLNODE_API_INFO="${ADMIN_TOKEN}:/ip4/127.0.0.1/tcp/2345/http" + + export FULLNODE_API_INFO } function forest_init_with_f3 { diff --git a/src/eth/mod.rs b/src/eth/mod.rs index 1b119e904ecd..bdc41c943ae2 100644 --- a/src/eth/mod.rs +++ b/src/eth/mod.rs @@ -4,7 +4,7 @@ mod eip_1559_transaction; mod eip_155_transaction; mod homestead_transaction; -mod transaction; +pub mod transaction; pub use eip_155_transaction::*; pub use eip_1559_transaction::*; diff --git a/src/eth/transaction.rs b/src/eth/transaction.rs index 1d402fab20eb..a9e4dbe1c7dd 100644 --- a/src/eth/transaction.rs +++ b/src/eth/transaction.rs @@ -60,6 +60,7 @@ pub enum EVMMethod { /// Ethereum transaction which can be of different types. /// The currently supported types are defined in [FIP-0091](https://github.com/filecoin-project/FIPs/blob/020bcb412ee20a2879b4a710337959c51b938d3b/FIPS/fip-0091.md). +#[derive(Debug)] pub enum EthTx { Homestead(Box), Eip1559(Box), @@ -155,7 +156,7 @@ impl EthTx { } } - fn rlp_signed_message(&self) -> anyhow::Result> { + pub fn rlp_signed_message(&self) -> anyhow::Result> { match self { Self::Homestead(tx) => (*tx).rlp_signed_message(), Self::Eip1559(tx) => (*tx).rlp_signed_message(), diff --git a/src/rpc/channel.rs b/src/rpc/channel.rs index 22da36412db2..b7d31db0af9c 100644 --- a/src/rpc/channel.rs +++ b/src/rpc/channel.rs @@ -246,7 +246,7 @@ fn create_notif_message( ) -> anyhow::Result> { let method = sink.method_name(); let channel_id = sink.channel_id(); - let result = serde_json::to_string(result)?; + let result = serde_json::to_value(result)?; let msg = serde_json::json!({ "jsonrpc": "2.0", "method": method, diff --git a/src/rpc/methods/eth.rs b/src/rpc/methods/eth.rs index a6f24f1863dc..a35d9381ab9a 100644 --- a/src/rpc/methods/eth.rs +++ b/src/rpc/methods/eth.rs @@ -213,6 +213,10 @@ impl EthUint64 { .try_into()?, ))) } + + pub fn to_hex_string(&self) -> String { + format!("0x{}", hex::encode(self.0.to_be_bytes())) + } } #[derive( @@ -3038,14 +3042,16 @@ fn eth_filter_result_from_events( } fn eth_filter_result_from_tipsets(events: &[CollectedEvent]) -> anyhow::Result { - Ok(EthFilterResult::Txs(eth_filter_logs_from_tipsets(events)?)) + Ok(EthFilterResult::Hashes(eth_filter_logs_from_tipsets( + events, + )?)) } fn eth_filter_result_from_messages( ctx: &Ctx, events: &[CollectedEvent], ) -> anyhow::Result { - Ok(EthFilterResult::Txs(eth_filter_logs_from_messages( + Ok(EthFilterResult::Hashes(eth_filter_logs_from_messages( ctx, events, )?)) } diff --git a/src/rpc/methods/eth/types.rs b/src/rpc/methods/eth/types.rs index a9e4e24431e7..20dc886e9890 100644 --- a/src/rpc/methods/eth/types.rs +++ b/src/rpc/methods/eth/types.rs @@ -556,8 +556,7 @@ impl From for EthFilterSpec { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] pub enum EthFilterResult { - Blocks(Vec), - Txs(Vec), + Hashes(Vec), Logs(Vec), } lotus_json_with_self!(EthFilterResult); @@ -565,8 +564,7 @@ lotus_json_with_self!(EthFilterResult); impl EthFilterResult { pub fn is_empty(&self) -> bool { match self { - Self::Blocks(v) => v.is_empty(), - Self::Txs(v) => v.is_empty(), + Self::Hashes(v) => v.is_empty(), Self::Logs(v) => v.is_empty(), } } @@ -575,8 +573,7 @@ impl EthFilterResult { impl PartialEq for EthFilterResult { fn eq(&self, other: &Self) -> bool { match (self, other) { - (Self::Blocks(a), Self::Blocks(b)) => a == b, - (Self::Txs(a), Self::Txs(b)) => a == b, + (Self::Hashes(a), Self::Hashes(b)) => a == b, (Self::Logs(a), Self::Logs(b)) => a == b, _ => self.is_empty() && other.is_empty(), } diff --git a/src/tool/subcommands/api_cmd.rs b/src/tool/subcommands/api_cmd.rs index d6dce47e9821..fdebd3c4bfe9 100644 --- a/src/tool/subcommands/api_cmd.rs +++ b/src/tool/subcommands/api_cmd.rs @@ -4,6 +4,7 @@ mod api_compare_tests; mod generate_test_snapshot; mod report; +mod stateful_tests; mod test_snapshot; use crate::cli_shared::{chain_path, read_config}; @@ -17,6 +18,7 @@ use crate::rpc::eth::types::*; use crate::rpc::prelude::*; use crate::shim::address::Address; use crate::tool::offline_server::start_offline_server; +use crate::tool::subcommands::api_cmd::stateful_tests::TestTransaction; use crate::tool::subcommands::api_cmd::test_snapshot::{Index, Payload}; use crate::utils::UrlFromMultiAddr; use anyhow::{Context as _, bail, ensure}; @@ -202,6 +204,51 @@ pub enum ApiCommands { #[arg(num_args = 1.., required = true)] files: Vec, }, + /// Run multiple stateful JSON-RPC API tests against a Filecoin node. + /// + /// Connection: uses `FULLNODE_API_INFO` from the environment. + /// + /// Some tests require sending a transaction to trigger events; the provided + /// `from`, `to`, `payload`, and `topic` inputs are used for those cases. + /// + /// Useful for verifying methods like `eth_newFilter`, `eth_getFilterLogs`, and others + /// that rely on internal state. + /// + /// Inputs: + /// - `--to`, `--from`: delegated Filecoin (f4) addresses + /// - `--payload`: calldata in hex (accepts optional `0x` prefix) + /// - `--topic`: `32‑byte` event topic in hex + /// - `--filter`: run only tests that interact with a specific RPC method + /// + /// Example output: + /// ```text + /// running 7 tests + /// test eth_newFilter install/uninstall ... ok + /// test eth_newFilter under limit ... ok + /// test eth_newFilter just under limit ... ok + /// test eth_newFilter over limit ... ok + /// test eth_newBlockFilter works ... ok + /// test eth_newPendingTransactionFilter works ... ok + /// test eth_getFilterLogs works ... ok + /// test result: ok. 7 passed; 0 failed; 0 ignored; 0 filtered out + /// ``` + TestStateful { + /// Test Transaction `to` address (delegated f4) + #[arg(long)] + to: Address, + /// Test Transaction `from` address (delegated f4) + #[arg(long)] + from: Address, + /// Test Transaction hex `payload` + #[arg(long)] + payload: String, + /// Log `topic` to search for + #[arg(long)] + topic: EthHash, + /// Filter which tests to run according to method name. Case sensitive. + #[arg(long, default_value = "")] + filter: String, + }, } impl ApiCommands { @@ -350,6 +397,30 @@ impl ApiCommands { }; } } + Self::TestStateful { + to, + from, + payload, + topic, + filter, + } => { + let client = Arc::new(rpc::Client::default_or_from_env(None)?); + + let payload = { + let clean = payload.strip_prefix("0x").unwrap_or(&payload); + hex::decode(clean) + .with_context(|| format!("invalid --payload hex: {payload}"))? + }; + let tx = TestTransaction { + to, + from, + payload, + topic, + }; + + let tests = stateful_tests::create_tests(tx).await; + stateful_tests::run_tests(tests, client, filter).await?; + } Self::DumpTests { create_tests_args, path, diff --git a/src/tool/subcommands/api_cmd/contracts/erc20/roberto.hex b/src/tool/subcommands/api_cmd/contracts/erc20/roberto.hex new file mode 100644 index 000000000000..e341e9578d62 --- /dev/null +++ b/src/tool/subcommands/api_cmd/contracts/erc20/roberto.hex @@ -0,0 +1 @@ +60c0604052600b60809081526a2937b132b93a37a1b7b4b760a91b60a0525f90610029908261024f565b506040805180820190915260038152622927a160e91b6020820152600190610051908261024f565b5034801561005d575f5ffd5b50600380546001600160a01b03191633908117909155610094906100836012600a610402565b61008f906103e8610414565b610099565b61043e565b6001600160a01b0382166100f35760405162461bcd60e51b815260206004820152601b60248201527f45524332303a206d696e7420746f207a65726f20616464726573730000000000604482015260640160405180910390fd5b8060025f828254610104919061042b565b90915550506001600160a01b0382165f908152600460205260408120805483929061013090849061042b565b90915550506040518181526001600160a01b038316907f0f6798a560793a54c3bcfe86a93cde1e73087d944c0ea20544137d41213968859060200160405180910390a26040518181526001600160a01b038316905f907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b634e487b7160e01b5f52604160045260245ffd5b600181811c908216806101df57607f821691505b6020821081036101fd57634e487b7160e01b5f52602260045260245ffd5b50919050565b601f82111561024a57805f5260205f20601f840160051c810160208510156102285750805b601f840160051c820191505b81811015610247575f8155600101610234565b50505b505050565b81516001600160401b03811115610268576102686101b7565b61027c8161027684546101cb565b84610203565b6020601f8211600181146102ae575f83156102975750848201515b5f19600385901b1c1916600184901b178455610247565b5f84815260208120601f198516915b828110156102dd57878501518255602094850194600190920191016102bd565b50848210156102fa57868401515f19600387901b60f8161c191681555b50505050600190811b01905550565b634e487b7160e01b5f52601160045260245ffd5b6001815b60018411156103585780850481111561033c5761033c610309565b600184161561034a57908102905b60019390931c928002610321565b935093915050565b5f8261036e575060016103fc565b8161037a57505f6103fc565b8160018114610390576002811461039a576103b6565b60019150506103fc565b60ff8411156103ab576103ab610309565b50506001821b6103fc565b5060208310610133831016604e8410600b84101617156103d9575081810a6103fc565b6103e55f19848461031d565b805f19048211156103f8576103f8610309565b0290505b92915050565b5f61040d8383610360565b9392505050565b80820281158282048414176103fc576103fc610309565b808201808211156103fc576103fc610309565b6106f58061044b5f395ff3fe608060405234801561000f575f5ffd5b5060043610610085575f3560e01c806370a082311161005857806370a08231146100f65780638da5cb5b1461011e57806395d89b4114610149578063a9059cbb14610151575f5ffd5b806306fdde031461008957806318160ddd146100a7578063313ce567146100b957806340c10f19146100d3575b5f5ffd5b610091610164565b60405161009e91906105b5565b60405180910390f35b6002545b60405190815260200161009e565b6100c1601281565b60405160ff909116815260200161009e565b6100e66100e1366004610605565b6101ef565b604051901515815260200161009e565b6100ab61010436600461062d565b6001600160a01b03165f9081526004602052604090205490565b600354610131906001600160a01b031681565b6040516001600160a01b03909116815260200161009e565b6100916102aa565b6100e661015f366004610605565b6102b7565b5f80546101709061064d565b80601f016020809104026020016040519081016040528092919081815260200182805461019c9061064d565b80156101e75780601f106101be576101008083540402835291602001916101e7565b820191905f5260205f20905b8154815290600101906020018083116101ca57829003601f168201915b505050505081565b6003545f906001600160a01b031633146102405760405162461bcd60e51b815260206004820152600d60248201526c2737ba103a34329037bbb732b960991b60448201526064015b60405180910390fd5b6001600160a01b0383166102965760405162461bcd60e51b815260206004820152601b60248201527f45524332303a206d696e7420746f207a65726f206164647265737300000000006044820152606401610237565b6102a083836102c3565b5060015b92915050565b600180546101709061064d565b5f6102a03384846103dd565b6001600160a01b0382166103195760405162461bcd60e51b815260206004820152601b60248201527f45524332303a206d696e7420746f207a65726f206164647265737300000000006044820152606401610237565b8060025f82825461032a9190610699565b90915550506001600160a01b0382165f9081526004602052604081208054839290610356908490610699565b90915550506040518181526001600160a01b038316907f0f6798a560793a54c3bcfe86a93cde1e73087d944c0ea20544137d41213968859060200160405180910390a26040518181526001600160a01b038316905f907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b6001600160a01b03831661043d5760405162461bcd60e51b815260206004820152602160248201527f45524332303a207472616e736665722066726f6d207a65726f206164647265736044820152607360f81b6064820152608401610237565b6001600160a01b0382166104935760405162461bcd60e51b815260206004820152601f60248201527f45524332303a207472616e7366657220746f207a65726f2061646472657373006044820152606401610237565b6001600160a01b0383165f908152600460205260409020548111156105095760405162461bcd60e51b815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e7420657863656564732062604482015265616c616e636560d01b6064820152608401610237565b6001600160a01b0383165f90815260046020526040812080548392906105309084906106ac565b90915550506001600160a01b0382165f908152600460205260408120805483929061055c908490610699565b92505081905550816001600160a01b0316836001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef836040516105a891815260200190565b60405180910390a3505050565b602081525f82518060208401528060208501604085015e5f604082850101526040601f19601f83011684010191505092915050565b80356001600160a01b0381168114610600575f5ffd5b919050565b5f5f60408385031215610616575f5ffd5b61061f836105ea565b946020939093013593505050565b5f6020828403121561063d575f5ffd5b610646826105ea565b9392505050565b600181811c9082168061066157607f821691505b60208210810361067f57634e487b7160e01b5f52602260045260245ffd5b50919050565b634e487b7160e01b5f52601160045260245ffd5b808201808211156102a4576102a4610685565b818103818111156102a4576102a461068556fea26469706673582212202c3acfb913ed303016cdee3d4a0c76585286176fa1d0612218f246e1d722c8a064736f6c634300081e0033 \ No newline at end of file diff --git a/src/tool/subcommands/api_cmd/contracts/erc20/roberto.sol b/src/tool/subcommands/api_cmd/contracts/erc20/roberto.sol new file mode 100644 index 000000000000..c73ea5a00d5e --- /dev/null +++ b/src/tool/subcommands/api_cmd/contracts/erc20/roberto.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +// Implements an ERC20 token. Supports transfers, balance checks, +// and minting by the owner. Primarily used for testing events/logs. + +pragma solidity ^0.8.0; + +interface IERC20 { + function totalSupply() external view returns (uint256); + + function balanceOf(address account) external view returns (uint256); + + function transfer( + address recipient, + uint256 amount + ) external returns (bool); + + event Transfer(address indexed from, address indexed to, uint256 value); +} + +contract RobertoCoin is IERC20 { + string public name = "RobertoCoin"; + string public symbol = "ROB"; + uint8 public constant decimals = 18; + + uint256 private _totalSupply; + address public owner; + + mapping(address => uint256) private _balances; + + event Mint(address indexed to, uint256 value); + + modifier onlyOwner() { + require(msg.sender == owner, "Not the owner"); + _; + } + + constructor() { + owner = msg.sender; + // Optionally mint here a fixed amount + _mint(owner, 1000 * 10 ** uint256(decimals)); // initialSupply in wei units + } + + // IERC20 functions + + function totalSupply() public view override returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) public view override returns (uint256) { + return _balances[account]; + } + + function transfer( + address recipient, + uint256 amount + ) public override returns (bool) { + _transfer(msg.sender, recipient, amount); + return true; + } + + // Mint function for owner + + function mint(address to, uint256 amount) public onlyOwner returns (bool) { + require(to != address(0), "ERC20: mint to zero address"); + _mint(to, amount); + return true; + } + + // Internal transfer function + + function _transfer( + address sender, + address recipient, + uint256 amount + ) internal { + require(sender != address(0), "ERC20: transfer from zero address"); + require(recipient != address(0), "ERC20: transfer to zero address"); + require( + _balances[sender] >= amount, + "ERC20: transfer amount exceeds balance" + ); + + _balances[sender] -= amount; + _balances[recipient] += amount; + + emit Transfer(sender, recipient, amount); + } + + // Internal mint function + + function _mint(address account, uint256 amount) internal { + require(account != address(0), "ERC20: mint to zero address"); + + _totalSupply += amount; + _balances[account] += amount; + + emit Mint(account, amount); + emit Transfer(address(0), account, amount); + } +} diff --git a/src/tool/subcommands/api_cmd/stateful_tests.rs b/src/tool/subcommands/api_cmd/stateful_tests.rs new file mode 100644 index 000000000000..e87a05bf165b --- /dev/null +++ b/src/tool/subcommands/api_cmd/stateful_tests.rs @@ -0,0 +1,655 @@ +// Copyright 2019-2025 ChainSafe Systems +// SPDX-License-Identifier: Apache-2.0, MIT +use crate::eth::EVMMethod; +use crate::rpc::eth::EthUint64; +use crate::rpc::eth::types::*; +use crate::rpc::types::ApiTipsetKey; +use crate::rpc::{self, RpcMethod, prelude::*}; +use crate::shim::{address::Address, message::Message}; + +use anyhow::{Context, ensure}; +use cbor4ii::core::Value; +use cid::Cid; +use futures::{SinkExt, StreamExt}; +use serde_json::json; +use tokio::time::Duration; +use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage}; + +use std::io::{self, Write}; +use std::pin::Pin; +use std::sync::Arc; + +type TestRunner = Arc< + dyn Fn(Arc) -> Pin> + Send>> + + Send + + Sync, +>; + +#[derive(Clone)] +pub struct TestTransaction { + pub to: Address, + pub from: Address, + pub payload: Vec, + pub topic: EthHash, +} + +#[derive(Clone)] +pub struct RpcTestScenario { + pub run: TestRunner, + pub name: Option<&'static str>, + pub should_fail_with: Option<&'static str>, + pub used_methods: Vec<&'static str>, + pub ignore: Option<&'static str>, +} + +impl RpcTestScenario { + /// Create a basic scenario from a simple async closure. + pub fn basic(run_fn: F) -> Self + where + F: Fn(Arc) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let run = Arc::new(move |client: Arc| { + Box::pin(run_fn(client)) as Pin> + Send>> + }); + Self { + run, + name: Default::default(), + should_fail_with: Default::default(), + used_methods: Default::default(), + ignore: None, + } + } + + fn name(mut self, name: &'static str) -> Self { + self.name = Some(name); + self + } + + pub fn should_fail_with(mut self, msg: &'static str) -> Self { + self.should_fail_with = Some(msg); + self + } + + fn using(mut self) -> Self + where + M: RpcMethod, + { + self.used_methods.push(M::NAME); + if let Some(alias) = M::NAME_ALIAS { + self.used_methods.push(alias); + } + self + } + + fn ignore(mut self, msg: &'static str) -> Self { + self.ignore = Some(msg); + self + } +} + +pub(super) async fn run_tests( + tests: impl IntoIterator + Clone, + client: impl Into>, + filter: String, +) -> anyhow::Result<()> { + let client: Arc = client.into(); + let mut passed = 0; + let mut failed = 0; + let mut ignored = 0; + let mut filtered = 0; + + println!("running {} tests", tests.clone().into_iter().count()); + + for (i, test) in tests.into_iter().enumerate() { + if !filter.is_empty() && !test.used_methods.iter().any(|m| m.starts_with(&filter)) { + filtered += 1; + continue; + } + if test.ignore.is_some() { + ignored += 1; + println!( + "test {} ... ignored", + if let Some(name) = test.name { + name.to_string() + } else { + format!("#{i}") + }, + ); + continue; + } + + print!( + "test {} ... ", + if let Some(name) = test.name { + name.to_string() + } else { + format!("#{i}") + } + ); + + io::stdout().flush()?; + + let result = (test.run)(client.clone()).await; + + match result { + Ok(_) => { + if let Some(expected_msg) = test.should_fail_with { + println!("FAILED (expected failure containing '{expected_msg}')"); + failed += 1; + } else { + println!("ok"); + passed += 1; + } + } + Err(e) => { + if let Some(expected_msg) = test.should_fail_with { + let err_str = format!("{e:#}"); + if err_str.contains(expected_msg) { + println!("ok"); + passed += 1; + } else { + println!("FAILED ({e:#})"); + failed += 1; + } + } else { + println!("FAILED {e:#}"); + failed += 1; + } + } + } + } + let status = if failed == 0 { "ok" } else { "FAILED" }; + println!( + "test result: {status}. {passed} passed; {failed} failed; {ignored} ignored; {filtered} filtered out" + ); + ensure!(failed == 0, "{failed} test(s) failed"); + Ok(()) +} + +#[allow(unreachable_code)] +async fn next_tipset(client: &rpc::Client) -> anyhow::Result<()> { + async fn close_channel( + stream: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + id: &serde_json::Value, + ) -> anyhow::Result<()> { + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "xrpc.cancel", + "params": [id] + }); + + stream + .send(WsMessage::Text(request.to_string().into())) + .await + .context("failed to send close channel request")?; + + Ok(()) + } + + let mut url = client.base_url().clone(); + url.set_scheme("ws") + .map_err(|_| anyhow::anyhow!("failed to set scheme"))?; + url.set_path("/rpc/v1"); + + // Note: The token is not required for the ChainNotify method. + let (mut ws_stream, _) = connect_async(url.as_str()).await?; + + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "Filecoin.ChainNotify", + "params": [] + }); + ws_stream + .send(WsMessage::Text(request.to_string().into())) + .await?; + + let mut channel_id: Option = None; + + // The goal of this loop is to wait for a new tipset to arrive without using a busy loop or sleep. + // It processes incoming WebSocket messages until it encounters an "apply" or "revert" change type. + // If an "apply" change is found, it closes the channel and exits. If a "revert" change is found, + // it closes the channel and raises an error. Any channel protocol or parameter validation issues result in an error. + loop { + let msg = match tokio::time::timeout(Duration::from_secs(180), ws_stream.next()).await { + Ok(Some(msg)) => msg, + Ok(None) => anyhow::bail!("WebSocket stream closed"), + Err(_) => { + if let Some(id) = channel_id.as_ref() { + let _ = close_channel(&mut ws_stream, id).await; + } + let _ = ws_stream.close(None).await; + anyhow::bail!("timeout waiting for tipset"); + } + }; + match msg { + Ok(WsMessage::Text(text)) => { + let json: serde_json::Value = serde_json::from_str(&text)?; + + if let Some(id) = json.get("result") { + channel_id = Some(id.clone()); + } else { + let method = json!("xrpc.ch.val"); + anyhow::ensure!(json.get("method") == Some(&method)); + + if let Some(params) = json.get("params").and_then(|v| v.as_array()) { + if let Some(id) = params.first() { + anyhow::ensure!(Some(id) == channel_id.as_ref()); + } else { + anyhow::bail!("expecting an open channel"); + } + if let Some(changes) = params.get(1).and_then(|v| v.as_array()) { + for change in changes { + if let Some(type_) = change.get("Type").and_then(|v| v.as_str()) { + if type_ == "apply" { + let id = channel_id.as_ref().ok_or_else(|| { + anyhow::anyhow!("subscription not opened") + })?; + close_channel(&mut ws_stream, id).await?; + ws_stream.close(None).await?; + return Ok(()); + } else if type_ == "revert" { + let id = channel_id.as_ref().ok_or_else(|| { + anyhow::anyhow!("subscription not opened") + })?; + close_channel(&mut ws_stream, id).await?; + ws_stream.close(None).await?; + anyhow::bail!("revert"); + } + } + } + } + } else { + let id = channel_id + .as_ref() + .ok_or_else(|| anyhow::anyhow!("subscription not opened"))?; + close_channel(&mut ws_stream, id).await?; + ws_stream.close(None).await?; + anyhow::bail!("expecting params"); + } + } + } + Err(..) | Ok(WsMessage::Close(..)) => { + let id = channel_id + .as_ref() + .ok_or_else(|| anyhow::anyhow!("subscription not opened"))?; + close_channel(&mut ws_stream, id).await?; + ws_stream.close(None).await?; + anyhow::bail!("unexpected error or close message"); + } + _ => { + // Ignore other message types + } + } + } + + unreachable!("loop always returns within the branches above") +} + +async fn wait_pending_message(client: &rpc::Client, message_cid: Cid) -> anyhow::Result<()> { + let mut retries = 100; + loop { + let pending = client + .call(MpoolPending::request((ApiTipsetKey(None),))?) + .await?; + + if pending.0.iter().any(|msg| msg.cid() == message_cid) { + break Ok(()); + } + ensure!(retries != 0, "Message not found in mpool"); + retries -= 1; + + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +fn create_eth_new_filter_test() -> RpcTestScenario { + RpcTestScenario::basic(|client| async move { + const BLOCK_RANGE: u64 = 200; + + let last_block = client.call(EthBlockNumber::request(())?).await?; + + let filter_spec = EthFilterSpec { + from_block: Some(EthUint64(last_block.0.saturating_sub(BLOCK_RANGE)).to_hex_string()), + to_block: Some(last_block.to_hex_string()), + ..Default::default() + }; + + let filter_id = client.call(EthNewFilter::request((filter_spec,))?).await?; + + let removed = client + .call(EthUninstallFilter::request((filter_id.clone(),))?) + .await?; + anyhow::ensure!(removed); + + let removed = client + .call(EthUninstallFilter::request((filter_id,))?) + .await?; + anyhow::ensure!(!removed); + + Ok(()) + }) +} + +fn create_eth_new_filter_limit_test(count: usize) -> RpcTestScenario { + RpcTestScenario::basic(move |client| async move { + const BLOCK_RANGE: u64 = 200; + + let last_block = client.call(EthBlockNumber::request(())?).await?; + + let filter_spec = EthFilterSpec { + from_block: Some(format!("0x{:x}", last_block.0.saturating_sub(BLOCK_RANGE))), + to_block: Some(last_block.to_hex_string()), + ..Default::default() + }; + + let mut ids = vec![]; + + for _ in 0..count { + let result = client + .call(EthNewFilter::request((filter_spec.clone(),))?) + .await; + + match result { + Ok(filter_id) => ids.push(filter_id), + Err(e) => { + // Cleanup any filters created so far to leave a clean state + for id in ids { + let removed = client.call(EthUninstallFilter::request((id,))?).await?; + anyhow::ensure!(removed); + } + anyhow::bail!(e) + } + } + } + + for id in ids { + let removed = client.call(EthUninstallFilter::request((id,))?).await?; + anyhow::ensure!(removed); + } + + Ok(()) + }) +} + +fn eth_new_block_filter() -> RpcTestScenario { + RpcTestScenario::basic(move |client| async move { + async fn process_filter(client: &rpc::Client, filter_id: &FilterID) -> anyhow::Result<()> { + let filter_result = client + .call(EthGetFilterChanges::request((filter_id.clone(),))?) + .await?; + + if let EthFilterResult::Hashes(prev_hashes) = filter_result { + let verify_hashes = async |hashes: &[EthHash]| -> anyhow::Result<()> { + for hash in hashes { + let _block = client + .call(EthGetBlockByHash::request((hash.clone(), false))?) + .await?; + } + Ok(()) + }; + verify_hashes(&prev_hashes).await?; + + // Wait for the next block to arrive + next_tipset(client).await?; + + let filter_result = client + .call(EthGetFilterChanges::request((filter_id.clone(),))?) + .await?; + + if let EthFilterResult::Hashes(hashes) = filter_result { + verify_hashes(&hashes).await?; + anyhow::ensure!(prev_hashes != hashes); + + Ok(()) + } else { + Err(anyhow::anyhow!("expecting blocks")) + } + } else { + Err(anyhow::anyhow!("expecting blocks")) + } + } + + let mut retries = 5; + loop { + // Create the filter + let filter_id = client.call(EthNewBlockFilter::request(())?).await?; + + let result = match process_filter(&client, &filter_id).await { + Ok(()) => Ok(()), + Err(e) if retries != 0 && e.to_string().contains("revert") => { + // Cleanup + let removed = client + .call(EthUninstallFilter::request((filter_id,))?) + .await?; + anyhow::ensure!(removed); + + retries -= 1; + continue; + } + Err(e) => Err(e), + }; + + // Cleanup + let removed = client + .call(EthUninstallFilter::request((filter_id,))?) + .await?; + anyhow::ensure!(removed); + + break result; + } + }) +} + +fn eth_new_pending_transaction_filter(tx: TestTransaction) -> RpcTestScenario { + RpcTestScenario::basic(move |client| { + let tx = tx.clone(); + async move { + let filter_id = client + .call(EthNewPendingTransactionFilter::request(())?) + .await?; + + let filter_result = client + .call(EthGetFilterChanges::request((filter_id.clone(),))?) + .await?; + + let result = if let EthFilterResult::Hashes(prev_hashes) = filter_result { + let encoded = cbor4ii::serde::to_vec( + Vec::with_capacity(tx.payload.len()), + &Value::Bytes(tx.payload.clone()), + ) + .context("failed to encode params")?; + + let message = Message { + to: tx.to, + from: tx.from, + method_num: EVMMethod::InvokeContract as u64, + params: encoded.into(), + ..Default::default() + }; + + let smsg = client + .call(MpoolPushMessage::request((message, None))?) + .await?; + + wait_pending_message(&client, smsg.cid()).await?; + + let filter_result = client + .call(EthGetFilterChanges::request((filter_id.clone(),))?) + .await?; + + if let EthFilterResult::Hashes(hashes) = filter_result { + anyhow::ensure!(prev_hashes != hashes); + + let mut cids = vec![]; + for hash in hashes { + if let Some(cid) = client + .call(EthGetMessageCidByTransactionHash::request((hash,))?) + .await? + { + cids.push(cid); + } + } + + anyhow::ensure!(cids.contains(&smsg.cid())); + + Ok(()) + } else { + Err(anyhow::anyhow!("expecting hashes")) + } + } else { + Err(anyhow::anyhow!("expecting transactions")) + }; + + let removed = client + .call(EthUninstallFilter::request((filter_id,))?) + .await?; + anyhow::ensure!(removed); + + result + } + }) +} + +fn as_logs(input: EthFilterResult) -> EthFilterResult { + match input { + EthFilterResult::Hashes(vec) if vec.is_empty() => EthFilterResult::Logs(Vec::new()), + other => other, + } +} + +fn eth_get_filter_logs(tx: TestTransaction) -> RpcTestScenario { + RpcTestScenario::basic(move |client| { + let tx = tx.clone(); + async move { + const BLOCK_RANGE: u64 = 1; + + let tipset = client.call(ChainHead::request(())?).await?; + + let encoded = cbor4ii::serde::to_vec( + Vec::with_capacity(tx.payload.len()), + &Value::Bytes(tx.payload.clone()), + ) + .context("failed to encode params")?; + + let message = Message { + to: tx.to, + from: tx.from, + method_num: EVMMethod::InvokeContract as u64, + params: encoded.into(), + ..Default::default() + }; + + let smsg = client + .call(MpoolPushMessage::request((message, None))?) + .await?; + + let lookup = client + .call( + StateWaitMsg::request((smsg.cid(), 0, tipset.epoch(), false))? + .with_timeout(Duration::from_secs(300)), + ) + .await?; + + let block_num = EthUint64(lookup.height as u64); + + let topics = EthTopicSpec(vec![EthHashList::Single(Some(tx.topic))]); + + let filter_spec = EthFilterSpec { + from_block: Some(format!("0x{:x}", block_num.0.saturating_sub(BLOCK_RANGE))), + to_block: Some(block_num.to_hex_string()), + topics: Some(topics), + ..Default::default() + }; + + let filter_id = client.call(EthNewFilter::request((filter_spec,))?).await?; + + let filter_result = as_logs( + client + .call(EthGetFilterLogs::request((filter_id.clone(),))?) + .await?, + ); + + let result = if let EthFilterResult::Logs(logs) = filter_result { + anyhow::ensure!(!logs.is_empty()); + Ok(()) + } else { + Err(anyhow::anyhow!("expecting logs")) + }; + + let removed = client + .call(EthUninstallFilter::request((filter_id,))?) + .await?; + anyhow::ensure!(removed); + + result + } + }) +} + +const LOTUS_EVENTS_MAXFILTERS: usize = 100; + +macro_rules! with_methods { + ( $builder:expr, $( $method:ty ),+ ) => {{ + let mut b = $builder; + $( + b = b.using::<{ <$method>::N_REQUIRED_PARAMS }, $method>(); + )+ + b + }}; +} + +pub(super) async fn create_tests(tx: TestTransaction) -> Vec { + vec![ + with_methods!( + create_eth_new_filter_test().name("eth_newFilter install/uninstall"), + EthNewFilter, + EthUninstallFilter + ), + with_methods!( + create_eth_new_filter_limit_test(20).name("eth_newFilter under limit"), + EthNewFilter, + EthUninstallFilter + ), + with_methods!( + create_eth_new_filter_limit_test(LOTUS_EVENTS_MAXFILTERS) + .name("eth_newFilter just under limit"), + EthNewFilter, + EthUninstallFilter + ), + with_methods!( + create_eth_new_filter_limit_test(LOTUS_EVENTS_MAXFILTERS + 1) + .name("eth_newFilter over limit") + .should_fail_with("maximum number of filters registered") + .ignore("https://github.com/ChainSafe/forest/issues/5915"), + EthNewFilter, + EthUninstallFilter + ), + with_methods!( + eth_new_block_filter().name("eth_newBlockFilter works"), + EthNewBlockFilter, + EthGetFilterChanges, + EthUninstallFilter + ), + with_methods!( + eth_new_pending_transaction_filter(tx.clone()) + .name("eth_newPendingTransactionFilter works") + .ignore("https://github.com/ChainSafe/forest/issues/5916"), + EthNewPendingTransactionFilter, + EthGetFilterChanges, + EthUninstallFilter + ), + with_methods!( + eth_get_filter_logs(tx.clone()) + .name("eth_getFilterLogs works") + .ignore("https://github.com/ChainSafe/forest/issues/5917"), + EthNewFilter, + EthGetFilterLogs, + EthUninstallFilter + ), + ] +}