Skip to main content

Reserve Balance

Summary

In Monad, asynchronous execution means that nodes achieve consensus on a block proposal prior to executing the transactions in that block. Execution is required to be completed in the next k (delay factor) blocks.

Because consensus operates on a k-block delayed view of the global state, it is necessary to adjust the consensus and execution rules slightly to allow consensus to safely build and validate blocks that include only transactions whose gas costs can be paid for.

Monad introduces the Reserve Balance mechanism to allow consensus and execution to collaborate across a multi-block lag to ensure that all EOAs must have enough MON in their account to pay for gas for any transaction included in the blockchain.

The Reserve Balance mechanism places light restrictions on when transactions can be included at consensus time, and imposes some conditions under which transactions will revert at execution time. These are described in greater detail herein, and more formally in Section 6 of the Monad Initial Spec proposal from Category Labs.

The Reserve Balance mechanism is designed to preserve safety under asynchronous execution without interfering with normal usage patterns. Most users and developers need not worry about the Reserve Balance constraints, however we provide the details here for those encountering corner cases.

The Reserve Balance rules also allow Monad to support EIP-7702.

Here is a very brief summary of the rules:

  • Execution time: during execution, transactions revert due to spending of account balance (outside of gas payments) when that account balance dips below a specified reserve balance level. An exception is made for undelegated accounts that have no pending transactions within the past k blocks.
  • Consensus time: When performing block validity checks for block n, consensus queries account balances of transaction senders from execution state as of block n − k and checks that the worst-case balance for each sender after executing blocks n − k + 1 through block n will be non-negative when factoring in execution spending restrictions.
Inflight transaction

Throughout this document, an inflight transaction refers to a transaction that has been included in a block less than k blocks ago, i.e. which is not yet reflected in the delayed state.

Why is reserve balance needed?

Monad has asynchronous execution: consensus is allowed to progress with building and validating blocks without waiting for execution to catch up. Specifically, proposing and validating consensus block n only requires knowledge of the state obtained after applying block n-k, where k is a protocol parameter currently set to 3.

While asynchronous execution has performance benefits, it introduces a novel challenge: how is consensus supposed to know the validity of a block if it does not have the latest state?

Let’s illustrate this challenge with an example (for our examples, we will use k = 3):

Consensus is validating block 4, which contains a transaction t from Alice with the relevant fields as:

sender=Alice, to=Bob, value=100, gas=1

Consensus only has the state that was obtained by executing block 1:

block=1, balances={Alice: 110}

If consensus simply accepts block 4 as valid because Alice appears to have enough balance, it risks a safety failure. For instance, Alice may have already spent her balance in transaction t’ in block 2. This creates a denial-of-service (DoS) vector, as Alice could cause consensus to include many transactions for free.

First attempt at a solution

