Architecture Overview
Diagrams
Finite state machine
evm.txes.state
unstarted
|
|
v
in_progress (only one per key)
|
|
v v
fatal_error unconfirmed
| ^
| |
v |
confirmed
eth_tx_attempts.state
in_progress
| ^
| |
v |
broadcast
Data structures
Key:
⚫️ - has never been broadcast to the network
🟠 - may or may not have been broadcast to the network
🔵 - has definitely been broadcast to the network
EB - EthBroadcaster
EC - EthConfirmer
evm.txes has five possible states:
- EB ⚫️
unstarted - EB 🟠
in_progress - EB/EC ⚫️
fatal_error - EB/EC 🔵
unconfirmed - EB/EC 🔵
confirmed
eth_tx_attempts has two possible states:
- EB/EC 🟠
in_progress - EB/EC 🔵
broadcast
An attempt may have 0 or more eth_receipts indicating that the transaction has been mined into a block. This block may or may not exist as part of the canonical longest chain.
Components
BulletproofTxManager is split into three components, each of which has a clearly delineated set of responsibilities.
EthTx
Conceptually, EthTx defines the transaction.
EthTx is responsible for generating the transaction criteria and inserting the initial unstarted row into the evm.txes table.
EthTx guarantees that the transaction is defined with the following criteria:
- From address
- To address
- Encoded payload
- Value (eth)
- Gas limit
Only one transaction may be created per EthTx task.
EthTx should wait until it's transaction confirms before marking the task as completed.
EthBroadcaster
Conceptually, EthBroadcaster assigns a nonce to a transaction and ensures that it is valid. It alone maintains the next usable sequence for a transaction.
EthBroadcaster monitors evm.txes for transactions that need to be broadcast, assigns nonces and ensures that at least one eth node somewhere has placed the transaction into its mempool.
It does not guarantee eventual confirmation!
A whole host of other things can subsequently go wrong such as transactions being evicted from the mempool, eth nodes crashing, netsplits between eth nodes, chain re-orgs etc. Responsibility for ensuring eventual inclusion into the longest chain falls on the shoulders of EthConfirmer.
EthBroadcaster makes the following guarantees:
- A gapless, monotonically increasing sequence of nonces for
evm.txes(scoped to key). - Transition of
evm.txesfromunstartedto eitherfatal_errororunconfirmed. - If final state is
fatal_errorthen the nonce is unassigned, and it is impossible that this transaction could ever be mined into a block. - If final state is
unconfirmedthen a savedeth_transaction_attemptexists. - If final state is
unconfirmedthen an eth node somewhere has accepted this transaction into its mempool at least once.
EthConfirmer must serialize access on a per-key basis since nonce assignment needs to be tightly controlled. Multiple keys can however be processed in parallel. Serialization is enforced with an advisory lock scoped to the key.
EthConfirmer
Conceptually, EthConfirmer adjusts the gas price as necessary to get a transaction mined into a block on the longest chain.
EthConfirmer listens to new heads and performs four separate tasks in sequence every time we become aware of a longer chain.
1. Mark "broadcast before"
When we receive a block we can be sure that any currently unconfirmed transactions were broadcast before this block was received, so we set broadcast_before_block_num on all transaction attempts made since we saw the last block.
It is important to know how long a transaction has been waiting for inclusion, so we can know for how many blocks a transaction has been waiting for inclusion in order to decide if we need to bump gas.
2. Check for receipts
Find all unconfirmed transactions and ask the eth node for a receipt. If there is a receipt, we save it and move this transaction into confirmed state.
3. Bump gas if necessary
Find all unconfirmed transactions where all attempts have remained unconfirmed for more than ETH_GAS_BUMP_THRESHOLD blocks. Create a new eth_transaction_attempt for each, with a higher gas price.
4. Re-org protection
Find all transactions confirmed within the past ETH_FINALITY_DEPTH blocks and verify that they have at least one receipt in the current longest chain. If any do not, then rebroadcast those transactions.
EthConfirmer makes the following guarantees:
- All transactions will eventually be confirmed on the canonical longest chain, unless a reorg occurs that is deeper than
ETH_FINALITY_DEPTHblocks. - In the case that an external wallet used the nonce, we will ensure that a transaction exists at this nonce up to a depth of
ETH_FINALITY_DEPTHblocks but it most likely will not be the transaction in our database.
Note that since checking for inclusion in the longest chain can now be done cheaply, without any calls to the eth node, ETH_FINALITY_DEPTH can be set to something quite large without penalty (e.g. 50 or 100).
EthBroadcaster runs are designed to be serialized. Running it concurrently with itself probably can't get the data into an inconsistent state, but it might hit database conflicts or double-send transactions. Serialization is enforced with an advisory lock.
Head Tracker limitations
The design of EthConfirmer relies on an unbroken chain of heads in our database. If there is a break in the chain of heads, our re-org protection is limited to this break.
For example if we have heads at heights:
1
2
4
Then a reorg that happened at block height 3 or above will not be detected and any transactions mined in those blocks may be left erroneously marked as confirmed.
Currently, the design of the head tracker opens us up to gaps in the head sequence. This can occur in several scenarios:
- CL Node goes offline for more than one or two blocks
- Eth node is behind a load balancer and gets switched out for one that has different block timing
- Websocket connection is broken and resubscribe does not occur right away
For this reason, I propose that follow-up work should be undertaken to ensure that the head tracker has some facility for backfilling heads up toETH_FINALITY_DEPTH.