Skip to main content

forest/rpc/methods/
mpool.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use super::gas::estimate_message_gas;
5use crate::lotus_json::{LotusJson, NotNullVec, lotus_json_with_self};
6use crate::message::SignedMessage;
7use crate::prelude::*;
8use crate::rpc::error::ServerError;
9use crate::rpc::types::{ApiTipsetKey, MessageSendSpec};
10use crate::rpc::{ApiPaths, Ctx, Permission, RpcMethod};
11use crate::shim::{
12    address::{Address, Protocol},
13    message::Message,
14    percent::Percent,
15};
16use ahash::HashSet;
17use enumflags2::BitFlags;
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use std::time::Duration;
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "PascalCase")]
24pub struct ApiMpoolConfig {
25    #[schemars(with = "LotusJson<Vec<Address>>")]
26    #[serde(with = "crate::lotus_json")]
27    pub priority_addrs: Vec<Address>,
28    pub size_limit_high: i64,
29    pub size_limit_low: i64,
30    #[serde(with = "crate::lotus_json")]
31    #[schemars(with = "LotusJson<Percent>")]
32    pub replace_by_fee_ratio: Percent,
33    #[schemars(with = "LotusJson<Duration>")]
34    #[serde(with = "crate::lotus_json")]
35    pub prune_cooldown: Duration,
36    pub gas_limit_overestimation: f64,
37}
38
39lotus_json_with_self!(ApiMpoolConfig);
40
41/// Returns a copy of the current mpool config.
42pub enum MpoolGetConfig {}
43impl RpcMethod<0> for MpoolGetConfig {
44    const NAME: &'static str = "Filecoin.MpoolGetConfig";
45    const PARAM_NAMES: [&'static str; 0] = [];
46    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
47    const PERMISSION: Permission = Permission::Read;
48    const DESCRIPTION: &'static str = "Returns a copy of the current mpool config.";
49
50    type Params = ();
51    type Ok = ApiMpoolConfig;
52
53    async fn handle(
54        ctx: Ctx,
55        (): Self::Params,
56        _: &http::Extensions,
57    ) -> Result<Self::Ok, ServerError> {
58        let cfg = ctx.mpool.config();
59        Ok(ApiMpoolConfig {
60            priority_addrs: cfg.priority_addrs,
61            size_limit_high: cfg.size_limit_high,
62            size_limit_low: cfg.size_limit_low,
63            replace_by_fee_ratio: cfg.replace_by_fee_ratio,
64            prune_cooldown: cfg.prune_cooldown,
65            gas_limit_overestimation: cfg.gas_limit_overestimation,
66        })
67    }
68}
69
70/// Gets next nonce for the specified sender.
71pub enum MpoolGetNonce {}
72impl RpcMethod<1> for MpoolGetNonce {
73    const NAME: &'static str = "Filecoin.MpoolGetNonce";
74    const PARAM_NAMES: [&'static str; 1] = ["address"];
75    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
76    const PERMISSION: Permission = Permission::Read;
77    const DESCRIPTION: &'static str = "Returns the current nonce for the specified address.";
78
79    type Params = (Address,);
80    type Ok = u64;
81
82    async fn handle(
83        ctx: Ctx,
84        (address,): Self::Params,
85        _: &http::Extensions,
86    ) -> Result<Self::Ok, ServerError> {
87        Ok(ctx.mpool.get_sequence(&address).await?)
88    }
89}
90
91/// Return `Vec` of pending messages in `mpool`
92pub enum MpoolPending {}
93impl RpcMethod<1> for MpoolPending {
94    const NAME: &'static str = "Filecoin.MpoolPending";
95    const PARAM_NAMES: [&'static str; 1] = ["tipsetKey"];
96    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
97    const PERMISSION: Permission = Permission::Read;
98    const DESCRIPTION: &'static str = "Returns the pending messages for a given tipset.";
99
100    type Params = (ApiTipsetKey,);
101    type Ok = NotNullVec<SignedMessage>;
102
103    async fn handle(
104        ctx: Ctx,
105        (ApiTipsetKey(tipset_key),): Self::Params,
106        _: &http::Extensions,
107    ) -> Result<Self::Ok, ServerError> {
108        let mut ts = ctx
109            .chain_store()
110            .load_required_tipset_or_heaviest(&tipset_key)?;
111
112        let (mut pending, mpts) = ctx.mpool.pending();
113
114        // The mpool is already at or past `ts`, so its pending set needs no on-chain merge.
115        if mpts.epoch() > ts.epoch() || mpts == ts {
116            return Ok(pending.into());
117        }
118
119        let mut have_cids: HashSet<_> = pending.iter().map(|m| m.cid()).collect();
120
121        loop {
122            // A null round can make `ts.parents()` skip past `mpts.epoch()`, so this may never
123            // match, in which case nothing is excluded and `ts` is merged as-is.
124            if mpts.epoch() == ts.epoch() {
125                if mpts == ts {
126                    break;
127                }
128
129                // Exclude what the mpool tipset's blocks already include, so only `ts`-specific
130                // messages get merged below.
131                // <https://github.com/filecoin-project/lotus/blob/27abf0f16a7f2a83305910f3c2a1844764d20b75/node/impl/full/mpool.go#L94>
132                let have = ctx.mpool.messages_for_blocks(mpts.block_headers().iter())?;
133                have_cids.extend(have.iter().map(|m| m.cid()));
134            }
135
136            let msgs = ctx.mpool.messages_for_blocks(ts.block_headers().iter())?;
137
138            for m in msgs {
139                if have_cids.insert(m.cid()) {
140                    pending.push(m);
141                }
142            }
143
144            if mpts.epoch() >= ts.epoch() {
145                break;
146            }
147
148            ts = ctx.chain_index().load_required_tipset(ts.parents())?;
149        }
150        Ok(pending.into())
151    }
152}
153
154/// Return `Vec` of pending messages for inclusion in the next block
155pub enum MpoolSelect {}
156impl RpcMethod<2> for MpoolSelect {
157    const NAME: &'static str = "Filecoin.MpoolSelect";
158    const PARAM_NAMES: [&'static str; 2] = ["tipsetKey", "ticketQuality"];
159    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
160    const PERMISSION: Permission = Permission::Read;
161    const DESCRIPTION: &'static str =
162        "Returns a list of pending messages for inclusion in the next block.";
163
164    type Params = (ApiTipsetKey, f64);
165    type Ok = Vec<SignedMessage>;
166
167    async fn handle(
168        ctx: Ctx,
169        (ApiTipsetKey(tipset_key), ticket_quality): Self::Params,
170        _: &http::Extensions,
171    ) -> Result<Self::Ok, ServerError> {
172        let ts = ctx
173            .chain_store()
174            .load_required_tipset_or_heaviest(&tipset_key)?;
175        Ok(ctx.mpool.select_messages(&ts, ticket_quality)?)
176    }
177}
178
179/// Add `SignedMessage` to `mpool`, return message CID
180pub enum MpoolPush {}
181impl RpcMethod<1> for MpoolPush {
182    const NAME: &'static str = "Filecoin.MpoolPush";
183    const PARAM_NAMES: [&'static str; 1] = ["message"];
184    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
185    const PERMISSION: Permission = Permission::Write;
186    const DESCRIPTION: &'static str = "Adds a signed message to the message pool.";
187
188    type Params = (SignedMessage,);
189    type Ok = Cid;
190
191    async fn handle(
192        ctx: Ctx,
193        (message,): Self::Params,
194        _: &http::Extensions,
195    ) -> Result<Self::Ok, ServerError> {
196        let cid = ctx.mpool.push(message).await?;
197        Ok(cid)
198    }
199}
200
201/// Add a batch of `SignedMessage`s to `mpool`, return message CIDs
202pub enum MpoolBatchPush {}
203impl RpcMethod<1> for MpoolBatchPush {
204    const NAME: &'static str = "Filecoin.MpoolBatchPush";
205    const PARAM_NAMES: [&'static str; 1] = ["messages"];
206    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
207    const PERMISSION: Permission = Permission::Write;
208    const DESCRIPTION: &'static str = "Adds a set of signed messages to the message pool.";
209
210    type Params = (Vec<SignedMessage>,);
211    type Ok = Vec<Cid>;
212
213    async fn handle(
214        ctx: Ctx,
215        (messages,): Self::Params,
216        _: &http::Extensions,
217    ) -> Result<Self::Ok, ServerError> {
218        let mut cids = vec![];
219        for msg in messages {
220            cids.push(ctx.mpool.push(msg).await?);
221        }
222        Ok(cids)
223    }
224}
225
226/// Add `SignedMessage` from untrusted source to `mpool`, return message CID
227pub enum MpoolPushUntrusted {}
228impl RpcMethod<1> for MpoolPushUntrusted {
229    const NAME: &'static str = "Filecoin.MpoolPushUntrusted";
230    const PARAM_NAMES: [&'static str; 1] = ["message"];
231    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
232    const PERMISSION: Permission = Permission::Write;
233    const DESCRIPTION: &'static str =
234        "Adds a message to the message pool with verification checks.";
235
236    type Params = (SignedMessage,);
237    type Ok = Cid;
238
239    async fn handle(
240        ctx: Ctx,
241        (message,): Self::Params,
242        _: &http::Extensions,
243    ) -> Result<Self::Ok, ServerError> {
244        // Lotus implements a few extra sanity checks that we skip. We skip them
245        // because those checks aren't used for messages received from peers and
246        // therefore aren't safety critical.
247        let cid = ctx.mpool.push_untrusted(message).await?;
248        Ok(cid)
249    }
250}
251
252/// Add a batch of `SignedMessage`s to `mpool`, return message CIDs
253pub enum MpoolBatchPushUntrusted {}
254impl RpcMethod<1> for MpoolBatchPushUntrusted {
255    const NAME: &'static str = "Filecoin.MpoolBatchPushUntrusted";
256    const PARAM_NAMES: [&'static str; 1] = ["messages"];
257    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
258    const PERMISSION: Permission = Permission::Write;
259    const DESCRIPTION: &'static str =
260        "Adds a set of messages to the message pool with additional verification checks.";
261
262    type Params = (Vec<SignedMessage>,);
263    type Ok = Vec<Cid>;
264
265    async fn handle(
266        ctx: Ctx,
267        (messages,): Self::Params,
268        ext: &http::Extensions,
269    ) -> Result<Self::Ok, ServerError> {
270        // Alias of MpoolBatchPush.
271        MpoolBatchPush::handle(ctx, (messages,), ext).await
272    }
273}
274
275/// Sign given `UnsignedMessage` and add it to `mpool`, return `SignedMessage`
276pub enum MpoolPushMessage {}
277impl RpcMethod<2> for MpoolPushMessage {
278    const NAME: &'static str = "Filecoin.MpoolPushMessage";
279    const PARAM_NAMES: [&'static str; 2] = ["message", "sendSpec"];
280    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
281    const PERMISSION: Permission = Permission::Sign;
282    const DESCRIPTION: &'static str =
283        "Assigns a nonce, signs, and pushes a message to the mempool.";
284
285    type Params = (Message, Option<MessageSendSpec>);
286    type Ok = SignedMessage;
287
288    async fn handle(
289        ctx: Ctx,
290        (message, send_spec): Self::Params,
291        extensions: &http::Extensions,
292    ) -> Result<Self::Ok, ServerError> {
293        let from = message.from;
294
295        let heaviest_tipset = ctx.chain_store().heaviest_tipset();
296        let key_addr = ctx
297            .state_manager
298            .resolve_to_deterministic_address(from, &heaviest_tipset)
299            .await?;
300
301        if message.sequence != 0 {
302            return Err(anyhow::anyhow!(
303                "Expected nonce for MpoolPushMessage is 0, and will be calculated for you"
304            )
305            .into());
306        }
307
308        let _sender_guard = ctx.mpool_locker.take_lock(key_addr).await;
309
310        let mut message =
311            estimate_message_gas(&ctx, message, send_spec, Default::default()).await?;
312        if message.gas_premium > message.gas_fee_cap {
313            return Err(anyhow::anyhow!(
314                "After estimation, gas premium is greater than gas fee cap"
315            )
316            .into());
317        }
318
319        if from.protocol() == Protocol::ID {
320            message.from = key_addr;
321        }
322
323        let balance =
324            super::wallet::WalletBalance::handle(ctx.clone(), (message.from,), extensions).await?;
325        let required_funds = &message.value + &message.gas_fee_cap * message.gas_limit;
326        if balance < required_funds {
327            return Err(anyhow::anyhow!(
328                "mpool push: not enough funds: {balance} < {required_funds}",
329            )
330            .into());
331        }
332
333        let key = crate::key_management::Key::try_from(crate::key_management::try_find(
334            &key_addr,
335            &ctx.keystore.as_ref().read(),
336        )?)?;
337        let eth_chain_id = ctx.chain_config().eth_chain_id;
338
339        let smsg = ctx
340            .nonce_tracker
341            .sign_and_push(&ctx.mpool, message, &key, eth_chain_id)
342            .await?;
343
344        Ok(smsg)
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::blocks::{Block, CachingBlockHeader, FullTipset, RawBlockHeader, Tipset};
352    use crate::chain::ChainStore;
353    use crate::chain_sync::TipsetValidator;
354    use crate::rpc::RPCState;
355    use crate::rpc::test_utils::chain_store;
356    use crate::shim::crypto::{SECP_SIG_LEN, Signature};
357    use crate::test_utils::dummy_ticket;
358    use fvm_ipld_blockstore::Blockstore;
359
360    /// A secp message, unique per `sequence`.
361    fn secp_message(sequence: u64) -> SignedMessage {
362        SignedMessage::new_unchecked(
363            Message {
364                from: Address::new_id(100),
365                to: Address::new_id(101),
366                sequence,
367                ..Default::default()
368            },
369            Signature::new_secp256k1(vec![0; SECP_SIG_LEN]),
370        )
371    }
372
373    /// A persisted single-block child of `parent` at an explicit `epoch`. A gap over
374    /// `parent.epoch() + 1` models a null round. `ticket` distinguishes siblings.
375    fn tipset_at(
376        db: &impl Blockstore,
377        parent: &Tipset,
378        epoch: i64,
379        ticket: u8,
380        bls: &[Message],
381        secp: &[SignedMessage],
382    ) -> Tipset {
383        let fts = FullTipset::new([Block {
384            header: CachingBlockHeader::new(RawBlockHeader {
385                parents: parent.key().clone(),
386                epoch,
387                messages: TipsetValidator::compute_msg_root(db, bls, secp).unwrap(),
388                ticket: dummy_ticket(ticket),
389                ..Default::default()
390            }),
391            bls_messages: bls.to_vec(),
392            secp_messages: secp.to_vec(),
393        }])
394        .unwrap();
395        fts.persist(db).unwrap();
396        fts.into_tipset()
397    }
398
399    /// A persisted single-block child of `parent`. `ticket` distinguishes siblings.
400    fn tipset_on(
401        db: &impl Blockstore,
402        parent: &Tipset,
403        ticket: u8,
404        bls: &[Message],
405        secp: &[SignedMessage],
406    ) -> Tipset {
407        tipset_at(db, parent, parent.epoch() + 1, ticket, bls, secp)
408    }
409
410    /// An `RPCState` whose message pool sits on `mpool_ts`.
411    fn ctx_on(cs: ChainStore, mpool_ts: &Tipset) -> Arc<RPCState> {
412        cs.set_heaviest_tipset(mpool_ts.clone()).unwrap();
413        let (ctx, _) = RPCState::for_tests(cs).unwrap();
414        assert_eq!(
415            &ctx.mpool.pending().1,
416            mpool_ts,
417            "the pool must adopt the heaviest tipset"
418        );
419        ctx
420    }
421
422    async fn pending_at(ctx: Arc<RPCState>, ts: &Tipset) -> Vec<SignedMessage> {
423        let NotNullVec(pending) = MpoolPending::handle(
424            ctx,
425            (ApiTipsetKey(Some(ts.key().clone())),),
426            &Default::default(),
427        )
428        .await
429        .unwrap();
430        pending
431    }
432
433    /// A tipset that forks away from the pool's own tipset must contribute the messages that are
434    /// neither pending nor already in the pool's tipset.
435    /// <https://github.com/filecoin-project/lotus/blob/27abf0f16a7f2a83305910f3c2a1844764d20b75/node/impl/full/mpool.go#L70>
436    #[tokio::test]
437    async fn merges_messages_of_a_same_height_fork() {
438        let cs = chain_store();
439        let genesis = cs.genesis_tipset();
440        let shared = secp_message(0);
441        let only_in_fork = secp_message(1);
442
443        let mpool_ts = tipset_on(cs.db(), &genesis, 1, &[], std::slice::from_ref(&shared));
444        let fork_ts = tipset_on(cs.db(), &genesis, 2, &[], &[shared, only_in_fork.clone()]);
445
446        let ctx = ctx_on(cs, &mpool_ts);
447        assert_eq!(pending_at(ctx, &fork_ts).await, vec![only_in_fork]);
448    }
449
450    /// Walking back to the pool's own tipset merges the tipsets in between and stops there.
451    #[tokio::test]
452    async fn walks_back_to_the_mpool_tipset() {
453        let cs = chain_store();
454        let genesis = cs.genesis_tipset();
455        let in_mpool_ts = secp_message(0);
456        let in_child = secp_message(1);
457
458        let mpool_ts = tipset_on(
459            cs.db(),
460            &genesis,
461            1,
462            &[],
463            std::slice::from_ref(&in_mpool_ts),
464        );
465        let child_ts = tipset_on(cs.db(), &mpool_ts, 2, &[], std::slice::from_ref(&in_child));
466
467        let ctx = ctx_on(cs, &mpool_ts);
468        assert_eq!(pending_at(ctx, &child_ts).await, vec![in_child]);
469    }
470
471    /// A null round at the pool's height on the requested branch See the null-round note in
472    /// `MpoolPending::handle`.
473    #[tokio::test]
474    async fn merges_across_a_null_round_past_the_mpool_tipset() {
475        let cs = chain_store();
476        let genesis = cs.genesis_tipset();
477        let only_in_ts = secp_message(1);
478
479        // A common ancestor below the pool's height that the walk can descend to and merge.
480        let base = tipset_on(cs.db(), &genesis, 5, &[], &[]);
481        // Pool sits on a fork at epoch 2.
482        let mpool_ts = tipset_on(cs.db(), &base, 1, &[], &[secp_message(0)]);
483        // Requested tipset is at epoch 3 but descends straight from `base` (epoch 1): epoch 2 is a
484        // null round on its branch, so its parent sits below the pool's height.
485        let ts = tipset_at(cs.db(), &base, 3, 3, &[], std::slice::from_ref(&only_in_ts));
486
487        let ctx = ctx_on(cs, &mpool_ts);
488        assert_eq!(pending_at(ctx, &ts).await, vec![only_in_ts]);
489    }
490
491    /// A tipset at or behind the pool's own needs no merge at all.
492    #[tokio::test]
493    async fn does_not_merge_at_or_behind_the_mpool_tipset() {
494        let cs = chain_store();
495        let genesis = cs.genesis_tipset();
496        let mpool_ts = tipset_on(cs.db(), &genesis, 1, &[], &[secp_message(0)]);
497
498        let ctx = ctx_on(cs, &mpool_ts);
499        // The pool's own tipset, then one behind it: both take the early return.
500        assert!(pending_at(ctx.clone(), &mpool_ts).await.is_empty());
501        assert!(pending_at(ctx, &genesis).await.is_empty());
502    }
503
504    /// A BLS message whose signature the pool never cached is skipped, rather than failing the
505    /// whole call.
506    /// <https://github.com/filecoin-project/lotus/blob/27abf0f16a7f2a83305910f3c2a1844764d20b75/chain/messagepool/messagepool.go#L1541>
507    #[tokio::test]
508    async fn skips_bls_messages_with_an_uncached_signature() {
509        let cs = chain_store();
510        let genesis = cs.genesis_tipset();
511        let only_in_fork = secp_message(1);
512
513        let mpool_ts = tipset_on(cs.db(), &genesis, 1, &[], &[]);
514        // The fork carries an unrecoverable BLS message alongside a recoverable secp one.
515        let fork_ts = tipset_on(
516            cs.db(),
517            &genesis,
518            2,
519            // A BLS message whose signature the pool never cached.
520            &[Message {
521                from: Address::new_id(200),
522                to: Address::new_id(201),
523                ..Default::default()
524            }],
525            std::slice::from_ref(&only_in_fork),
526        );
527
528        let ctx = ctx_on(cs, &mpool_ts);
529        assert_eq!(pending_at(ctx, &fork_ts).await, vec![only_in_fork]);
530    }
531}