Scheduled Transactions

We are excited to share the following proposal for bringing scheduled transactions to the Monad protocol: any account or contract can schedule a transaction for a future block, with the gas paid upfront and execution guaranteed by the protocol. This removes the dependence on off-chain keepers required for many applications to function properly and enables multiple flows that were awkward or impossible before.

Check out our accompanying blog post, working through five prototypical examples in detail:

  • On-chain keepers/cron jobs: a contract can schedule its next run without an off-chain daemon.
  • Recurring payments: monthly salary payments without needing periodic transactions to be submitted.
  • Long-running/asynchronous computations, i.e., that schedule their own continuation, allowing them to span several blocks.
  • Escrow timeouts: escrowed swap funds can be made to return to their owner if the counterparty misses the deadline.
  • Order-book cleanup: expired orders can be automatically removed at expiry instead of lingering in storage.

We welcome discussion and feedback from the community on all aspects of the design, with particular focus on:

  • If you build on Monad: which of your flows depend on a keeper today, and would anything in the design get in your way?
  • Which of the extensions proposed in Section 13 (range scheduling, the best-effort ASAP mode, time-based scheduling) matter most for what you are building?

Scheduled Transactions

Status: Draft (v1, design and architecture, specification to be finalized)

Authors: Andrei Constantinescu, Lioba Heimbach

Contents

  1. Abstract
  2. Motivation
  3. General Design
  4. Block Assembly
  5. Ordering
  6. Admission Rules
  7. Fee Mechanism
  8. Scheduling Interface
  9. Ownership Model and Escrowing
  10. Storage and Transaction Format
  11. Execution Semantics
  12. Auditing, Tracing and Debugging
  13. Extensions
  14. Appendix

1. Abstract

We introduce scheduled transactions as a mechanism for EOAs, contracts (and delegated EOAs) to schedule transactions ahead of time, enabling many novel flows in DeFi and beyond. Scheduling happens through a schedule(target_block, tx) precompile function. Scheduled transactions are not included in the blocks by consensus. Instead, they are stored in the state tree until their due time and patched into the respective block before the block executes. They appear in the final ETH-style blocks reported by RPCs for tracing, debugging and auditing purposes. In terms of block placement and ordering, they are placed at the end of the respective block and ordered in a first-come-first-served fashion. To ensure all blocks maintain dual capacity: for normal transactions and scheduled transactions alike, blocks are split into two lanes: 90% for normal transactions, and 10% for scheduled ones, the latter being released in steps.

2. Motivation

A meaningful amount of DeFi depends on off-chain keeper infrastructure to function, because the EVM has no native way to make something happen at a future time. Contracts are purely reactive and only execute when poked: perp DEXes run off-chain processes to update funding rates on a cadence, vaults rely on keepers to harvest and rebalance, recurring payments and order expiry are driven by off-chain executors that users have to trust to show up on time. This infrastructure is a hidden trust assumption and a single point of failure.

Keeper liveness and latency variance pose a meaningful risk to both UX and market health. The result is β€œhybrid” DeFi, where the applications require more than just contracts to function.

Proposal: A protocol-level scheduled transaction primitive. Queue a call to execute a transaction at a specific block height, with execution guaranteed by the Monad protocol and gas prepaid at scheduling time. This primitive moves advanced markets, which usually need to compromise with some centralized infrastructure, closer to being fully on-chain and simplifies infrastructure requirements for builders.

Applications: scheduled transactions serve three kinds of flows.

  • On a cadence: on-chain keepers for funding rate updates, vault rebalancing and game loops, as well as recurring payments and vesting payouts set up once by the user.
  • At a chosen block: settlement of auctions, batch auctions, lotteries and DAO votes, options that exercise at expiry, unstaking that completes on its own, token approvals that expire.
  • In response to an earlier action: escrowed funds returned when a counterparty goes silent, order-book cleanup, long-running computations that span across several blocks.

Our blog post works through several of these in detail. Tell us what matters for what you are building, and whether anything in the design would get in your way.

3. General Design

Unlike usual transactions, which get included in blocks by consensus, scheduled transactions are included in blocks as a result of scheduling actions taken by other transactions.

We distinguish:

  • Scheduling transactions β†’ that schedule transactions for later blocks;
  • Scheduled transactions β†’ that get scheduled as a result.

