Skip to main content

forest/tool/subcommands/api_cmd/
stateful_tests.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3use crate::eth::EVMMethod;
4use crate::message::SignedMessage;
5use crate::networks::calibnet::ETH_CHAIN_ID;
6use crate::rpc::eth::EthUint64;
7use crate::rpc::eth::pubsub_trait::SubscriptionKind;
8use crate::rpc::eth::types::*;
9use crate::rpc::types::ApiTipsetKey;
10use crate::rpc::{self, RpcMethod, prelude::*};
11use crate::shim::{address::Address, message::Message};
12
13use anyhow::{Context, ensure};
14use cbor4ii::core::Value;
15use cid::Cid;
16use ethereum_types::H256;
17use futures::{SinkExt, StreamExt};
18use serde_json::json;
19use tokio::time::Duration;
20use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
21use tokio_util::sync::CancellationToken;
22
23use std::io::{self, Write};
24use std::pin::Pin;
25use std::sync::Arc;
26
27type TestRunner = Arc<
28    dyn Fn(Arc<rpc::Client>) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>>
29        + Send
30        + Sync,
31>;
32
33#[derive(Clone)]
34pub struct TestTransaction {
35    pub to: Address,
36    pub from: Address,
37    pub payload: Vec<u8>,
38    pub topic: EthHash,
39}
40
41#[derive(Clone)]
42pub struct RpcTestScenario {
43    pub run: TestRunner,
44    pub name: Option<&'static str>,
45    pub should_fail_with: Option<&'static str>,
46    pub used_methods: Vec<&'static str>,
47    pub ignore: Option<&'static str>,
48}
49
50impl RpcTestScenario {
51    /// Create a basic scenario from a simple async closure.
52    pub fn basic<F, Fut>(run_fn: F) -> Self
53    where
54        F: Fn(Arc<rpc::Client>) -> Fut + Send + Sync + 'static,
55        Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
56    {
57        let run = Arc::new(move |client: Arc<rpc::Client>| {
58            Box::pin(run_fn(client)) as Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>>
59        });
60        Self {
61            run,
62            name: Default::default(),
63            should_fail_with: Default::default(),
64            used_methods: Default::default(),
65            ignore: None,
66        }
67    }
68
69    fn name(mut self, name: &'static str) -> Self {
70        self.name = Some(name);
71        self
72    }
73
74    pub fn should_fail_with(mut self, msg: &'static str) -> Self {
75        self.should_fail_with = Some(msg);
76        self
77    }
78
79    fn using<const ARITY: usize, M>(mut self) -> Self
80    where
81        M: RpcMethod<ARITY>,
82    {
83        self.used_methods.push(M::NAME);
84        if let Some(alias) = M::NAME_ALIAS {
85            self.used_methods.push(alias);
86        }
87        self
88    }
89
90    fn _ignore(mut self, msg: &'static str) -> Self {
91        self.ignore = Some(msg);
92        self
93    }
94}
95
96pub(super) async fn run_tests(
97    tests: impl IntoIterator<Item = RpcTestScenario> + Clone,
98    client: impl Into<Arc<rpc::Client>>,
99    filter: String,
100) -> anyhow::Result<()> {
101    let client: Arc<rpc::Client> = client.into();
102    let mut passed = 0;
103    let mut failed = 0;
104    let mut ignored = 0;
105    let mut filtered = 0;
106
107    println!("running {} tests", tests.clone().into_iter().count());
108
109    for (i, test) in tests.into_iter().enumerate() {
110        if !filter.is_empty() && !test.used_methods.iter().any(|m| m.starts_with(&filter)) {
111            filtered += 1;
112            continue;
113        }
114        if test.ignore.is_some() {
115            ignored += 1;
116            println!(
117                "test {} ... ignored",
118                if let Some(name) = test.name {
119                    name.to_string()
120                } else {
121                    format!("#{i}")
122                },
123            );
124            continue;
125        }
126
127        print!(
128            "test {} ... ",
129            if let Some(name) = test.name {
130                name.to_string()
131            } else {
132                format!("#{i}")
133            }
134        );
135
136        io::stdout().flush()?;
137
138        let result = (test.run)(client.clone()).await;
139
140        match result {
141            Ok(_) => {
142                if let Some(expected_msg) = test.should_fail_with {
143                    println!("FAILED (expected failure containing '{expected_msg}')");
144                    failed += 1;
145                } else {
146                    println!("ok");
147                    passed += 1;
148                }
149            }
150            Err(e) => {
151                if let Some(expected_msg) = test.should_fail_with {
152                    let err_str = format!("{e:#}");
153                    if err_str
154                        .to_lowercase()
155                        .contains(&expected_msg.to_lowercase())
156                    {
157                        println!("ok");
158                        passed += 1;
159                    } else {
160                        println!("FAILED ({e:#})");
161                        failed += 1;
162                    }
163                } else {
164                    println!("FAILED {e:#}");
165                    failed += 1;
166                }
167            }
168        }
169    }
170    let status = if failed == 0 { "ok" } else { "FAILED" };
171    println!(
172        "test result: {status}. {passed} passed; {failed} failed; {ignored} ignored; {filtered} filtered out"
173    );
174    ensure!(failed == 0, "{failed} test(s) failed");
175    Ok(())
176}
177
178/// A client-side WebSocket stream to the node's JSON-RPC endpoint.
179type EthSubStream =
180    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
181
182/// Open a WebSocket to the node's JSON-RPC endpoint (`/rpc/v1`).
183/// Returns the live stream.
184async fn connect_ws(client: &rpc::Client) -> anyhow::Result<EthSubStream> {
185    let mut url = client.base_url().clone();
186    let ws_scheme = match url.scheme() {
187        "http" => "ws",
188        "https" => "wss",
189        scheme => anyhow::bail!("unsupported RPC URL scheme: {scheme}"),
190    };
191    url.set_scheme(ws_scheme)
192        .map_err(|_| anyhow::anyhow!("failed to set scheme"))?;
193    url.set_path("/rpc/v1");
194    let (ws_stream, _) = connect_async(url.as_str()).await?;
195    Ok(ws_stream)
196}
197
198async fn wait_next_epoch(client: &rpc::Client) -> anyhow::Result<()> {
199    let base = client.call(ChainHead::request(())?).await?.epoch();
200    tokio::time::timeout(Duration::from_secs(180), async {
201        loop {
202            tokio::time::sleep(Duration::from_millis(100)).await;
203            if client.call(ChainHead::request(())?).await?.epoch() > base {
204                break Ok(());
205            }
206        }
207    })
208    .await
209    .context("timeout waiting for the next epoch")?
210}
211
212/// Poll `MpoolPending` until `message_cid` is visible. Returns while the message
213/// is still pending; it does not wait for on-chain inclusion.
214async fn wait_in_mempool(client: &rpc::Client, message_cid: Cid) -> anyhow::Result<()> {
215    let mut retries = 100;
216    loop {
217        let pending = client
218            .call(MpoolPending::request((ApiTipsetKey(None),))?)
219            .await?;
220        if pending.0.iter().any(|msg| msg.cid() == message_cid) {
221            break Ok(());
222        }
223        ensure!(retries != 0, "Message not found in mpool");
224        retries -= 1;
225        tokio::time::sleep(Duration::from_millis(10)).await;
226    }
227}
228
229/// Wait for `message_cid` to appear in the mempool and then be included on chain.
230async fn wait_pending_message(client: &rpc::Client, message_cid: Cid) -> anyhow::Result<()> {
231    let tipset = client.call(ChainHead::request(())?).await?;
232    wait_in_mempool(client, message_cid).await?;
233    client
234        .call(
235            StateWaitMsg::request((message_cid, 1, tipset.epoch(), true))?
236                .with_timeout(Duration::from_secs(300)),
237        )
238        .await?;
239    Ok(())
240}
241
242/// Poll `eth_getFilterChanges` until the hashes seen so far contain `want`.
243///
244/// The filter collects pending `txs` on a background task, so a poll right after a
245/// tx appears in the mempool may run before the collector has processed it.
246/// Each poll consumes the filter's buffer, so hashes are accumulated across
247/// polls and the full set seen is returned.
248async fn poll_pending_filter_until(
249    client: &rpc::Client,
250    filter_id: &FilterID,
251    want: &EthHash,
252) -> anyhow::Result<Vec<EthHash>> {
253    let mut seen: Vec<EthHash> = Vec::new();
254    let mut retries = 100;
255    loop {
256        let result = client
257            .call(EthGetFilterChanges::request((filter_id.clone(),))?)
258            .await?;
259        let EthFilterResult::Hashes(hashes) = result else {
260            anyhow::bail!("expected hashes, got {result:?}");
261        };
262        seen.extend(hashes);
263        if seen.contains(want) {
264            break Ok(seen);
265        }
266        ensure!(
267            retries != 0,
268            "filter did not return {want:?} in time; saw {seen:?}"
269        );
270        retries -= 1;
271        tokio::time::sleep(Duration::from_millis(10)).await;
272    }
273}
274
275async fn invoke_contract(client: &rpc::Client, tx: &TestTransaction) -> anyhow::Result<Cid> {
276    let encoded_params = cbor4ii::serde::to_vec(
277        Vec::with_capacity(tx.payload.len()),
278        &Value::Bytes(tx.payload.clone()),
279    )
280    .context("failed to encode params")?;
281    let nonce = client.call(MpoolGetNonce::request((tx.from,))?).await?;
282    let message = Message {
283        to: tx.to,
284        from: tx.from,
285        sequence: nonce,
286        method_num: EVMMethod::InvokeContract as u64,
287        params: encoded_params.into(),
288        ..Default::default()
289    };
290    let unsigned_msg = client
291        .call(GasEstimateMessageGas::request((
292            message,
293            None,
294            ApiTipsetKey(None),
295        ))?)
296        .await?;
297
298    let eth_tx_args = crate::eth::EthEip1559TxArgsBuilder::default()
299        .chain_id(ETH_CHAIN_ID)
300        .unsigned_message(&unsigned_msg)?
301        .build()
302        .map_err(|e| anyhow::anyhow!("Failed to build EIP-1559 transaction: {}", e))?;
303    let eth_tx = crate::eth::EthTx::from(eth_tx_args);
304    let data = eth_tx.rlp_unsigned_message(ETH_CHAIN_ID)?;
305
306    let sig = client.call(WalletSign::request((tx.from, data))?).await?;
307    let smsg = SignedMessage::new_unchecked(unsigned_msg, sig);
308    let cid = smsg.cid();
309
310    client.call(MpoolPush::request((smsg,))?).await?;
311
312    Ok(cid)
313}
314
315/// Open a WebSocket and start an `eth_subscribe` subscription of the given
316/// `kind`, with optional `filter` params (used by `logs`). Returns the live
317/// stream and the assigned subscription id.
318async fn open_eth_subscription(
319    client: &rpc::Client,
320    kind: SubscriptionKind,
321    filter: Option<serde_json::Value>,
322) -> anyhow::Result<(EthSubStream, serde_json::Value)> {
323    let mut ws_stream = connect_ws(client).await?;
324
325    let mut params = vec![serde_json::to_value(kind)?];
326    params.extend(filter);
327    let request = json!({
328        "jsonrpc": "2.0",
329        "id": 1,
330        "method": "eth_subscribe",
331        "params": params,
332    });
333    ws_stream
334        .send(WsMessage::Text(request.to_string().into()))
335        .await
336        .context("failed to send eth_subscribe request")?;
337
338    // The acknowledgement carries the subscription id in `result`.
339    let subscription_id = loop {
340        let msg = match tokio::time::timeout(Duration::from_secs(30), ws_stream.next()).await {
341            Ok(Some(msg)) => msg,
342            Ok(None) => anyhow::bail!("WebSocket stream closed before eth_subscribe ack"),
343            Err(_) => anyhow::bail!("timeout waiting for eth_subscribe ack"),
344        };
345        match msg {
346            Ok(WsMessage::Text(text)) => {
347                let json: serde_json::Value = serde_json::from_str(&text)?;
348                if let Some(error) = json.get("error") {
349                    anyhow::bail!("eth_subscribe failed: {error}");
350                }
351                if let Some(result) = json.get("result") {
352                    break result.clone();
353                }
354            }
355            Err(..) | Ok(WsMessage::Close(..)) => {
356                anyhow::bail!("WebSocket closed before eth_subscribe ack")
357            }
358            _ => {}
359        }
360    };
361
362    Ok((ws_stream, subscription_id))
363}
364
365/// Wait for the next `eth_subscription` notification on `subscription_id` and
366/// return its `result` payload.
367async fn next_subscription_payload(
368    ws_stream: &mut EthSubStream,
369    subscription_id: &serde_json::Value,
370    timeout: Duration,
371) -> anyhow::Result<serde_json::Value> {
372    loop {
373        let msg = match tokio::time::timeout(timeout, ws_stream.next()).await {
374            Ok(Some(msg)) => msg,
375            Ok(None) => anyhow::bail!("WebSocket stream closed"),
376            Err(_) => anyhow::bail!("timeout waiting for subscription notification"),
377        };
378        match msg {
379            Ok(WsMessage::Text(text)) => {
380                let json: serde_json::Value = serde_json::from_str(&text)?;
381                if json.get("method").and_then(|m| m.as_str()) != Some("eth_subscription") {
382                    continue;
383                }
384                let params = json
385                    .get("params")
386                    .context("subscription notification missing params")?;
387                anyhow::ensure!(
388                    params.get("subscription") == Some(subscription_id),
389                    "subscription id mismatch in notification"
390                );
391                return params
392                    .get("result")
393                    .cloned()
394                    .context("subscription notification missing result");
395            }
396            Err(..) | Ok(WsMessage::Close(..)) => anyhow::bail!("WebSocket closed unexpectedly"),
397            _ => {}
398        }
399    }
400}
401
402/// Cancel the subscription and close the WebSocket. Best-effort: the node also
403/// drops the subscription when the socket closes.
404async fn close_eth_subscription(
405    ws_stream: &mut EthSubStream,
406    subscription_id: &serde_json::Value,
407) -> anyhow::Result<()> {
408    let request = json!({
409        "jsonrpc": "2.0",
410        "id": 2,
411        "method": "eth_unsubscribe",
412        "params": [subscription_id],
413    });
414    ws_stream
415        .send(WsMessage::Text(request.to_string().into()))
416        .await
417        .context("failed to send eth_unsubscribe request")?;
418    ws_stream.close(None).await?;
419    Ok(())
420}
421
422fn eth_subscribe_new_heads() -> RpcTestScenario {
423    RpcTestScenario::basic(|client| async move {
424        // Remember the chain head before subscribing so we can prove the head we
425        // receive is real, advancing — not stale or random data.
426        let start = client.call(EthBlockNumber::request(())?).await?;
427
428        let (mut ws_stream, subscription_id) =
429            open_eth_subscription(&client, SubscriptionKind::NewHeads, None).await?;
430
431        // A new head is published once per tipset (~30s on calibnet).
432        let payload =
433            next_subscription_payload(&mut ws_stream, &subscription_id, Duration::from_secs(180))
434                .await;
435
436        let _ = close_eth_subscription(&mut ws_stream, &subscription_id).await;
437        let payload = payload?;
438
439        // `newHeads` must yield a block-header object
440        anyhow::ensure!(
441            payload.is_object(),
442            "newHeads must yield a block-header object, got: {payload}"
443        );
444        let header: ApiHeaders = serde_json::from_value(payload)
445            .context("newHeads payload is not a valid Eth block header")?;
446
447        // Identity: the header's number must be at or beyond the head seen at
448        // subscription time, proving it's a genuine fresh head.
449        anyhow::ensure!(
450            header.0.number.0 >= start.0 as i64,
451            "newHeads number {} precedes the head {} seen at subscription time",
452            header.0.number.0,
453            start.0
454        );
455        Ok(())
456    })
457}
458
459fn eth_subscribe_pending_transactions(tx: TestTransaction) -> RpcTestScenario {
460    RpcTestScenario::basic(move |client| {
461        let tx = tx.clone();
462        async move {
463            let (mut ws_stream, subscription_id) =
464                open_eth_subscription(&client, SubscriptionKind::PendingTransactions, None).await?;
465
466            // The subscription is active, so the pending tx we push now is observable.
467            let cid = invoke_contract(&client, &tx).await?;
468            let tx_hash = client
469                .call(EthGetTransactionHashByCid::request((cid,))?)
470                .await?
471                .context("no Eth transaction hash for CID")?;
472
473            // Watch pending-tx hashes until `our` transaction shows up.
474            let watch = async {
475                loop {
476                    let payload = next_subscription_payload(
477                        &mut ws_stream,
478                        &subscription_id,
479                        Duration::from_secs(120),
480                    )
481                    .await?;
482                    // a pending tx is a single hash string.
483                    anyhow::ensure!(
484                        payload.is_string(),
485                        "pendingTransactions must yield a tx-hash string, got: {payload}"
486                    );
487                    let hash: EthHash = serde_json::from_value(payload)
488                        .context("pendingTransactions payload is not an Eth hash")?;
489                    // received hash must be `our` transaction hash.
490                    if hash.eq(&tx_hash) {
491                        break;
492                    }
493                }
494                anyhow::Ok(())
495            };
496            let outcome = tokio::time::timeout(Duration::from_secs(120), watch)
497                .await
498                .unwrap_or_else(|_| {
499                    Err(anyhow::anyhow!(
500                        "timed out waiting for our pendingTransactions notification"
501                    ))
502                });
503
504            let _ = close_eth_subscription(&mut ws_stream, &subscription_id).await;
505            outcome
506        }
507    })
508}
509
510/// Minimal typed view of an `EthLog` for the fields the log test asserts on.
511#[derive(serde::Deserialize)]
512#[serde(rename_all = "camelCase")]
513struct LogView {
514    topics: Vec<EthHash>,
515    transaction_hash: EthHash,
516}
517
518/// One opened logs subscription paired with the events it is expected to deliver.
519struct CaseSub {
520    label: &'static str,
521    ws: EthSubStream,
522    sub_id: serde_json::Value,
523    /// Event signatures (each log's `topic[0]`) this filter should deliver for our mint.
524    expected: Vec<EthHash>,
525}
526
527/// Read notifications until one for our mint tx (`our_tx`) arrives; unrelated logs are
528/// skipped. Returns `None` if the `timeout` window elapses (or the socket closes) first.
529async fn next_our_log(
530    ws: &mut EthSubStream,
531    sub_id: &serde_json::Value,
532    our_tx: &EthHash,
533    timeout: Duration,
534) -> anyhow::Result<Option<LogView>> {
535    while let Ok(payload) = next_subscription_payload(ws, sub_id, timeout).await {
536        ensure!(
537            payload.is_object(),
538            "logs must yield a single log object, got: {payload}"
539        );
540        let log: LogView =
541            serde_json::from_value(payload).context("logs payload is not an Eth log")?;
542        if &log.transaction_hash == our_tx {
543            return Ok(Some(log));
544        }
545    }
546    Ok(None)
547}
548
549/// Backstop for a single subscription read. The coordinator's cancellation normally ends a
550/// drain first; this only bounds a read if the mint never executes.
551const LOGS_DELIVERY_TIMEOUT: Duration = Duration::from_secs(100);
552
553/// Drain one case subscription until the coordinator signals `stop` (a settle window after the mint's receipt
554/// lands), then assert the set of event signatures it delivered for our mint equals
555/// `expected`. An empty `expected` asserts the filter delivered nothing.
556async fn verify_case(
557    label: &str,
558    ws: &mut EthSubStream,
559    sub_id: &serde_json::Value,
560    our_tx: &EthHash,
561    expected: &[EthHash],
562    stop: &CancellationToken,
563) -> anyhow::Result<()> {
564    let mut got = Vec::new();
565    loop {
566        tokio::select! {
567            _ = stop.cancelled() => break,
568            log = next_our_log(ws, sub_id, our_tx, LOGS_DELIVERY_TIMEOUT) => {
569                if let Some(log) = log? {
570                    got.push(*log.topics.first().context("log is missing topic[0]")?);
571                }
572            }
573        }
574    }
575    // Reorgs re-deliver the same event (apply -> revert -> re-apply); assert the *set* of
576    // event signatures delivered, not the count.
577    got.sort();
578    got.dedup();
579    let mut want = expected.to_vec();
580    want.sort();
581    ensure!(got == want, "{label}: expected {want:?}, got {got:?}");
582    Ok(())
583}
584
585fn eth_subscribe_logs(tx: TestTransaction) -> RpcTestScenario {
586    RpcTestScenario::basic(move |client| {
587        // Once the txn lands, how long to let the log feed push to every subscription before we stop draining.
588        const SETTLE: Duration = Duration::from_secs(10);
589
590        let tx = tx.clone();
591        async move {
592            // A valid address that is not the contract.
593            const WRONG_ADDRESS: &str = "0x000000000000000000000000000000000000dead";
594            // The suite already passes Mint's signature as `topic`, so reuse it.
595            // Transfer's signature isn't passed in derive it from the event string.
596            let mint_topic = tx.topic;
597            let transfer = EthHash(H256::from(crate::utils::encoding::keccak_256(
598                b"Transfer(address,address,uint256)",
599            )));
600            let contract = EthAddress::from_filecoin_address(&tx.to)?;
601
602            // The values we filter on come straight from the `mint(address,uint256)`
603            // calldata (`--payload`): a 4-byte selector, then the 32-byte `to` and `amount`
604            // words.
605            let v_to = EthHash(H256::from_slice(
606                tx.payload
607                    .get(4..36)
608                    .context("mint calldata missing `to`")?,
609            ));
610            let v_amount = EthHash(H256::from_slice(
611                tx.payload
612                    .get(36..68)
613                    .context("mint calldata missing `amount`")?,
614            ));
615            let v_zero = EthHash::default(); // address(0)
616
617            // Contract address as a `0x..` string, for embedding in filters.
618            let c = format!("{:#x}", contract.0);
619
620            // Every topic case scopes to the contract address; only `topics` varies.
621            let by_topics =
622                |topics: serde_json::Value| json!({ "address": [c.as_str()], "topics": topics });
623
624            // Expected event sets, named for the assertion site.
625            let both_topics = vec![mint_topic, transfer];
626            let only_mint_topic = vec![mint_topic];
627            let only_transfer_topic = vec![transfer];
628            let none: Vec<EthHash> = vec![];
629
630            // (label, filter, expected events), grouped by the dimension it exercises.
631            let specs: Vec<(&'static str, serde_json::Value, Vec<EthHash>)> = vec![
632                // --- address ---
633                (
634                    "address empty list (wildcard)",
635                    json!({ "address": [], "topics": null }),
636                    both_topics.clone(),
637                ),
638                (
639                    "address [contract, other]",
640                    json!({ "address": [c.as_str(), WRONG_ADDRESS], "topics": null }),
641                    both_topics.clone(),
642                ),
643                (
644                    "address non-matching",
645                    json!({ "address": [WRONG_ADDRESS], "topics": null }),
646                    none.clone(),
647                ),
648                // --- topic[0] = event signature ---
649                (
650                    "topic0 = Mint",
651                    by_topics(json!([mint_topic.to_string()])),
652                    only_mint_topic.clone(),
653                ),
654                (
655                    "topic0 = Transfer",
656                    by_topics(json!([transfer.to_string()])),
657                    only_transfer_topic.clone(),
658                ),
659                (
660                    "topic0 OR [Mint, Transfer]",
661                    by_topics(json!([[mint_topic.to_string(), transfer.to_string()]])),
662                    both_topics.clone(),
663                ),
664                (
665                    "topic0 empty-list wildcard",
666                    by_topics(json!([[]])),
667                    both_topics.clone(),
668                ),
669                (
670                    "topic0 null wildcard",
671                    by_topics(json!([null])),
672                    both_topics.clone(),
673                ),
674                // --- trailing wildcard / positions past the log's topics ---
675                (
676                    "Mint + trailing null",
677                    by_topics(json!([mint_topic.to_string(), null])),
678                    only_mint_topic.clone(),
679                ),
680                (
681                    "Mint + null past topics",
682                    by_topics(json!([mint_topic.to_string(), v_to.to_string(), null])),
683                    only_mint_topic.clone(),
684                ),
685                (
686                    "Mint + value past topics (no match)",
687                    by_topics(json!([
688                        mint_topic.to_string(),
689                        v_to.to_string(),
690                        v_to.to_string()
691                    ])),
692                    none.clone(),
693                ),
694                // --- indexed values: AND across positions, OR within, positional ---
695                (
696                    "topic1 = to (only Mint)",
697                    by_topics(json!([null, v_to.to_string()])),
698                    only_mint_topic.clone(),
699                ),
700                (
701                    "topic1 = 0x0 (only Transfer)",
702                    by_topics(json!([null, v_zero.to_string()])),
703                    only_transfer_topic.clone(),
704                ),
705                (
706                    "topic2 = to (only Transfer)",
707                    by_topics(json!([null, null, v_to.to_string()])),
708                    only_transfer_topic.clone(),
709                ),
710                (
711                    "Transfer AND from=0 AND to",
712                    by_topics(json!([
713                        transfer.to_string(),
714                        v_zero.to_string(),
715                        v_to.to_string()
716                    ])),
717                    only_transfer_topic.clone(),
718                ),
719                (
720                    "Transfer AND topic1=to (mismatch)",
721                    by_topics(json!([transfer.to_string(), v_to.to_string()])),
722                    none.clone(),
723                ),
724                (
725                    "(Mint|Transfer) AND topic1=to",
726                    by_topics(json!([
727                        [mint_topic.to_string(), transfer.to_string()],
728                        v_to.to_string()
729                    ])),
730                    only_mint_topic.clone(),
731                ),
732                // --- data is never matched as a topic ---
733                (
734                    "topic1 = amount (in data, no match)",
735                    by_topics(json!([null, v_amount.to_string()])),
736                    none.clone(),
737                ),
738            ];
739            let mut subs = Vec::with_capacity(specs.len());
740            for (label, filter, expected) in specs {
741                let (ws, sub_id) =
742                    open_eth_subscription(&client, SubscriptionKind::Logs, Some(filter)).await?;
743                subs.push(CaseSub {
744                    label,
745                    ws,
746                    sub_id,
747                    expected,
748                });
749            }
750
751            // Emit the operator's `mint` tx; it produces the Mint + Transfer logs.
752            let cid = invoke_contract(&client, &tx).await?;
753            let tx_hash = client
754                .call(EthGetTransactionHashByCid::request((cid,))?)
755                .await?
756                .context("no Eth transaction hash for CID")?;
757            // Drain every subscription concurrently so none sits idle while we wait.
758            // All subs receive the same logs at once, so the coordinator just waits for the mint to execute,
759            // and then we wait for some `SETTLE` time and then stop as soon as the logs are delivered.
760            let stop = CancellationToken::new();
761            // Make sure token is cancelled on error path
762            let _cancellation_token_drop_guard = stop.drop_guard_ref();
763            let coordinator = async {
764                let executed = wait_pending_message(&client, cid).await;
765                tokio::time::sleep(SETTLE).await;
766                stop.cancel();
767                executed
768            };
769            let drains =
770                futures::future::join_all(subs.iter_mut().map(|c| {
771                    verify_case(c.label, &mut c.ws, &c.sub_id, &tx_hash, &c.expected, &stop)
772                }));
773            let (executed, case_results) = tokio::join!(coordinator, drains);
774            let outcome = executed.and_then(|()| {
775                for result in case_results {
776                    result?;
777                }
778                anyhow::Ok(())
779            });
780
781            for sub in &mut subs {
782                let _ = close_eth_subscription(&mut sub.ws, &sub.sub_id).await;
783            }
784            outcome
785        }
786    })
787}
788
789fn create_eth_new_filter_test() -> RpcTestScenario {
790    RpcTestScenario::basic(|client| async move {
791        const BLOCK_RANGE: u64 = 200;
792
793        let last_block = client.call(EthBlockNumber::request(())?).await?;
794
795        let filter_spec = EthFilterSpec {
796            from_block: Some(EthUint64(last_block.0.saturating_sub(BLOCK_RANGE)).to_hex_string()),
797            to_block: Some(last_block.to_hex_string()),
798            ..Default::default()
799        };
800
801        let filter_id = client.call(EthNewFilter::request((filter_spec,))?).await?;
802
803        let removed = client
804            .call(EthUninstallFilter::request((filter_id.clone(),))?)
805            .await?;
806        anyhow::ensure!(removed);
807
808        let removed = client
809            .call(EthUninstallFilter::request((filter_id,))?)
810            .await?;
811        anyhow::ensure!(!removed);
812
813        Ok(())
814    })
815}
816
817fn create_eth_new_filter_limit_test(count: usize) -> RpcTestScenario {
818    RpcTestScenario::basic(move |client| async move {
819        const BLOCK_RANGE: u64 = 200;
820
821        let last_block = client.call(EthBlockNumber::request(())?).await?;
822
823        let filter_spec = EthFilterSpec {
824            from_block: Some(format!("0x{:x}", last_block.0.saturating_sub(BLOCK_RANGE))),
825            to_block: Some(last_block.to_hex_string()),
826            ..Default::default()
827        };
828
829        let mut ids = vec![];
830
831        for _ in 0..count {
832            let result = client
833                .call(EthNewFilter::request((filter_spec.clone(),))?)
834                .await;
835
836            match result {
837                Ok(filter_id) => ids.push(filter_id),
838                Err(e) => {
839                    // Cleanup any filters created so far to leave a clean state
840                    for id in ids {
841                        let removed = client.call(EthUninstallFilter::request((id,))?).await?;
842                        anyhow::ensure!(removed);
843                    }
844                    anyhow::bail!(e)
845                }
846            }
847        }
848
849        for id in ids {
850            let removed = client.call(EthUninstallFilter::request((id,))?).await?;
851            anyhow::ensure!(removed);
852        }
853
854        Ok(())
855    })
856}
857
858fn eth_new_block_filter() -> RpcTestScenario {
859    RpcTestScenario::basic(move |client| async move {
860        async fn process_filter(client: &rpc::Client, filter_id: &FilterID) -> anyhow::Result<()> {
861            let poll = async || -> anyhow::Result<Vec<EthHash>> {
862                match client
863                    .call(EthGetFilterChanges::request((filter_id.clone(),))?)
864                    .await?
865                {
866                    EthFilterResult::Hashes(hashes) => Ok(hashes),
867                    _ => Err(anyhow::anyhow!("expecting block hashes")),
868                }
869            };
870            let verify_hashes = async |hashes: &[EthHash]| -> anyhow::Result<()> {
871                for hash in hashes {
872                    let _block = client
873                        .call(EthGetBlockByHash::request((*hash, false))?)
874                        .await?;
875                }
876                Ok(())
877            };
878
879            let prev_hashes = poll().await?;
880            verify_hashes(&prev_hashes).await?;
881
882            // The filter derives its hashes from executed events, so an epoch
883            // without events legitimately leaves the poll unchanged, allow a
884            // few more epochs before declaring the filter stuck.
885            let mut hashes = Vec::new();
886            for _ in 0..3 {
887                wait_next_epoch(client).await?;
888                hashes = poll().await?;
889                verify_hashes(&hashes).await?;
890                if hashes != prev_hashes {
891                    break;
892                }
893            }
894            anyhow::ensure!((prev_hashes.is_empty() && hashes.is_empty()) || prev_hashes != hashes);
895
896            Ok(())
897        }
898
899        // Create the filter
900        let filter_id = client.call(EthNewBlockFilter::request(())?).await?;
901
902        let result = process_filter(&client, &filter_id).await;
903
904        // Cleanup
905        let cleanup: anyhow::Result<()> = async {
906            let removed = client
907                .call(EthUninstallFilter::request((filter_id,))?)
908                .await
909                .context("failed to uninstall filter")?;
910            anyhow::ensure!(removed, "filter was not removed");
911            Ok(())
912        }
913        .await;
914
915        // A cleanup failure must not mask the original test failure.
916        result.and(cleanup)
917    })
918}
919
920fn eth_new_pending_transaction_filter(tx: TestTransaction) -> RpcTestScenario {
921    RpcTestScenario::basic(move |client| {
922        let tx = tx.clone();
923        async move {
924            let filter_id = client
925                .call(EthNewPendingTransactionFilter::request(())?)
926                .await?;
927
928            let filter_result = client
929                .call(EthGetFilterChanges::request((filter_id.clone(),))?)
930                .await?;
931
932            let result = if let EthFilterResult::Hashes(prev_hashes) = filter_result {
933                let cid = invoke_contract(&client, &tx).await?;
934                let tx_hash = client
935                    .call(EthGetTransactionHashByCid::request((cid,))?)
936                    .await?
937                    .context("no Eth transaction hash for CID")?;
938
939                // Observe the mempool state before the message is mined. The
940                // filter collects asynchronously, so poll until tx_hash appears.
941                wait_in_mempool(&client, cid).await?;
942                let hashes = poll_pending_filter_until(&client, &filter_id, &tx_hash).await?;
943
944                anyhow::ensure!(
945                    prev_hashes != hashes,
946                    "prev_hashes={prev_hashes:?} hashes={hashes:?}"
947                );
948                anyhow::ensure!(
949                    hashes.contains(&tx_hash),
950                    "transaction hash missing from filter results: tx_hash={tx_hash:?} cid={cid:?} hashes={hashes:?}"
951                );
952                Ok(())
953            } else {
954                Err(anyhow::anyhow!("expecting transactions"))
955            };
956
957            let removed = client
958                .call(EthUninstallFilter::request((filter_id,))?)
959                .await?;
960            anyhow::ensure!(removed);
961
962            result
963        }
964    })
965}
966
967/// Verify that successive `eth_getFilterChanges` polls return only the
968/// pending transactions added since the previous poll.
969///
970/// 1. Install a pending-tx filter.
971/// 2. Drain any baseline state with an initial poll.
972/// 3. Submit tx A, wait for it in the mempool, poll — assert hash A present.
973/// 4. Submit tx B, wait for it in the mempool, poll — assert hash B present
974///    and hash A absent (it was already consumed by the previous poll).
975fn eth_new_pending_transaction_filter_multi_poll(tx: TestTransaction) -> RpcTestScenario {
976    RpcTestScenario::basic(move |client| {
977        let tx = tx.clone();
978        async move {
979            let filter_id = client
980                .call(EthNewPendingTransactionFilter::request(())?)
981                .await?;
982
983            let result = async {
984                // Baseline: clear any pre-existing pending state.
985                let _ = client
986                    .call(EthGetFilterChanges::request((filter_id.clone(),))?)
987                    .await?;
988
989                // First tx — poll until it shows up (collection is async).
990                let cid_a = invoke_contract(&client, &tx).await?;
991                let hash_a = client
992                    .call(EthGetTransactionHashByCid::request((cid_a,))?)
993                    .await?
994                    .context("no Eth transaction hash for cid_a")?;
995                wait_in_mempool(&client, cid_a).await?;
996                poll_pending_filter_until(&client, &filter_id, &hash_a).await?;
997
998                // Second tx — the next polls return it but not the
999                // already-consumed tx_a.
1000                let cid_b = invoke_contract(&client, &tx).await?;
1001                let hash_b = client
1002                    .call(EthGetTransactionHashByCid::request((cid_b,))?)
1003                    .await?
1004                    .context("no Eth transaction hash for cid_b")?;
1005                wait_in_mempool(&client, cid_b).await?;
1006                let hashes_b = poll_pending_filter_until(&client, &filter_id, &hash_b).await?;
1007                anyhow::ensure!(
1008                    !hashes_b.contains(&hash_a),
1009                    "second poll should not return previously-consumed tx_a: \
1010                     hash_a={hash_a:?} hashes={hashes_b:?}"
1011                );
1012
1013                anyhow::Ok(())
1014            }
1015            .await;
1016
1017            let removed = client
1018                .call(EthUninstallFilter::request((filter_id,))?)
1019                .await?;
1020            anyhow::ensure!(removed);
1021
1022            result
1023        }
1024    })
1025}
1026
1027fn as_logs(input: EthFilterResult) -> EthFilterResult {
1028    match input {
1029        EthFilterResult::Hashes(vec) if vec.is_empty() => EthFilterResult::Logs(Vec::new()),
1030        other => other,
1031    }
1032}
1033
1034fn eth_get_filter_logs(tx: TestTransaction) -> RpcTestScenario {
1035    RpcTestScenario::basic(move |client| {
1036        let tx = tx.clone();
1037        async move {
1038            const BLOCK_RANGE: u64 = 1;
1039
1040            let tipset = client.call(ChainHead::request(())?).await?;
1041            let cid = invoke_contract(&client, &tx).await?;
1042            let lookup = client
1043                .call(
1044                    StateWaitMsg::request((cid, 1, tipset.epoch(), true))?
1045                        .with_timeout(Duration::from_secs(300)),
1046                )
1047                .await?;
1048            let block_num = EthUint64(lookup.height as u64);
1049
1050            let topics = EthTopicSpec(vec![EthHashList::Single(Some(tx.topic))]);
1051            let filter_spec = EthFilterSpec {
1052                from_block: Some(format!("0x{:x}", block_num.0.saturating_sub(BLOCK_RANGE))),
1053                topics: Some(topics),
1054                ..Default::default()
1055            };
1056
1057            let filter_id = client
1058                .call(EthNewFilter::request((filter_spec.clone(),))?)
1059                .await?;
1060            let filter_result = as_logs(
1061                client
1062                    .call(EthGetFilterLogs::request((filter_id.clone(),))?)
1063                    .await?,
1064            );
1065            let result = if let EthFilterResult::Logs(logs) = filter_result {
1066                anyhow::ensure!(
1067                    !logs.is_empty(),
1068                    "Empty logs: filter_spec={filter_spec:?} cid={cid:?}",
1069                );
1070                Ok(())
1071            } else {
1072                Err(anyhow::anyhow!("expecting logs"))
1073            };
1074
1075            let removed = client
1076                .call(EthUninstallFilter::request((filter_id,))?)
1077                .await?;
1078            anyhow::ensure!(removed);
1079
1080            result
1081        }
1082    })
1083}
1084
1085const LOTUS_EVENTS_MAXFILTERS: usize = 100;
1086
1087macro_rules! with_methods {
1088    ( $builder:expr, $( $method:ty ),+ ) => {{
1089        let mut b = $builder;
1090        $(
1091            b = b.using::<{ <$method>::N_REQUIRED_PARAMS }, $method>();
1092        )+
1093        b
1094    }};
1095}
1096
1097pub(super) async fn create_tests(tx: TestTransaction) -> Vec<RpcTestScenario> {
1098    vec![
1099        with_methods!(
1100            create_eth_new_filter_test().name("eth_newFilter install/uninstall"),
1101            EthNewFilter,
1102            EthUninstallFilter
1103        ),
1104        with_methods!(
1105            create_eth_new_filter_limit_test(20).name("eth_newFilter under limit"),
1106            EthNewFilter,
1107            EthUninstallFilter
1108        ),
1109        with_methods!(
1110            create_eth_new_filter_limit_test(LOTUS_EVENTS_MAXFILTERS)
1111                .name("eth_newFilter just under limit"),
1112            EthNewFilter,
1113            EthUninstallFilter
1114        ),
1115        with_methods!(
1116            create_eth_new_filter_limit_test(LOTUS_EVENTS_MAXFILTERS + 1)
1117                .name("eth_newFilter over limit")
1118                .should_fail_with("maximum number of filters registered"),
1119            EthNewFilter,
1120            EthUninstallFilter
1121        ),
1122        with_methods!(
1123            eth_new_block_filter().name("eth_newBlockFilter works"),
1124            EthNewBlockFilter,
1125            EthGetFilterChanges,
1126            EthUninstallFilter
1127        ),
1128        with_methods!(
1129            eth_new_pending_transaction_filter(tx.clone())
1130                .name("eth_newPendingTransactionFilter works"),
1131            EthNewPendingTransactionFilter,
1132            EthGetFilterChanges,
1133            EthGetTransactionHashByCid,
1134            EthUninstallFilter
1135        ),
1136        with_methods!(
1137            eth_new_pending_transaction_filter_multi_poll(tx.clone())
1138                .name("eth_getFilterChanges returns only new pending txs per poll"),
1139            EthNewPendingTransactionFilter,
1140            EthGetFilterChanges,
1141            EthGetTransactionHashByCid,
1142            EthUninstallFilter
1143        ),
1144        with_methods!(
1145            eth_get_filter_logs(tx.clone()).name("eth_getFilterLogs works"),
1146            EthNewFilter,
1147            EthGetFilterLogs,
1148            EthUninstallFilter
1149        ),
1150        with_methods!(
1151            eth_subscribe_new_heads().name("eth_subscribe newHeads works"),
1152            EthSubscribe,
1153            EthUnsubscribe
1154        ),
1155        with_methods!(
1156            eth_subscribe_pending_transactions(tx.clone())
1157                .name("eth_subscribe pendingTransactions works"),
1158            EthSubscribe,
1159            EthUnsubscribe,
1160            EthGetTransactionHashByCid
1161        ),
1162        with_methods!(
1163            eth_subscribe_logs(tx.clone()).name("eth_subscribe logs filter matrix"),
1164            EthSubscribe,
1165            EthUnsubscribe,
1166            EthGetTransactionHashByCid
1167        ),
1168    ]
1169}