One idea is for the consensus client to statically inspect transactions in blocks 2 and later, checking if Alice has spent any value in her transactions. This would let consensus reject block 4 as invalid if any transaction before t (such as t') in blocks 2, 3, or 4 originates from Alice and spends some value or gas.

While this is a fine solution on the face of it, it suffers from two shortcomings:

  1. Suppose, as part of smart contract execution in blocks 2 or 3, Alice received a lot of currency. She would have had enough balance to pay for transaction t despite t' existing, if only we had the latest state. So, rejecting transactions based solely on static checks is overly restrictive.

  2. It is not only restrictive, it is also not safe with EIP-7702. With EIP-7702, Alice could have her account delegated to a smart contract, which can transfer out currency from Alice’s account in a way that is not statically inspectable by consensus. Concretely in our example, Alice does not need to send a transaction like t' from her account in order to spend currency from her account, if her account is delegated. A spend could potentially be triggered by a transaction submitted by anyone else. So our static check would not succeed and it may be unsafe to accept block 4 as valid even if we don’t see any other transaction from Alice in blocks 2, 3 and 4.

Reserve balance as the solution

Simple version

Intuitively, the core idea of reserve balance is as follows: if consensus and execution agree ahead of time that, for each EOA, execution will prevent the account balance from dropping below a certain pre-determined threshold known to consensus, then consensus can then safely include transactions whose gas expenditures stay below that threshold, without knowing the latest state and without being vulnerable to the DoS vector described above.

In our example, if execution ensures that Alice’s account cannot be drawn below 10 MON (otherwise, the withdrawing transactions are reverted), then consensus can safely include transaction t, as by definition Alice’s account will have at least 10 MON to pay for transaction t.

This concept can be generalized as follows:

  1. Consensus accepts transactions from user u after the delayed state s as long as the sum of the gas fees for all inflight transactions sent by u is below a parameter called user_reserve_balance.
  2. Execution reverts any transaction that causes an account’s balance to dip below user_reserve_balance, except due to transaction fees.

In Monad, user_reserve_balance is currently set to 10 MON for each EOA.

An additional refinement to improve UX

One criticism of the above rule is that it is difficult for users with balances below the reserve to do anything that requires MON other than for gas fees.

For instance, the following behaviors might be desired, but are currently blocked by the above rule (with user_reserve_balance set to 10 MON):

  • Alice has a balance of 5 MON and wants to send 4.99 MON to Bob (plus pay 0.01 MON in gas)
  • Alice has a balance of 20 MON and wants to swap 18 MON into a memecoin (plus pay 0.01 MON in gas)

To address this, we add some additional conditions where transactions are allowed.

First let's define an "emptying transaction":

Emptying Transaction

An "emptying transaction" is a transaction that (when evaluated at time of execution) could take the balance below the reserve balance.

Notice that if a user account is not EIP-7702-delegated, then consensus can simply inspect transactions statically in order to estimate the lowest a user’s balance can possibly go (since an undelegated user’s account can only be debited due to value transfers and gas fees specified in the transaction data).

Therefore, we add the following rule:

  1. Execution policy: for each undelegated account sender, if a transaction is the first inflight transaction from that sender, and the transaction would have otherwise reverted due to being an "emptying transaction", allow that transaction to proceed anyway.
  2. Consensus policy: for each undelegated account sender, if a transaction is the first inflight transaction from that sender, then statically inspect that transaction's total MON needs (i.e. gas_bid * gas_limit + value), and - if this will end up being an "emptying transaction" - take into account the fact that execution will still allow this transaction through. This means that for any subsequent transactions in the next k blocks, the reserve balance that consensus is working with will be lower.

This rule lets execution allow undelegated accounts to dip below the reserve balance once every k blocks. Since k blocks is 1.2 seconds, this policy should allow most small accounts to still interact with the blockchain normally.

The additional policy allows both of the examples mentioned at the start of this section, as long as they are the first transaction sent by the sender in k blocks.

Full specification

See the reserve balance spec for the formal set of Reserve Balance rules.

Algorithms 1 and 2 implement this check for consensus and execution, respectively.

Algorithm 3 implements the mechanism to detect the dipping into the reserve balance (Algorithm 2 uses Algorithm 3 to revert transactions that dip).

Algorithm 4 specifies the criteria for emptying transactions:

  • The sender account must be undelegated in the prior k blocks. This is checked statically by verifying the account was undelegated in a known state in the past k blocks, and there has been no change in its delegation status in the last k blocks (this can be inspected statically).
  • There must not be another transaction from the same sender in the prior k blocks.

Here is a quick summary of the reserve balance rules at consensus time:

If the account is not delegated and there are no inflight transactions

If the account is not delegated, and there are no previous inflight transactions, then consensus checks that the gas fee for this transaction is less than the balance from the lagged state.

gas_fees(tx)balance\text{gas\_fees}(\text{tx}) \leq \text{balance}

If the account is not delegated and has one emptying inflight transaction

If the account is not delegated, and there is one previous inflight transaction, then consensus has to take into account the inflight transaction's total MON expenditures (including value):

let adjusted_balance=balance(first_tx.value+gas_fees(first_tx))\text{let adjusted\_balance} = \text{balance} - (\text{first\_tx.value} + \text{gas\_fees}(\text{first\_tx})) let reserve=min(user_reserve_balance(t.sender),adjusted_balance)\text{let } \text{reserve} = \min(\text{user\_reserve\_balance}(t.\text{sender}), \text{adjusted\_balance}) \quad

A new transaction can only be included if the sum of all inflight transactions' gas fees (excluding the first one) is less than the reserve:

txI[1:]gas_fees(tx)reserve\sum_{tx \in I[1:]} \text{gas\_fees}(tx) \leq \text{reserve}

All other cases

The reserve is equal to minimum of systemwide reserve balance (10 MON) or the account's balance at block n - k:

reserve=min(user_reserve_balance(t.sender),balance)\text{reserve} = \min(\text{user\_reserve\_balance}(t.\text{sender}), \text{balance})

A new transaction can only be included if the sum of all inflight transactions' gas fees is less than the reserve:

txIgas_fees(tx)reserve\sum_{tx \in I} \text{gas\_fees}(tx) \leq \text{reserve}

Adjusting the reserve balance

The reserve balance is currently the same for every account (10 MON). In a future version, the protocol could allow users, through a stateful precompile, to customize their reserve balance.

Coq proofs

The safety of the reserve balance specification has been formally proved in Coq.

The full proofs documentation is available here.

The consensus check is formalized in Coq as consensusAcceptableTxs. The predicate, consensusAcceptableTxs s ltx, defines the criteria for the consensus module to accept the list of transactions ltx on top of state s.

The proof shows that consensusAcceptableTxs s ltx implies that when the execution module executes all the transactions in ltx one by one on top of s, none of them will fail due to having insufficient balance to cover gas fees. The proof is by induction on the list ltx: one can think of this as doing natural induction on the length of ltx. The proof in the inductive step involves unfolding the definitions of the consensus and execution checks and considering all the cases. In each case, the estimates of effective reserve balance in consensus checks is shown to be conservative with respect to what happens in execution.

Additional examples

To test your understanding, here are some examples along with the expected outcome. Each example is independent.

In the following examples, we use start_block = 2, meaning the initial balances and reserves are after block 1. We also specify the reserve balance parameter for each example, although it is a constant system wide parameter.

For each transaction, the expected result is indicated by a code:

  • 2: Successfully executed
  • 1: Included but reverted during execution (due to reserve balance dip)
  • 0: Excluded by consensus

Example 1: Basic transaction inclusion

Initial state:

Alice: balance = 100, reserve = 10
Bob: balance = 5, reserve = 10

Transactions:

Block 2: [
Alice: send 1 MON, fee 0.05 — Expected: 2
Bob: send 2 MON, fee 0.05 — Expected: 2
]

Final balances:

Alice: 98.95
Bob: 2.95

Example 2: Low reserve balance but high balance

Initial state:

Alice: balance = 100, reserve = 1

Transactions:

Block 2: [
Alice: send 3 MON, fee 2 — Expected: 2 (emptying transaction)
Alice: send 3 MON, fee 2 — Expected: 0 (excluded)
]

Final balance:

Alice: 95.0

Example 3: Multi-block, low reserve but high balance

Initial state:

Alice: balance = 100, reserve = 1

Transactions:

Block 2: [
Alice: send 3 MON, fee 2 — Expected: 2
]
Block 5: [
Alice: send 3 MON, fee 2 — Expected: 2
]

Final balance:

Alice: 90.0

Example 4: Comprehensive

Initial state:

Alice: balance = 100, reserve = 1

Transactions:

Block 2: [
Alice: send 99 MON, fee 0.1 — Expected: 2 (large emptying transaction)
]
Block 3: [
Alice: send 0.5 MON, fee 0.99 — Expected: 0 (excluded)
]
Block 4: [
Alice: send 0.8 MON, fee 0.1 — Expected: 1 (included but reverted)
]
Block 5: [
Alice: send 0 MON, fee 0.9 — Expected: 0 (excluded)
Alice: send 5 MON, fee 0.1 — Expected: 1 (included but reverted)
Alice: send 5 MON, fee 0.8 — Expected: 0 (excluded)
]

Final balance:

Alice: 0.70

Example 5: Edge case — zero value transactions

Initial state:

Alice: balance = 2, reserve = 1

Transactions:

Block 2: [
Alice: send 0 MON, fee 0.5 — Expected: 2
Alice: send 0 MON, fee 0.6 — Expected: 2
Alice: send 0 MON, fee 0.5 — Expected: 0 (exceeds reserve)
]

Final balance:

Alice: 0.9

Example 6: Reserve bBalance boundary

Initial state:

Alice: balance = 10, reserve = 2

Transactions:

Block 2: [
Alice: send 1 MON, fee 2 — Expected: 2 (matches reserve)
Alice: send 0 MON, fee 0.01 — Expected: 2
]

Final balance:

Alice: 6.99