Main Properties:

  • A transaction can be both scheduled and scheduling: scheduled transactions can themselves schedule new transactions.
  • A single transaction can schedule several transactions. They do not have to be statically declared and can be constructed dynamically at execution time.
  • Scheduled transactions are prepaid for at scheduling time.
  • Scheduled transactions are committed to the state tree until their target block comes.
  • Scheduled transactions appear in the final blocks reported by RPCs and facilitate auditing and tracing by providing a causal backlink to the transaction that scheduled them.
  • A special schedule() precompile function is introduced to allow scheduling. It can be called by both EOAs (including delegated ones) and contracts.
  • Scheduled transactions use an ownership model where the caller of the schedule() function is considered to own the respective scheduled transaction, granting the owner cancellation rights over the transaction. The prepaid gas is debited from the owner’s account balance (which notably can be a smart contract account).
  • Scheduled transactions can be cancelled by their owner by calling a cancel() precompile function.

Example flow 1: Alice wants to schedule transaction tx for block 1000. Alice submits a scheduling transaction tx' with <tx, 1000> in its payload. When tx' executes, it attempts to schedule tx for block 1000 by calling schedule(1000, tx), debiting Alice’s account to prepay for tx. If the scheduling succeeds, tx is considered scheduled for block 1000.

Example flow 2: Alice submits tx_1. When tx_1 runs, it schedules tx_2. When tx_2 runs, it schedules tx_3, and so on.

Example flow 3: Alice submits a transaction calling into a contract C. In turn, C calls schedule(..., tx) to schedule a transaction tx. Here, the owner of tx is C (not Alice!), and the prepayment is done from C’s account. Alice cannot cancel tx unless she β€œmakes C do it”.

4. Block Assembly

Transactions scheduled for block number n are appended to the end of block n before the block starts execution. The transactions become part of the externally-visible block/ledger. Consensus is not responsible for including those transactions: they are patched in at the boundary between consensus and execution: either in a translational layer, or as an early step in execution.

5. Ordering

Transactions scheduled for block number n are ordered in first-come-first-served (FIFO) fashion.

Example: Consider two transactions included in the ledger: tx_1 and tx_2, with the former occurring before the latter. Assume:

  • tx_1 schedules tx_S_1 and tx_S_2 for block n in this order;
  • tx_2 schedules tx_S_3 for block n.

Then, at the end of block n, the three scheduled transactions will come in the order tx_S_1, tx_S_2, tx_S_3.

6. Admission Rules

When a transaction tx in block n1 attempts to schedule another transaction for block n2, the system accepts the scheduling request if the following conditions are met:

  • n1 + MIN_WINDOW <= n2 <= n1 + MAX_WINDOW;
  • Block n2 has enough remaining capacity for scheduled transactions (see 6.1 Block Capacity Allocation);
  • The caller can cover the scheduling fee and the transaction’s value upfront; the fee is charged and the value escrowed at scheduling time (see 7. Fee Mechanism and 9. Ownership Model and Escrowing).
  • The transaction’s gas limit covers the intrinsic gas of the scheduled call (21,000 plus the calldata charge, as for any transaction) and does not exceed MAX_SCHEDULED_GAS.

Rationale:

  • MIN_WINDOW is in place for several reasons. For one, scheduling for the same block can create client implementation difficulties: new transactions should not be appended to a block on the fly after it started executing. Moreover, scheduling over too small a time horizon incentivizes sophisticated economic behavior. Last but not least, choosing MIN_WINDOW >= k, where k is Monad’s deferred execution delay, lets the client patch scheduled transactions into block n as soon as consensus has produced it, without waiting for the execution results of blocks n βˆ’ k + 1 to n βˆ’ 1, enabling pipelining. We propose MIN_WINDOW = k.
  • MAX_WINDOW is in place to navigate protocol upgrades. If a later MIP modifies the semantics of scheduled transactions, orchestrating the migration could become challenging with pending transactions scheduled arbitrarily far in the future. Enforcing this upper bound protects the protocol and users alike. Note that this is not a hard constraint for scheduling recurring jobs: they can be scheduled as a transaction that automatically reschedules itself every time it runs, and this is an expected pattern where the scheduled call’s last action is to schedule its own next run.

