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
198#[allow(unreachable_code)]
199async fn next_tipset(client: &rpc::Client) -> anyhow::Result<()> {
200    async fn close_channel(
201        stream: &mut EthSubStream,
202        id: &serde_json::Value,
203    ) -> anyhow::Result<()> {
204        let request = json!({
205            "jsonrpc": "2.0",
206            "id": 1,
207            "method": "xrpc.cancel",
208            "params": [id]
209        });
210
211        stream
212            .send(WsMessage::Text(request.to_string().into()))
213            .await
214            .context("failed to send close channel request")?;
215
216        Ok(())
217    }
218
219    let mut ws_stream = connect_ws(client).await?;
220
221    let request = json!({
222        "jsonrpc": "2.0",
223        "id": 1,
224        "method": "Filecoin.ChainNotify",
225        "params": []
226    });
227    ws_stream
228        .send(WsMessage::Text(request.to_string().into()))
229        .await?;
230
231    let mut channel_id: Option<serde_json::Value> = None;
232
233    // The goal of this loop is to wait for a new tipset to arrive without using a busy loop or sleep.
234    // It processes incoming WebSocket messages until it encounters an "apply" or "revert" change type.
235    // If an "apply" change is found, it closes the channel and exits. If a "revert" change is found,
236    // it closes the channel and raises an error. Any channel protocol or parameter validation issues result in an error.
237    loop {
238        let msg = match tokio::time::timeout(Duration::from_secs(180), ws_stream.next()).await {
239            Ok(Some(msg)) => msg,
240            Ok(None) => anyhow::bail!("WebSocket stream closed"),
241            Err(_) => {
242                if let Some(id) = channel_id.as_ref() {
243                    let _ = close_channel(&mut ws_stream, id).await;
244                }
245                let _ = ws_stream.close(None).await;
246                anyhow::bail!("timeout waiting for tipset");
247            }
248        };
249        match msg {
250            Ok(WsMessage::Text(text)) => {
251                let json: serde_json::Value = serde_json::from_str(&text)?;
252
253                if let Some(id) = json.get("result") {
254                    channel_id = Some(id.clone());
255                } else {
256                    let method = json!("xrpc.ch.val");
257                    anyhow::ensure!(json.get("method") == Some(&method));
258
259                    if let Some(params) = json.get("params").and_then(|v| v.as_array()) {
260                        if let Some(id) = params.first() {
261                            anyhow::ensure!(Some(id) == channel_id.as_ref());
262                        } else {
263                            anyhow::bail!("expecting an open channel");
264                        }
265                        if let Some(changes) = params.get(1).and_then(|v| v.as_array()) {
266                            for change in changes {
267                                if let Some(type_) = change.get("Type").and_then(|v| v.as_str()) {
268                                    if type_ == "apply" {
269                                        let id = channel_id.as_ref().ok_or_else(|| {
270                                            anyhow::anyhow!("subscription not opened")
271                                        })?;
272                                        close_channel(&mut ws_stream, id).await?;
273                                        ws_stream.close(None).await?;
274                                        return Ok(());
275                                    } else if type_ == "revert" {
276                                        let id = channel_id.as_ref().ok_or_else(|| {
277                                            anyhow::anyhow!("subscription not opened")
278                                        })?;
279                                        close_channel(&mut ws_stream, id).await?;
280                                        ws_stream.close(None).await?;
281                                        anyhow::bail!("revert");
282                                    }
283                                }
284                            }
285                        }
286                    } else {
287                        let id = channel_id
288                            .as_ref()
289                            .ok_or_else(|| anyhow::anyhow!("subscription not opened"))?;
290                        close_channel(&mut ws_stream, id).await?;
291                        ws_stream.close(None).await?;
292                        anyhow::bail!("expecting params");
293                    }
294                }
295            }
296            Err(..) | Ok(WsMessage::Close(..)) => {
297                let id = channel_id
298                    .as_ref()
299                    .ok_or_else(|| anyhow::anyhow!("subscription not opened"))?;
300                close_channel(&mut ws_stream, id).await?;
301                ws_stream.close(None).await?;
302                anyhow::bail!("unexpected error or close message");
303            }
304            _ => {
305                // Ignore other message types
306            }
307        }
308    }
309
310    unreachable!("loop always returns within the branches above")
311}
312
313/// Poll `MpoolPending` until `message_cid` is visible. Returns while the message
314/// is still pending; it does not wait for on-chain inclusion.
315async fn wait_in_mempool(client: &rpc::Client, message_cid: Cid) -> anyhow::Result<()> {
316    let mut retries = 100;
317    loop {
318        let pending = client
319            .call(MpoolPending::request((ApiTipsetKey(None),))?)
320            .await?;
321        if pending.0.iter().any(|msg| msg.cid() == message_cid) {
322            break Ok(());
323        }
324        ensure!(retries != 0, "Message not found in mpool");
325        retries -= 1;
326        tokio::time::sleep(Duration::from_millis(10)).await;
327    }
328}
329
330/// Wait for `message_cid` to appear in the mempool and then be included on chain.
331async fn wait_pending_message(client: &rpc::Client, message_cid: Cid) -> anyhow::Result<()> {
332    let tipset = client.call(ChainHead::request(())?).await?;
333    wait_in_mempool(client, message_cid).await?;
334    client
335        .call(
336            StateWaitMsg::request((message_cid, 1, tipset.epoch(), true))?
337                .with_timeout(Duration::from_secs(300)),
338        )
339        .await?;
340    Ok(())
341}
342
343/// Poll `eth_getFilterChanges` until the hashes seen so far contain `want`.
344///
345/// The filter collects pending `txs` on a background task, so a poll right after a
346/// tx appears in the mempool may run before the collector has processed it.
347/// Each poll consumes the filter's buffer, so hashes are accumulated across
348/// polls and the full set seen is returned.
349async fn poll_pending_filter_until(
350    client: &rpc::Client,
351    filter_id: &FilterID,
352    want: &EthHash,
353) -> anyhow::Result<Vec<EthHash>> {
354    let mut seen: Vec<EthHash> = Vec::new();
355    let mut retries = 100;
356    loop {
357        let result = client
358            .call(EthGetFilterChanges::request((filter_id.clone(),))?)
359            .await?;
360        let EthFilterResult::Hashes(hashes) = result else {
361            anyhow::bail!("expected hashes, got {result:?}");
362        };
363        seen.extend(hashes);
364        if seen.contains(want) {
365            break Ok(seen);
366        }
367        ensure!(
368            retries != 0,
369            "filter did not return {want:?} in time; saw {seen:?}"
370        );
371        retries -= 1;
372        tokio::time::sleep(Duration::from_millis(10)).await;
373    }
374}
375
376async fn invoke_contract(client: &rpc::Client, tx: &TestTransaction) -> anyhow::Result<Cid> {
377    let encoded_params = cbor4ii::serde::to_vec(
378        Vec::with_capacity(tx.payload.len()),
379        &Value::Bytes(tx.payload.clone()),
380    )
381    .context("failed to encode params")?;
382    let nonce = client.call(MpoolGetNonce::request((tx.from,))?).await?;
383    let message = Message {
384        to: tx.to,
385        from: tx.from,
386        sequence: nonce,
387        method_num: EVMMethod::InvokeContract as u64,
388        params: encoded_params.into(),
389        ..Default::default()
390    };
391    let unsigned_msg = client
392        .call(GasEstimateMessageGas::request((
393            message,
394            None,
395            ApiTipsetKey(None),
396        ))?)
397        .await?;
398
399    let eth_tx_args = crate::eth::EthEip1559TxArgsBuilder::default()
400        .chain_id(ETH_CHAIN_ID)
401        .unsigned_message(&unsigned_msg)?
402        .build()
403        .map_err(|e| anyhow::anyhow!("Failed to build EIP-1559 transaction: {}", e))?;
404    let eth_tx = crate::eth::EthTx::from(eth_tx_args);
405    let data = eth_tx.rlp_unsigned_message(ETH_CHAIN_ID)?;
406
407    let sig = client.call(WalletSign::request((tx.from, data))?).await?;
408    let smsg = SignedMessage::new_unchecked(unsigned_msg, sig);
409    let cid = smsg.cid();
410
411    client.call(MpoolPush::request((smsg,))?).await?;
412
413    Ok(cid)
414}
415
416/// Open a WebSocket and start an `eth_subscribe` subscription of the given
417/// `kind`, with optional `filter` params (used by `logs`). Returns the live
418/// stream and the assigned subscription id.
419async fn open_eth_subscription(
420    client: &rpc::Client,
421    kind: SubscriptionKind,
422    filter: Option<serde_json::Value>,
423) -> anyhow::Result<(EthSubStream, serde_json::Value)> {
424    let mut ws_stream = connect_ws(client).await?;
425
426    let mut params = vec![serde_json::to_value(kind)?];
427    params.extend(filter);
428    let request = json!({
429        "jsonrpc": "2.0",
430        "id": 1,
431        "method": "eth_subscribe",
432        "params": params,
433    });
434    ws_stream
435        .send(WsMessage::Text(request.to_string().into()))
436        .await
437        .context("failed to send eth_subscribe request")?;
438
439    // The acknowledgement carries the subscription id in `result`.
440    let subscription_id = loop {
441        let msg = match tokio::time::timeout(Duration::from_secs(30), ws_stream.next()).await {
442            Ok(Some(msg)) => msg,
443            Ok(None) => anyhow::bail!("WebSocket stream closed before eth_subscribe ack"),
444            Err(_) => anyhow::bail!("timeout waiting for eth_subscribe ack"),
445        };
446        match msg {
447            Ok(WsMessage::Text(text)) => {
448                let json: serde_json::Value = serde_json::from_str(&text)?;
449                if let Some(error) = json.get("error") {
450                    anyhow::bail!("eth_subscribe failed: {error}");
451                }
452                if let Some(result) = json.get("result") {
453                    break result.clone();
454                }
455            }
456            Err(..) | Ok(WsMessage::Close(..)) => {
457                anyhow::bail!("WebSocket closed before eth_subscribe ack")
458            }
459            _ => {}
460        }
461    };
462
463    Ok((ws_stream, subscription_id))
464}
465
466/// Wait for the next `eth_subscription` notification on `subscription_id` and
467/// return its `result` payload.
468async fn next_subscription_payload(
469    ws_stream: &mut EthSubStream,
470    subscription_id: &serde_json::Value,
471    timeout: Duration,
472) -> anyhow::Result<serde_json::Value> {
473    loop {
474        let msg = match tokio::time::timeout(timeout, ws_stream.next()).await {
475            Ok(Some(msg)) => msg,
476            Ok(None) => anyhow::bail!("WebSocket stream closed"),
477            Err(_) => anyhow::bail!("timeout waiting for subscription notification"),
478        };
479        match msg {
480            Ok(WsMessage::Text(text)) => {
481                let json: serde_json::Value = serde_json::from_str(&text)?;
482                if json.get("method").and_then(|m| m.as_str()) != Some("eth_subscription") {
483                    continue;
484                }
485                let params = json
486                    .get("params")
487                    .context("subscription notification missing params")?;
488                anyhow::ensure!(
489                    params.get("subscription") == Some(subscription_id),
490                    "subscription id mismatch in notification"
491                );
492                return params
493                    .get("result")
494                    .cloned()
495                    .context("subscription notification missing result");
496            }
497            Err(..) | Ok(WsMessage::Close(..)) => anyhow::bail!("WebSocket closed unexpectedly"),
498            _ => {}
499        }
500    }
501}
502
503/// Cancel the subscription and close the WebSocket. Best-effort: the node also
504/// drops the subscription when the socket closes.
505async fn close_eth_subscription(
506    ws_stream: &mut EthSubStream,
507    subscription_id: &serde_json::Value,
508) -> anyhow::Result<()> {
509    let request = json!({
510        "jsonrpc": "2.0",
511        "id": 2,
512        "method": "eth_unsubscribe",
513        "params": [subscription_id],
514    });
515    ws_stream
516        .send(WsMessage::Text(request.to_string().into()))
517        .await
518        .context("failed to send eth_unsubscribe request")?;
519    ws_stream.close(None).await?;
520    Ok(())
521}
522
523fn eth_subscribe_new_heads() -> RpcTestScenario {
524    RpcTestScenario::basic(|client| async move {
525        // Remember the chain head before subscribing so we can prove the head we
526        // receive is real, advancing — not stale or random data.
527        let start = client.call(EthBlockNumber::request(())?).await?;
528
529        let (mut ws_stream, subscription_id) =
530            open_eth_subscription(&client, SubscriptionKind::NewHeads, None).await?;
531
532        // A new head is published once per tipset (~30s on calibnet).
533        let payload =
534            next_subscription_payload(&mut ws_stream, &subscription_id, Duration::from_secs(180))
535                .await;
536
537        let _ = close_eth_subscription(&mut ws_stream, &subscription_id).await;
538        let payload = payload?;
539
540        // `newHeads` must yield a block-header object
541        anyhow::ensure!(
542            payload.is_object(),
543            "newHeads must yield a block-header object, got: {payload}"
544        );
545        let header: ApiHeaders = serde_json::from_value(payload)
546            .context("newHeads payload is not a valid Eth block header")?;
547
548        // Identity: the header's number must be at or beyond the head seen at
549        // subscription time, proving it's a genuine fresh head.
550        anyhow::ensure!(
551            header.0.number.0 >= start.0 as i64,
552            "newHeads number {} precedes the head {} seen at subscription time",
553            header.0.number.0,
554            start.0
555        );
556        Ok(())
557    })
558}
559
560fn eth_subscribe_pending_transactions(tx: TestTransaction) -> RpcTestScenario {
561    RpcTestScenario::basic(move |client| {
562        let tx = tx.clone();
563        async move {
564            let (mut ws_stream, subscription_id) =
565                open_eth_subscription(&client, SubscriptionKind::PendingTransactions, None).await?;
566
567            // The subscription is active, so the pending tx we push now is observable.
568            let cid = invoke_contract(&client, &tx).await?;
569            let tx_hash = client
570                .call(EthGetTransactionHashByCid::request((cid,))?)
571                .await?
572                .context("no Eth transaction hash for CID")?;
573
574            // Watch pending-tx hashes until `our` transaction shows up.
575            let watch = async {
576                loop {
577                    let payload = next_subscription_payload(
578                        &mut ws_stream,
579                        &subscription_id,
580                        Duration::from_secs(120),
581                    )
582                    .await?;
583                    // a pending tx is a single hash string.
584                    anyhow::ensure!(
585                        payload.is_string(),
586                        "pendingTransactions must yield a tx-hash string, got: {payload}"
587                    );
588                    let hash: EthHash = serde_json::from_value(payload)
589                        .context("pendingTransactions payload is not an Eth hash")?;
590                    // received hash must be `our` transaction hash.
591                    if hash.eq(&tx_hash) {
592                        break;
593                    }
594                }
595                anyhow::Ok(())
596            };
597            let outcome = tokio::time::timeout(Duration::from_secs(120), watch)
598                .await
599                .unwrap_or_else(|_| {
600                    Err(anyhow::anyhow!(
601                        "timed out waiting for our pendingTransactions notification"
602                    ))
603                });
604
605            let _ = close_eth_subscription(&mut ws_stream, &subscription_id).await;
606            outcome
607        }
608    })
609}
610
611/// Minimal typed view of an `EthLog` for the fields the log test asserts on.
612#[derive(serde::Deserialize)]
613#[serde(rename_all = "camelCase")]
614struct LogView {
615    topics: Vec<EthHash>,
616    transaction_hash: EthHash,
617}
618
619/// One opened logs subscription paired with the events it is expected to deliver.
620struct CaseSub {
621    label: &'static str,
622    ws: EthSubStream,
623    sub_id: serde_json::Value,
624    /// Event signatures (each log's `topic[0]`) this filter should deliver for our mint.
625    expected: Vec<EthHash>,
626}
627
628/// Read notifications until one for our mint tx (`our_tx`) arrives; unrelated logs are
629/// skipped. Returns `None` if the `timeout` window elapses (or the socket closes) first.
630async fn next_our_log(
631    ws: &mut EthSubStream,
632    sub_id: &serde_json::Value,
633    our_tx: &EthHash,
634    timeout: Duration,
635) -> anyhow::Result<Option<LogView>> {
636    while let Ok(payload) = next_subscription_payload(ws, sub_id, timeout).await {
637        ensure!(
638            payload.is_object(),
639            "logs must yield a single log object, got: {payload}"
640        );
641        let log: LogView =
642            serde_json::from_value(payload).context("logs payload is not an Eth log")?;
643        if &log.transaction_hash == our_tx {
644            return Ok(Some(log));
645        }
646    }
647    Ok(None)
648}
649
650/// Backstop for a single subscription read. The coordinator's cancellation normally ends a
651/// drain first; this only bounds a read if the mint never executes.
652const LOGS_DELIVERY_TIMEOUT: Duration = Duration::from_secs(100);
653
654/// Drain one case subscription until the coordinator signals `stop` (a settle window after the mint's receipt
655/// lands), then assert the set of event signatures it delivered for our mint equals
656/// `expected`. An empty `expected` asserts the filter delivered nothing.
657async fn verify_case(
658    label: &str,
659    ws: &mut EthSubStream,
660    sub_id: &serde_json::Value,
661    our_tx: &EthHash,
662    expected: &[EthHash],
663    stop: &CancellationToken,
664) -> anyhow::Result<()> {
665    let mut got = Vec::new();
666    loop {
667        tokio::select! {
668            _ = stop.cancelled() => break,
669            log = next_our_log(ws, sub_id, our_tx, LOGS_DELIVERY_TIMEOUT) => {
670                if let Some(log) = log? {
671                    got.push(*log.topics.first().context("log is missing topic[0]")?);
672                }
673            }
674        }
675    }
676    // Reorgs re-deliver the same event (apply -> revert -> re-apply); assert the *set* of
677    // event signatures delivered, not the count.
678    got.sort();
679    got.dedup();
680    let mut want = expected.to_vec();
681    want.sort();
682    ensure!(got == want, "{label}: expected {want:?}, got {got:?}");
683    Ok(())
684}
685
686fn eth_subscribe_logs(tx: TestTransaction) -> RpcTestScenario {
687    RpcTestScenario::basic(move |client| {
688        // Once the txn lands, how long to let the log feed push to every subscription before we stop draining.
689        const SETTLE: Duration = Duration::from_secs(10);
690
691        let tx = tx.clone();
692        async move {
693            // A valid address that is not the contract.
694            const WRONG_ADDRESS: &str = "0x000000000000000000000000000000000000dead";
695            // The suite already passes Mint's signature as `topic`, so reuse it.
696            // Transfer's signature isn't passed in derive it from the event string.
697            let mint_topic = tx.topic;
698            let transfer = EthHash(H256::from(crate::utils::encoding::keccak_256(
699                b"Transfer(address,address,uint256)",
700            )));
701            let contract = EthAddress::from_filecoin_address(&tx.to)?;
702
703            // The values we filter on come straight from the `mint(address,uint256)`
704            // calldata (`--payload`): a 4-byte selector, then the 32-byte `to` and `amount`
705            // words.
706            let v_to = EthHash(H256::from_slice(
707                tx.payload
708                    .get(4..36)
709                    .context("mint calldata missing `to`")?,
710            ));
711            let v_amount = EthHash(H256::from_slice(
712                tx.payload
713                    .get(36..68)
714                    .context("mint calldata missing `amount`")?,
715            ));
716            let v_zero = EthHash::default(); // address(0)
717
718            // Contract address as a `0x..` string, for embedding in filters.
719            let c = format!("{:#x}", contract.0);
720
721            // Every topic case scopes to the contract address; only `topics` varies.
722            let by_topics =
723                |topics: serde_json::Value| json!({ "address": [c.as_str()], "topics": topics });
724
725            // Expected event sets, named for the assertion site.
726            let both_topics = vec![mint_topic, transfer];
727            let only_mint_topic = vec![mint_topic];
728            let only_transfer_topic = vec![transfer];
729            let none: Vec<EthHash> = vec![];
730
731            // (label, filter, expected events), grouped by the dimension it exercises.
732            let specs: Vec<(&'static str, serde_json::Value, Vec<EthHash>)> = vec![
733                // --- address ---
734                (
735                    "address empty list (wildcard)",
736                    json!({ "address": [], "topics": null }),
737                    both_topics.clone(),
738                ),
739                (
740                    "address [contract, other]",
741                    json!({ "address": [c.as_str(), WRONG_ADDRESS], "topics": null }),
742                    both_topics.clone(),
743                ),
744                (
745                    "address non-matching",
746                    json!({ "address": [WRONG_ADDRESS], "topics": null }),
747                    none.clone(),
748                ),
749                // --- topic[0] = event signature ---
750                (
751                    "topic0 = Mint",
752                    by_topics(json!([mint_topic.to_string()])),
753                    only_mint_topic.clone(),
754                ),
755                (
756                    "topic0 = Transfer",
757                    by_topics(json!([transfer.to_string()])),
758                    only_transfer_topic.clone(),
759                ),
760                (
761                    "topic0 OR [Mint, Transfer]",
762                    by_topics(json!([[mint_topic.to_string(), transfer.to_string()]])),
763                    both_topics.clone(),
764                ),
765                (
766                    "topic0 empty-list wildcard",
767                    by_topics(json!([[]])),
768                    both_topics.clone(),
769                ),
770                (
771                    "topic0 null wildcard",
772                    by_topics(json!([null])),
773                    both_topics.clone(),
774                ),
775                // --- trailing wildcard / positions past the log's topics ---
776                (
777                    "Mint + trailing null",
778                    by_topics(json!([mint_topic.to_string(), null])),
779                    only_mint_topic.clone(),
780                ),
781                (
782                    "Mint + null past topics",
783                    by_topics(json!([mint_topic.to_string(), v_to.to_string(), null])),
784                    only_mint_topic.clone(),
785                ),
786                (
787                    "Mint + value past topics (no match)",
788                    by_topics(json!([
789                        mint_topic.to_string(),
790                        v_to.to_string(),
791                        v_to.to_string()
792                    ])),
793                    none.clone(),
794                ),
795                // --- indexed values: AND across positions, OR within, positional ---
796                (
797                    "topic1 = to (only Mint)",
798                    by_topics(json!([null, v_to.to_string()])),
799                    only_mint_topic.clone(),
800                ),
801                (
802                    "topic1 = 0x0 (only Transfer)",
803                    by_topics(json!([null, v_zero.to_string()])),
804                    only_transfer_topic.clone(),
805                ),
806                (
807                    "topic2 = to (only Transfer)",
808                    by_topics(json!([null, null, v_to.to_string()])),
809                    only_transfer_topic.clone(),
810                ),
811                (
812                    "Transfer AND from=0 AND to",
813                    by_topics(json!([
814                        transfer.to_string(),
815                        v_zero.to_string(),
816                        v_to.to_string()
817                    ])),
818                    only_transfer_topic.clone(),
819                ),
820                (
821                    "Transfer AND topic1=to (mismatch)",
822                    by_topics(json!([transfer.to_string(), v_to.to_string()])),
823                    none.clone(),
824                ),
825                (
826                    "(Mint|Transfer) AND topic1=to",
827                    by_topics(json!([
828                        [mint_topic.to_string(), transfer.to_string()],
829                        v_to.to_string()
830                    ])),
831                    only_mint_topic.clone(),
832                ),
833                // --- data is never matched as a topic ---
834                (
835                    "topic1 = amount (in data, no match)",
836                    by_topics(json!([null, v_amount.to_string()])),
837                    none.clone(),
838                ),
839            ];
840            let mut subs = Vec::with_capacity(specs.len());
841            for (label, filter, expected) in specs {
842                let (ws, sub_id) =
843                    open_eth_subscription(&client, SubscriptionKind::Logs, Some(filter)).await?;
844                subs.push(CaseSub {
845                    label,
846                    ws,
847                    sub_id,
848                    expected,
849                });
850            }
851
852            // Emit the operator's `mint` tx; it produces the Mint + Transfer logs.
853            let cid = invoke_contract(&client, &tx).await?;
854            let tx_hash = client
855                .call(EthGetTransactionHashByCid::request((cid,))?)
856                .await?
857                .context("no Eth transaction hash for CID")?;
858            // Drain every subscription concurrently so none sits idle while we wait.
859            // All subs receive the same logs at once, so the coordinator just waits for the mint to execute,
860            // and then we wait for some `SETTLE` time and then stop as soon as the logs are delivered.
861            let stop = CancellationToken::new();
862            // Make sure token is cancelled on error path
863            let _cancellation_token_drop_guard = stop.drop_guard_ref();
864            let coordinator = async {
865                let executed = wait_pending_message(&client, cid).await;
866                tokio::time::sleep(SETTLE).await;
867                stop.cancel();
868                executed
869            };
870            let drains =
871                futures::future::join_all(subs.iter_mut().map(|c| {
872                    verify_case(c.label, &mut c.ws, &c.sub_id, &tx_hash, &c.expected, &stop)
873                }));
874            let (executed, case_results) = tokio::join!(coordinator, drains);
875            let outcome = executed.and_then(|()| {
876                for result in case_results {
877                    result?;
878                }
879                anyhow::Ok(())
880            });
881
882            for sub in &mut subs {
883                let _ = close_eth_subscription(&mut sub.ws, &sub.sub_id).await;
884            }
885            outcome
886        }
887    })
888}
889
890fn create_eth_new_filter_test() -> RpcTestScenario {
891    RpcTestScenario::basic(|client| async move {
892        const BLOCK_RANGE: u64 = 200;
893
894        let last_block = client.call(EthBlockNumber::request(())?).await?;
895
896        let filter_spec = EthFilterSpec {
897            from_block: Some(EthUint64(last_block.0.saturating_sub(BLOCK_RANGE)).to_hex_string()),
898            to_block: Some(last_block.to_hex_string()),
899            ..Default::default()
900        };
901
902        let filter_id = client.call(EthNewFilter::request((filter_spec,))?).await?;
903
904        let removed = client
905            .call(EthUninstallFilter::request((filter_id.clone(),))?)
906            .await?;
907        anyhow::ensure!(removed);
908
909        let removed = client
910            .call(EthUninstallFilter::request((filter_id,))?)
911            .await?;
912        anyhow::ensure!(!removed);
913
914        Ok(())
915    })
916}
917
918fn create_eth_new_filter_limit_test(count: usize) -> RpcTestScenario {
919    RpcTestScenario::basic(move |client| async move {
920        const BLOCK_RANGE: u64 = 200;
921
922        let last_block = client.call(EthBlockNumber::request(())?).await?;
923
924        let filter_spec = EthFilterSpec {
925            from_block: Some(format!("0x{:x}", last_block.0.saturating_sub(BLOCK_RANGE))),
926            to_block: Some(last_block.to_hex_string()),
927            ..Default::default()
928        };
929
930        let mut ids = vec![];
931
932        for _ in 0..count {
933            let result = client
934                .call(EthNewFilter::request((filter_spec.clone(),))?)
935                .await;
936
937            match result {
938                Ok(filter_id) => ids.push(filter_id),
939                Err(e) => {
940                    // Cleanup any filters created so far to leave a clean state
941                    for id in ids {
942                        let removed = client.call(EthUninstallFilter::request((id,))?).await?;
943                        anyhow::ensure!(removed);
944                    }
945                    anyhow::bail!(e)
946                }
947            }
948        }
949
950        for id in ids {
951            let removed = client.call(EthUninstallFilter::request((id,))?).await?;
952            anyhow::ensure!(removed);
953        }
954
955        Ok(())
956    })
957}
958
959fn eth_new_block_filter() -> RpcTestScenario {
960    RpcTestScenario::basic(move |client| async move {
961        async fn process_filter(client: &rpc::Client, filter_id: &FilterID) -> anyhow::Result<()> {
962            let filter_result = client
963                .call(EthGetFilterChanges::request((filter_id.clone(),))?)
964                .await?;
965
966            if let EthFilterResult::Hashes(prev_hashes) = filter_result {
967                let verify_hashes = async |hashes: &[EthHash]| -> anyhow::Result<()> {
968                    for hash in hashes {
969                        let _block = client
970                            .call(EthGetBlockByHash::request((*hash, false))?)
971                            .await?;
972                    }
973                    Ok(())
974                };
975                verify_hashes(&prev_hashes).await?;
976
977                // Wait for the next block to arrive
978                next_tipset(client).await?;
979
980                let filter_result = client
981                    .call(EthGetFilterChanges::request((filter_id.clone(),))?)
982                    .await?;
983
984                if let EthFilterResult::Hashes(hashes) = filter_result {
985                    verify_hashes(&hashes).await?;
986                    anyhow::ensure!(
987                        (prev_hashes.is_empty() && hashes.is_empty()) || prev_hashes != hashes,
988                    );
989
990                    Ok(())
991                } else {
992                    Err(anyhow::anyhow!("expecting blocks"))
993                }
994            } else {
995                Err(anyhow::anyhow!("expecting blocks"))
996            }
997        }
998
999        let mut retries = 5;
1000        loop {
1001            // Create the filter
1002            let filter_id = client.call(EthNewBlockFilter::request(())?).await?;
1003
1004            let result = match process_filter(&client, &filter_id).await {
1005                Ok(()) => Ok(()),
1006                Err(e) if retries != 0 && e.to_string().contains("revert") => {
1007                    // Cleanup
1008                    let removed = client
1009                        .call(EthUninstallFilter::request((filter_id,))?)
1010                        .await?;
1011                    anyhow::ensure!(removed);
1012
1013                    retries -= 1;
1014                    continue;
1015                }
1016                Err(e) => Err(e),
1017            };
1018
1019            // Cleanup
1020            let removed = client
1021                .call(EthUninstallFilter::request((filter_id,))?)
1022                .await?;
1023            anyhow::ensure!(removed);
1024
1025            break result;
1026        }
1027    })
1028}
1029
1030fn eth_new_pending_transaction_filter(tx: TestTransaction) -> RpcTestScenario {
1031    RpcTestScenario::basic(move |client| {
1032        let tx = tx.clone();
1033        async move {
1034            let filter_id = client
1035                .call(EthNewPendingTransactionFilter::request(())?)
1036                .await?;
1037
1038            let filter_result = client
1039                .call(EthGetFilterChanges::request((filter_id.clone(),))?)
1040                .await?;
1041
1042            let result = if let EthFilterResult::Hashes(prev_hashes) = filter_result {
1043                let cid = invoke_contract(&client, &tx).await?;
1044                let tx_hash = client
1045                    .call(EthGetTransactionHashByCid::request((cid,))?)
1046                    .await?
1047                    .context("no Eth transaction hash for CID")?;
1048
1049                // Observe the mempool state before the message is mined. The
1050                // filter collects asynchronously, so poll until tx_hash appears.
1051                wait_in_mempool(&client, cid).await?;
1052                let hashes = poll_pending_filter_until(&client, &filter_id, &tx_hash).await?;
1053
1054                anyhow::ensure!(
1055                    prev_hashes != hashes,
1056                    "prev_hashes={prev_hashes:?} hashes={hashes:?}"
1057                );
1058                anyhow::ensure!(
1059                    hashes.contains(&tx_hash),
1060                    "transaction hash missing from filter results: tx_hash={tx_hash:?} cid={cid:?} hashes={hashes:?}"
1061                );
1062                Ok(())
1063            } else {
1064                Err(anyhow::anyhow!("expecting transactions"))
1065            };
1066
1067            let removed = client
1068                .call(EthUninstallFilter::request((filter_id,))?)
1069                .await?;
1070            anyhow::ensure!(removed);
1071
1072            result
1073        }
1074    })
1075}
1076
1077/// Verify that successive `eth_getFilterChanges` polls return only the
1078/// pending transactions added since the previous poll.
1079///
1080/// 1. Install a pending-tx filter.
1081/// 2. Drain any baseline state with an initial poll.
1082/// 3. Submit tx A, wait for it in the mempool, poll — assert hash A present.
1083/// 4. Submit tx B, wait for it in the mempool, poll — assert hash B present
1084///    and hash A absent (it was already consumed by the previous poll).
1085fn eth_new_pending_transaction_filter_multi_poll(tx: TestTransaction) -> RpcTestScenario {
1086    RpcTestScenario::basic(move |client| {
1087        let tx = tx.clone();
1088        async move {
1089            let filter_id = client
1090                .call(EthNewPendingTransactionFilter::request(())?)
1091                .await?;
1092
1093            let result = async {
1094                // Baseline: clear any pre-existing pending state.
1095                let _ = client
1096                    .call(EthGetFilterChanges::request((filter_id.clone(),))?)
1097                    .await?;
1098
1099                // First tx — poll until it shows up (collection is async).
1100                let cid_a = invoke_contract(&client, &tx).await?;
1101                let hash_a = client
1102                    .call(EthGetTransactionHashByCid::request((cid_a,))?)
1103                    .await?
1104                    .context("no Eth transaction hash for cid_a")?;
1105                wait_in_mempool(&client, cid_a).await?;
1106                poll_pending_filter_until(&client, &filter_id, &hash_a).await?;
1107
1108                // Second tx — the next polls return it but not the
1109                // already-consumed tx_a.
1110                let cid_b = invoke_contract(&client, &tx).await?;
1111                let hash_b = client
1112                    .call(EthGetTransactionHashByCid::request((cid_b,))?)
1113                    .await?
1114                    .context("no Eth transaction hash for cid_b")?;
1115                wait_in_mempool(&client, cid_b).await?;
1116                let hashes_b = poll_pending_filter_until(&client, &filter_id, &hash_b).await?;
1117                anyhow::ensure!(
1118                    !hashes_b.contains(&hash_a),
1119                    "second poll should not return previously-consumed tx_a: \
1120                     hash_a={hash_a:?} hashes={hashes_b:?}"
1121                );
1122
1123                anyhow::Ok(())
1124            }
1125            .await;
1126
1127            let removed = client
1128                .call(EthUninstallFilter::request((filter_id,))?)
1129                .await?;
1130            anyhow::ensure!(removed);
1131
1132            result
1133        }
1134    })
1135}
1136
1137fn as_logs(input: EthFilterResult) -> EthFilterResult {
1138    match input {
1139        EthFilterResult::Hashes(vec) if vec.is_empty() => EthFilterResult::Logs(Vec::new()),
1140        other => other,
1141    }
1142}
1143
1144fn eth_get_filter_logs(tx: TestTransaction) -> RpcTestScenario {
1145    RpcTestScenario::basic(move |client| {
1146        let tx = tx.clone();
1147        async move {
1148            const BLOCK_RANGE: u64 = 1;
1149
1150            let tipset = client.call(ChainHead::request(())?).await?;
1151            let cid = invoke_contract(&client, &tx).await?;
1152            let lookup = client
1153                .call(
1154                    StateWaitMsg::request((cid, 1, tipset.epoch(), true))?
1155                        .with_timeout(Duration::from_secs(300)),
1156                )
1157                .await?;
1158            let block_num = EthUint64(lookup.height as u64);
1159
1160            let topics = EthTopicSpec(vec![EthHashList::Single(Some(tx.topic))]);
1161            let filter_spec = EthFilterSpec {
1162                from_block: Some(format!("0x{:x}", block_num.0.saturating_sub(BLOCK_RANGE))),
1163                topics: Some(topics),
1164                ..Default::default()
1165            };
1166
1167            let filter_id = client
1168                .call(EthNewFilter::request((filter_spec.clone(),))?)
1169                .await?;
1170            let filter_result = as_logs(
1171                client
1172                    .call(EthGetFilterLogs::request((filter_id.clone(),))?)
1173                    .await?,
1174            );
1175            let result = if let EthFilterResult::Logs(logs) = filter_result {
1176                anyhow::ensure!(
1177                    !logs.is_empty(),
1178                    "Empty logs: filter_spec={filter_spec:?} cid={cid:?}",
1179                );
1180                Ok(())
1181            } else {
1182                Err(anyhow::anyhow!("expecting logs"))
1183            };
1184
1185            let removed = client
1186                .call(EthUninstallFilter::request((filter_id,))?)
1187                .await?;
1188            anyhow::ensure!(removed);
1189
1190            result
1191        }
1192    })
1193}
1194
1195const LOTUS_EVENTS_MAXFILTERS: usize = 100;
1196
1197macro_rules! with_methods {
1198    ( $builder:expr, $( $method:ty ),+ ) => {{
1199        let mut b = $builder;
1200        $(
1201            b = b.using::<{ <$method>::N_REQUIRED_PARAMS }, $method>();
1202        )+
1203        b
1204    }};
1205}
1206
1207pub(super) async fn create_tests(tx: TestTransaction) -> Vec<RpcTestScenario> {
1208    vec![
1209        with_methods!(
1210            create_eth_new_filter_test().name("eth_newFilter install/uninstall"),
1211            EthNewFilter,
1212            EthUninstallFilter
1213        ),
1214        with_methods!(
1215            create_eth_new_filter_limit_test(20).name("eth_newFilter under limit"),
1216            EthNewFilter,
1217            EthUninstallFilter
1218        ),
1219        with_methods!(
1220            create_eth_new_filter_limit_test(LOTUS_EVENTS_MAXFILTERS)
1221                .name("eth_newFilter just under limit"),
1222            EthNewFilter,
1223            EthUninstallFilter
1224        ),
1225        with_methods!(
1226            create_eth_new_filter_limit_test(LOTUS_EVENTS_MAXFILTERS + 1)
1227                .name("eth_newFilter over limit")
1228                .should_fail_with("maximum number of filters registered"),
1229            EthNewFilter,
1230            EthUninstallFilter
1231        ),
1232        with_methods!(
1233            eth_new_block_filter().name("eth_newBlockFilter works"),
1234            EthNewBlockFilter,
1235            EthGetFilterChanges,
1236            EthUninstallFilter
1237        ),
1238        with_methods!(
1239            eth_new_pending_transaction_filter(tx.clone())
1240                .name("eth_newPendingTransactionFilter works"),
1241            EthNewPendingTransactionFilter,
1242            EthGetFilterChanges,
1243            EthGetTransactionHashByCid,
1244            EthUninstallFilter
1245        ),
1246        with_methods!(
1247            eth_new_pending_transaction_filter_multi_poll(tx.clone())
1248                .name("eth_getFilterChanges returns only new pending txs per poll"),
1249            EthNewPendingTransactionFilter,
1250            EthGetFilterChanges,
1251            EthGetTransactionHashByCid,
1252            EthUninstallFilter
1253        ),
1254        with_methods!(
1255            eth_get_filter_logs(tx.clone()).name("eth_getFilterLogs works"),
1256            EthNewFilter,
1257            EthGetFilterLogs,
1258            EthUninstallFilter
1259        ),
1260        with_methods!(
1261            eth_subscribe_new_heads().name("eth_subscribe newHeads works"),
1262            EthSubscribe,
1263            EthUnsubscribe
1264        ),
1265        with_methods!(
1266            eth_subscribe_pending_transactions(tx.clone())
1267                .name("eth_subscribe pendingTransactions works"),
1268            EthSubscribe,
1269            EthUnsubscribe,
1270            EthGetTransactionHashByCid
1271        ),
1272        with_methods!(
1273            eth_subscribe_logs(tx.clone()).name("eth_subscribe logs filter matrix"),
1274            EthSubscribe,
1275            EthUnsubscribe,
1276            EthGetTransactionHashByCid
1277        ),
1278    ]
1279}