6.1 Block Capacity Allocation

For a block n with gas target G, scheduled transactions may reserve at most C = 0.10 Γ— G gas. The capacity is released iteratively.

A reservation for g gas is accepted only if: reserved_gas[n] + g <= released_capacity[n], where:

  • reserved_gas[n] is the current amount of gas scheduled for block n;
  • released_capacity[n] is the currently opened-up capacity of the scheduled lane of block n. It ranges from 0 to C.

C is released in K equal steps of S = C / K, one every MAX_WINDOW / K blocks, starting at n - MAX_WINDOW. A scheduled transaction carries at most MAX_SCHEDULED_GAS <= C gas.

Rationale: Releasing the scheduled capacity of a block in steps, rather than all at once, limits griefing. Otherwise an attacker could reserve the entire scheduled lane of a block ahead of time, leaving later transactions no chance to compete. The steps should not be too small either. A reservation for g gas fits only if at least g of the released capacity is still unreserved. If each step releases less than g, an attacker can take up each step as it opens, and a reservation of size g never fits. The guarantee is therefore that transactions up to S can always compete for each released step. Larger transactions, up to MAX_SCHEDULED_GAS, can only be placed where enough released capacity remains unreserved.

7. Fee Mechanism

The two lanes (normal and scheduled) use different pricing mechanisms. The normal lane, for example, uses a base-fee controller. Keeping their capacity budgets separate gives that controller a consistent capacity target, independent of scheduled lane utilization. Below we discuss the fee mechanism for the scheduled lane.

Consider a transaction in block n1 scheduling a transaction comprising g units of gas for block n2. The total amount paid when scheduling the transaction is: scheduled_fee = capacity_fee + storage_fee.

Payment is locked when the reservation is accepted. The capacity fee is charged on reserved g = gas_limit, with no refund for unused gas, consistent with Monad’s current fee mechanism for normal transactions.

A fraction c% of the scheduled_fee is distributed pro rata to all active validators according to their stake and the remaining (100 βˆ’ c)% is burned. The fee is held by the precompile until the transaction fires or is cancelled, to account for possible cancellation refunds (see 8.1 Cancellation). The burned share is burned at that point; the validators’ share may, for performance reasons, be distributed in aggregate (e.g. once per epoch).

Rationale: the fee should not go to a single proposer because proposers have no optionality as to whether to include scheduled transactions. Instead, the distributed rewards go to all validators to cover for their execution costs.

7.1 Capacity Fee

The capacity fee starts from a base rate fixed at booking time, alpha Γ— base_fee(n1) per unit of gas (e.g., alpha = 1.1), scaled by a multiplier that rises as block n2’s scheduled lane fills up. When x units of gas are reserved for n2, the marginal multiplier is multiplier(x) = 1 + (M βˆ’ 1) Γ— x / C.

Example: With M = 10, it rises from 1Γ— when the lane is empty to 10Γ— when it’s full. Utilization is always measured against the final capacity C (not released_capacity[n2]) so the multiplier never decreases (apart from cancellations).

A reservation pays the base rate times the multiplier, summed over every unit of capacity it occupies, [reserved_gas[n2], reserved_gas[n2] + g]. Since the multiplier is linear, this equals g units charged at the multiplier for the midpoint of that range:

capacity_fee = alpha Γ— base_fee(n1) Γ— g Γ— multiplier(reserved_gas[n2] + g/2)

Because the fee depends only on which capacity a reservation occupies, splitting it into consecutive smaller reservations made in the same block gives the same total.

Rationale: Scheduled capacity gets more expensive as it becomes scarce. A user who needs a slot in a busy block can still get one by paying more. The base rate depends on the base fee at scheduling time to prevent scheduling over short time horizons from being less expensive than submitting normal transactions.

7.2 Storage Fee

Storage is charged separately: storage_fee = storage_price_per_byte Γ— tx_size Γ— (n2 - n1)

where storage_price_per_byte is the price of storing one byte for one block, and tx_size is the encoded size of the scheduled transaction in bytes. The fee covers the number of bytes the pending transaction occupies in the state tree and the number of blocks it stays there.

8. Scheduling Interface

Scheduling a transaction is done via calling a new precompile schedule(target_block, tx) function. This can be called by EOAs (whether normal or delegated) and contracts alike. The function either accepts the scheduling request or reverts (see 6. Admission Rules for when a request is rejected):

  1. If the scheduling request is accepted, schedule() returns a handle for the scheduled transaction, which can be used to call getStatus(handle) and cancel(handle);
  2. There is also a function getQuote(target_block, gas_limit, data_size) to check for availability of scheduled gas at a given block height and get a quote for its price before committing to a schedule() call.

The precompile can only be invoked with a plain CALL, plus STATICCALL for its view functions. In particular, it rejects DELEGATECALLs. In the appendix, Section 14.1 contains a proposal of the precompile interface.

8.1 Cancellation

Consider a transaction tx comprising g gas that got scheduled for block n2. When cancel() is called to cancel it in block n1, the call succeeds if the following conditions are met:

A successful cancellation releases the reserved capacity of block n2 and refunds the owner the escrowed value and the following costs:

  • Storage cost: storage_price_per_byte Γ— tx_size Γ— (n2 - n1), i.e., the storage cost for the portion it no longer needs to be stored for.
  • Capacity fee: beta Γ— min_base_fee Γ— g Γ— multiplier(g/2) (e.g., beta = 0.5), i.e., a fraction of the capacity fee at the base fee floor and minimum capacity multiplier for the target block.

Rationale: providing cancellation refunds ensures good state hygiene: users have an incentive to cancel scheduled transactions even when they would have no effect on the current system state. The capacity fee is only partially refunded, and at the lowest possible rate, for two reasons. First, holding a reservation has value in itself: the owner keeps the option to fire or not until shortly before the target block, and the non-refunded part is the price of that option. Second, refunding at the rate originally paid would let users cancel and rebook the same transaction whenever the price drops; refunding at the floor price and the empty-lane multiplier means rebooking can never come out ahead.

9. Ownership Model and Escrowing

Scheduled transactions introduce a special model of transaction ownership. Assume a transaction tx is successfully scheduled. tx’s owner is the address that made the successful schedule() call that scheduled it. The owner is responsible for prepaying for gas at scheduling time from its own account balance and holds the rights to cancel the transaction. Moreover, if the tx does a value transfer, this value is additionally escrowed at scheduling time, and credited back to the owner immediately before the scheduled call runs, outside its call frame, so the owner keeps the value even if the call reverts.

Example: Suppose Alice calls C1, which in turn calls C2. If C2 then successfully calls schedule(target_block, tx), contract C2 becomes the owner of tx, and the gas prepayment at scheduling time is taken from C2’s balance, with tx.value additionally escrowed. It is then only C2 who can, in the future, cancel tx. Cancellation returns the escrowed value and part of the costs.

Rationale: this ownership model enables smart contracts to schedule or cancel transactions in response to processing calls. Transactions scheduled by smart contracts this way cannot be cancelled by EOAs directly, unless going through the smart contracts that scheduled them.

10. Storage and Transaction Format

Successfully scheduled transactions are committed to the state tree (at least) until their time to be included in the block comes. Their representation in the state tree is implementation-defined, generally stored as contract storage at the scheduling precompile’s address. When they are included in a block passed to execution, they materialize as a new EIP-2718 transaction type with the following fields:

  • type = 123 (placeholder)
  • origin (its owner: the address that made the corresponding schedule() call)
  • to (non-nullable)
  • value
  • calldata
  • gas_limit
  • causal_link (id of the transaction that scheduled it, for debugging and auditing purposes)
  • schedule_id (the handle returned by schedule(): sequence number assigned at scheduling time, ensures no hash collisions)

Notably, they have no signature (and hence an explicit origin instead) and no nonce. This is because they need no replay protection: they are created by the system, users cannot send them directly.

The requirement that the to field is non-nullable comes from the semantics of the null case: it corresponds to contract creations. We forbid scheduling such creations because scheduled transactions neither carry nor bump a nonce, so the derived address could collide with the owner’s next deployment. Notably, this is not overly restrictive: contract creations can still be scheduled, but only through contracts using the CREATE or CREATE2 opcodes.

Garbage collection of scheduled transactions from the state tree can be delayed after their firing time to make the output of getStatus() more informative in on-chain querying contexts, but garbage collection must eventually happen.

11. Execution Semantics

Executing a scheduled transaction tx is similar to executing a normal transaction, with small changes to the transaction prologue/validation:

  • There is no signature, so signature validation is skipped;
  • There is no tx.nonce, so nonce validation and the nonce bump on tx.origin are skipped;
  • The gas debit from tx.origin is skipped (it already happened at scheduling time). Accordingly, GASPRICE returns 0 and the receipt reports effectiveGasPrice = 0;
  • The escrowed tx.value is returned to the owner.

Rationale on nonces: This gives better UX when a single EOA wants to use both scheduled transactions and normal transactions. The normal ones require a nonce and perform the nonce bump, while scheduled ones do neither. This keeps the current nonce invariants intact, as nonces only count signed transactions, and scheduled transactions are unsigned.

11.1 ORIGIN, CALLER, and Contracts as Origin

Per EIP-2718, every transaction type must present a CALLER for the first frame and the same address as ORIGIN. Scheduled transactions start executing with CALLER = ORIGIN = origin (the owner that scheduled the transaction), so a transaction scheduled by Alice acts as Alice, and one scheduled by a contract acts as that contract. Contracts that authorize the caller need no change as a result.

Note that origin can be a contract. This is new to the EVM: EIP-3607 rejects transactions whose origin has code (with the exception of delegated EOAs as in EIP-7702). EIP-3607 continues to apply to signed transactions, but is not applied to scheduled ones, since its concern (an address collision letting a user bypass the contract code) does not arise: a scheduled transaction with a contract as origin was issued by that code.

Contracts that gate on CALLER == ORIGIN to exclude programmable callers will see scheduled transactions pass that check. This is not a new exposure: under EIP-7702 a delegated EOA can already call itself and then the gated contract, which sees CALLER = ORIGIN = the EOA while the EOA’s code is what is calling.

12. Auditing, Tracing and Debugging

Scheduled transactions are patched into the execution block and appear in the final executed blocks that RPCs serve. These transactions are part of the transactions_root, receipts_root and the logs bloom filter, etc. They contain the special causal_link unique to their transaction format that allows one to backtrace the reason a certain scheduled transaction was included, useful for debugging and auditing purposes.

13. Extensions

  • Range scheduling: with the proposed design, one can only ask to be scheduled for a specific block height. The scheduling fails if there is no spare capacity at that height. Variants can be added where the caller asks for the earliest or cheapest block in some range [n1, n2] where capacity is still available and gets their transaction committed for that block. Alternatively, it could ask for the earliest block where the given amount of gas fits and the price is lower than a given target. These unlock simpler and cheaper retry logic for application developers (i.e., if n2 - n1 is large enough, scheduling will essentially never fail or be too expensive). It also combats attacks where someone reads a contract’s logic and figures out in which blocks it would try to schedule, buying out the block-space there in advance.
  • Do not escrow value: Currently, the transaction’s value is escrowed when it is scheduled. A possible extension would leave that value in the owner’s account until execution (or better said, assume the owner acquires it in time for execution, reverting if not). However, scheduled transactions would then interact with Monad’s reserve balance.
  • Possible as-soon-as-possible (ASAP) extension: Monad could also offer a cheaper, best-effort scheduling mode: β€œexecute this transaction as soon as there is spare capacity within the range [n1, n2].” Normal transactions and guaranteed scheduled transactions would take priority. If no suitable capacity becomes available before the deadline, the transaction would expire without execution. This helps the Monad protocol leverage spare capacity to do useful work and lets users with non-critical tasks benefit from it at a discount. Pricing, queue admission and block-patching decisions would need a separate design.
  • Possible time-based extension: The proposed design allows scheduling for a specific block height, while time is the prevailing notion in Web2. One would be tempted to measure time in blocks: every block is 300ms, but this does not account for variance in block-time. In the unlikely event of a stall, transactions could still execute late, even if the stall has been long recovered from. Addressing these limitations requires extending the design to allow the target for scheduling to be a time T instead of a block-height h. The scheduled transaction will fire at or after time T. Under good network conditions, this will be soon after T, while during adverse network conditions the system will manage the backlog and prioritize draining it after returning to normal operation. Capacity management and block-patching need adjusting to become queue-based, accounting for scheduled transactions no longer being bound to a block height ahead of time.

14. Appendix

14.1 Possible Precompile Interface

The scheduling interface is exposed as a precompile (i.e., to be thought of as a contract living at a fixed address, but whose implementation is provided by the protocol).

interface IScheduler {
    /// Schedule a call of `to` with `data` to run at block `target_block`.
    /// The CALLER becomes the owner: sole canceller, and the CALLER == ORIGIN
    /// of the eventual execution.
    ///
    /// msg.value must cover the scheduling fee plus `value`:
    ///   - the fee (capacity fee + storage fee, see 7. Fee Mechanism) is held by
    ///     the precompile until the transaction fires or is cancelled;
    ///   - `value` is escrowed and credited back to the owner right before
    ///     the scheduled call runs, so the call cannot fail for lack of funds;
    ///   - any remainder is refunded to the caller immediately.
    /// Reverts if msg.value is insufficient, `target_block` is outside
    /// [current + MIN_WINDOW, current + MAX_WINDOW], the block's released
    /// scheduled capacity cannot fit `gas_limit` or `gas_limit` exceeds 
    /// MAX_SCHEDULED_GAS.
    /// On success the call is guaranteed to run at `target_block` and `handle`
    /// is returned; the same number is carried by the scheduled transaction
    /// as its `schedule_id`.
    function schedule(
        uint64  target_block,
        address to,
        uint256 value,
        uint64  gas_limit,
        bytes   calldata data
    ) external payable returns (uint64 handle);

    /// Cancel a scheduled transaction. Owner-only; allowed while
    /// target_block >= current + MIN_WINDOW. Frees the reserved capacity,
    /// returns the escrowed `value` and refunds the unused portion of the
    /// storage fee plus a fraction of the capacity fee (see 8.1 Cancellation).
    /// Reverts if the caller is not the owner, the entry does not exist, or
    /// the cancellation window has closed.
    function cancel(uint64 handle) external;

    // ── Reads ────────────────────────────────────────────────────────────

    /// Scheduling lifecycle of an entry. Whether a fired transaction succeeded
    /// or reverted is not exposed on-chain: it is read off-chain from its receipt
    /// (e.g., via RPC), which it has like any other transaction.
    enum Status {
        Unknown,    // never existed, or no longer retained
        Pending,    // reserved for its target block, not yet fired or cancelled
        Cancelled,  // cancelled by the owner before firing
        Completed   // fired at its target block (success or revert alike)
    }

    /// Lifecycle state of `handle`. Entries may be reclaimed some time after
    /// firing or cancellation, after which they read `Unknown`.
    function getStatus(uint64 handle) external view returns (Status);

    /// Feasibility and price of scheduling a call of `gas_limit` gas and
    /// `data_size` calldata bytes at `target_block`, as of the current block.
    /// `available` is false when `target_block` is outside the scheduling
    /// window or the block's released scheduled capacity cannot fit
    /// `gas_limit`; `cost` is then zero. Otherwise `cost` is the fee
    /// `schedule()` would charge right now (capacity fee at the lane's
    /// current fill level plus storage fee), excluding `value`.
    function getQuote(uint64 target_block, uint64 gas_limit, uint32 data_size)
        external view returns (bool available, uint256 cost);

    // ── Parameters ───────────────────────────────────────────────────────

    /// Minimum distance, in blocks, from the current block to `target_block`.
    /// `schedule()` requires target_block >= current + MIN_WINDOW, and
    /// `cancel()` is allowed only while target_block >= current + MIN_WINDOW.
    function getMinWindow() external view returns (uint64);

    /// Maximum distance, in blocks, from the current block to `target_block`.
    /// `schedule()` requires target_block <= current + MAX_WINDOW.
    function getMaxWindow() external view returns (uint64);

    /// Maximum `gas_limit` a single scheduled transaction may carry.
    function getMaxScheduledGas() external view returns (uint64);

    // ── Events ───────────────────────────────────────────────────────────

    /// Emitted by the precompile in the receipt of the scheduling transaction.
    event Scheduled(
        uint64  indexed handle,
        address indexed owner,
        uint64  indexed target_block,
        address to,
        uint256 value,
        uint64  gas_limit
    );

    /// Emitted by the precompile in the receipt of the cancelling transaction.
    event Cancelled(uint64 indexed handle, address indexed owner);
}
5 Likes