Table of Contents
Intro
This book will contain code walkthroughs for the most popular L2s. Its main purpose is to act as the main internal knowledge base for L2BEAT, but it can be used by anyone.
Arbitrum
Table of Contents
Intro
TODO
Sequencing
Table of Contents
Forced transactions
Before the implementation of the censorship buffer, each transaction could have been censored by the permissioned sequencer for up to 24h. This was a problem for Orbit L3s on Arbitrum One as, in case of censorship, there wouldn’t be enough time to play the challenge game on the L2 (for the L3) if the challenge period was set to 7d, as around ~60 moves are needed to finish a game, implying the need of at least 60 days.
High-level flow
To force transactions on Arbitrum through L1, the following steps are taken:
- The EOA sends a message to the L2 through the
sendL2MessageFromOriginfunction on theInboxcontract. - The
sendL2MessageFromOriginfunction calls theenqueueDelayedMessagefunction on theBridgecontract, which pushes the message to thedelayedInboxAccsarray. - The EOA waits for
delayBlocksto pass. - The EOA can finally call the
forceInclusionfunction on theSequencerInboxcontract to force the message to be included in the canonical sequence of messages.
Inbox: the sendL2MessageFromOrigin function
This function acts as the entry point to send L1 to L2 messages from a EOA.
function sendL2MessageFromOrigin(
bytes calldata messageData
)
It’s important to note that the function can be gated if the allowListEnabled variable is set to true, which then checks if the tx.origin returns true in the isAllowed mapping. The function only allows calls from EOAs1 and that the length of the message doesn’t exceed the maxDataSize variable, which is supposed to be set to ~90% of geth tx size limit2. The enqueueDelayedMessage function on the Bridge contract is then called by specifying the L2_MSG message type, the L1-to-L2 aliased msg.sender as the sender, and the messageDataHash, which is constructed as the keccak hash of messageData. All message types are defined in the MessageTypes library:
uint8 constant L2_MSG = 3;
uint8 constant L1MessageType_L2FundedByL1 = 7;
uint8 constant L1MessageType_submitRetryableTx = 9;
uint8 constant L1MessageType_ethDeposit = 12;
uint8 constant L2MessageType_unsignedEOATx = 0;
uint8 constant L2MessageType_unsignedContractTx = 1;
uint8 constant ROLLUP_PROTOCOL_EVENT_TYPE = 8;
uint8 constant INITIALIZATION_MSG_TYPE = 11;
Bridge: the enqueueDelayedMessage function
The enqueueDelayedMessage function can only be called by an authorized inbox, as specified in the allowedDelayedInbox mapping.
function enqueueDelayedMessage(
uint8 kind,
address sender,
bytes32 messageDataHash
) external payable returns (uint256)
A messageHash is constructed using the kind (set to L2_MSG when called through the sendL2MessageFromOrigin function), the sender (the L1-to-L2 aliased msg.sender), the messageDataHash, but also the current block number, block timestamp and base fee. This value is then hashed with the latest message hash and pushed to the delayedInboxAccs array. The new message count is returned.
SequencerInbox: the forceInclusion function
The purpose of this function is to be able to force include messages that have been queued to the delayedInboxAccs array.
function forceInclusion(
uint256 _totalDelayedMessagesRead,
uint8 kind,
uint64[2] calldata l1BlockAndTime,
uint256 baseFeeL1,
address sender,
bytes32 messageDataHash
) external
The _totalDelayedMessagesRead value should represent the new amount of delayed messages read after this one, which should be equal to totalDelayedMessagesRead + 1. A function call to isDelayBufferable() checks whether the censorship buffer is active. If not, then each transaction can be delayed up to delayBlocks blocks, usually set to represent 24h. If the buffer is set to active, then its update function is called specifying the block number of the message being forced at the time of its inclusion to the delayedInboxAccs array. This function call overrides the delayBlocks value if the value returned is lower.
After the message hash is checked against the latest accumulated hash in the delayedInboxAccs array, the message is included in the canonical sequence of messages by calling the addSequencerL2BatchImpl function, which ultimately calls the enqueueSequencerMessage function on the Bridge contract.
The DelayBuffer library
The function that handles the core buffer update logic is the calcPendingBuffer function that given the block number of the last processed message (start), the block number of the new message being considered (end), the current buffer size in blocks (bufferBlocks), the buffer threshold (threshold), the time of the last update (sequenced), the max buffer size (max) and the replenish rate (replenishRateInBasis), calculates the new available buffer size.
The intuition is as follows: the buffer is first replenished by calculating the elapsed time between the last processed message and the one being considered during this call, where the rate is usually set to 500/10000, or in other words, such that a block is added to the buffer every 20 blocks between two messages. A delay is calculated as the number of blocks between the last update (not to be confused with the block number of the latest processed message) and the block number of the current message being considered. The buffer doesn’t consider any delay of 150 blocks (i.e. the threshold) or less (≤30 mins, assuming 12s block times) to be “unexpected”, and only the delay on top of it is considered. If the buffer contains more blocks than this value, then the buffer is reduced by the appropriate amount. If not, the threshold value is returned as the minimum buffer size. The buffer is capped at the max value of blocks, usually set to be 14400 blocks, or 2 days assuming 12s block times.3
-
TOCHECK: is this compatible with 7702? A comment says so but it’s worth double checking. ↩
-
TOCHECK: why? ↩
-
TODO: calculate and show worst case censorship within 7 days. Relevant to calculate risks on L3s. ↩
The BoLD proof system
Table of Contents
- High-level overview
- The
RollupUserLogiccontractstakeOnNewAssertionfunctionnewStakeOnNewAssertionfunctionnewStakefunctionconfirmAssertionfunctionreturnOldDepositandreturnOldDepositForfunctionswithdrawStakerFundsfunctionaddToDepositfunctionreduceDepositfunctionremoveWhitelistAfterValidatorAfkfunctionremoveWhitelistAfterForkfunction
- Fast withdrawals
- The
EdgeChallengeManagercontract - [WIP] The
OneStepProofEntrycontract
High-level overview
Each pending assertion is backed by one single stake. A stake on an assertion also counts as a stake for all of its ancestors in the assertions tree. If an assertion has a child made by someone else, its stake can be moved anywhere else since there is already some stake backing it. Implicitly, the stake is tracked to be on the latest assertion a staker is staked on, and the outside logic makes sure that a new assertion can be created only in the proper conditions. In other words, it is made impossible for one actor to be staked on multiple assertions at the same time. If the last assertion of a staker has a child or is confirmed, then the staker is considered “inactive”. If conflicting assertions are created, then one stake amount will be moved to a “loser stake escrow” as the protocol guarantees that only one stake will eventually remain active, and that the other will be slashed. The token used for staking is defined in the stakeToken onchain value.
The RollupUserLogic contract
Calls to the Rollup proxy are forwarded to this contract if the msg.sender is not the designated proxy admin.
stakeOnNewAssertion function
The entry point to propose new state roots, given that the staker is already staked on some other assertion on the same branch, is the stakeOnNewAssertion function in the RollupProxy contract, more specifically in the RollupUserLogic implementation contract.
function stakeOnNewAssertion(
AssertionInputs calldata assertion,
bytes32 expectedAssertionHash
) public onlyValidator(msg.sender) whenNotPaused
The function is gated by the onlyValidator modifier, which checks whether the validator whitelist is disabled, or if the caller is whitelisted. Usage of the whitelist is recommended for all chains without very high amounts of value secured. Realistically, only Arbitrum One will operate without a whitelist.
It is then checked that the caller is staked by querying the _stakerMap mapping, which maps from addresses to Staker struct, defined as:
struct Staker {
uint256 amountStaked;
bytes32 latestStakedAssertion;
uint64 index;
bool isStaked;
address withdrawalAddress;
}
in particular, the isStaked field is checked to be true.
The AssertionInputs struct is defined as:
struct AssertionInputs {
// Additional data used to validate the before state
BeforeStateData beforeStateData;
AssertionState beforeState;
AssertionState afterState;
}
The BeforeStateData struct is defined as:
struct BeforeStateData {
// The assertion hash of the prev of the beforeState(prev)
bytes32 prevPrevAssertionHash;
// The sequencer inbox accumulator asserted by the beforeState(prev)
bytes32 sequencerBatchAcc;
// below are the components of config hash
ConfigData configData;
}
The ConfigData struct is defined as:
struct ConfigData {
bytes32 wasmModuleRoot;
uint256 requiredStake;
address challengeManager;
uint64 confirmPeriodBlocks;
uint64 nextInboxPosition;
}
It is then verified that the amountStaked is at least the required amount. This is checked against the user-supplied requiredStake in the configData of the beforeStateData. The correspondence of the user provided data will be later checked against the one already stored onchain.
The AssertionState struct is defined as:
struct AssertionState {
GlobalState globalState;
MachineStatus machineStatus;
bytes32 endHistoryRoot;
}
An assertion hash (as in the expectedAssertionHash param) is calculated by calling the assertionHash function of the RollupLib library, which takes as input the previous assertion hash, the current state hash and the current sequencer inbox accumulator. In the case of the beforeStateData, the previous assertion hash is the prevPrevAssertionHash, the current state hash is the beforeState hash and the current sequencer inbox accumulator is the sequencerBatchAcc of the beforeStateData. On a high level, this corresponds to hashing the previous state with the current state and the inputs leading from the previous to the current state.
The _assertions mapping maps from assertion hashes to AssertionNode struct, which are defined as:
struct AssertionNode {
// This value starts at zero and is set to a value when the first child is created. After that it is constant until the assertion is destroyed or the owner destroys pending assertions
uint64 firstChildBlock;
// This value starts at zero and is set to a value when the second child is created. After that it is constant until the assertion is destroyed or the owner destroys pending assertions
uint64 secondChildBlock;
// The block number when this assertion was created
uint64 createdAtBlock;
// True if this assertion is the first child of its prev
bool isFirstChild;
// Status of the Assertion
AssertionStatus status;
// A hash of the context available at the time of this assertion's creation. It should contain information that is not specific
// to this assertion, but instead to the environment at the time of creation. This is necessary to store on the assertion
// as this environment can change and we need to know what it was like at the time this assertion was created. An example
// of this is the wasm module root which determines the state transition function on the L2. If the wasm module root
// changes we need to know that previous assertions were made under a different root, so that we can understand that they
// were valid at the time. So when resolving a challenge by one step, the edge challenge manager finds the wasm module root
// that was recorded on the prev of the assertions being disputed and uses it to resolve the one step proof.
bytes32 configHash;
}
The function will check that such previous assertion hash already exists in the _assertions mapping by verifying that the status is different than NoAssertion. The possible statuses are NoAssertion, Pending or Confirmed.
To effectively move their stake, the function then checks that the msg.sender’s last assertion they’re staked on is the previous assertion hash claimed during this call, or that the last assertion they’re staked on has at least one child by checking the firstChildBlock field, meaning that someone else has decided to back the claim with another assertion.
Before creating the new assertion, it is made sure that the config data of the claimed previous state matches the one that is already stored in the _assertions mapping. This is necessary because the assertion hashes do not contain the config data and the previous check does not cover this case. It is then checked that the final machine status is either FINISHED or ERRORED as a sanity check [^2]. The possible machine statuses are RUNNING, FINISHED or ERRORED. An ERRORED state is considered valid because it proves that something went wrong during execution and governance has to intervene to resolve the issue.
Then, the correspondence between the values in assertion and the previous assertion hash is again checked, but this check was confirmed to be redundant as the previous assertion hash is already calculated from the assertion values.
The beforeState’s machineStatus must be FINISHED as it is not possible to advance from an ERRORED state.
The GlobalState struct is defined as:
struct GlobalState {
bytes32[2] bytes32Vals;
uint64[2] u64Vals;
}
where u64Vals[0] represents a inbox position, u64Vals[1] represents a position in message, bytes32Vals[0] represents a block hash, and bytes32Vals[1] represents a send root. It is checked that the position of the afterState is greater than the position of the beforeState, where the position is first checked against the inbox position, and, if equal, against the message position, to verify that the claim processes at least some new messages. It is then verified that the beforeStateData’s nextInboxPosition is greater or equal than the afterState’s inbox position. The nextInboxPosition can be seen as a “target” for the next assertion to process messages up to. If the current assertion didn’t manage to process all messages up to the target, it is considered a “overflow” assertion. It is also checked that the current assertion doesn’t claim to process more messages than currently posted by the sequencer.
The nextInboxPosition is prepared for the next assertion to be either the current sequencer message count (as per bridge.sequencerMessageCount()), or, if the current assertion already processed all messages, to the current sequencer message count plus one. In this way, all assertions are forced to process at least one message, and in this case, the next assertion will process exactly one message before updating the nextInboxPosition again. The afterInboxPosition is then checked to be non-zero. The newAssertionHash is calculated given the previousAssertionHash already checked, the afterState and the sequencerBatchAcc calculated given the afterState’s inbox position in its globalState. It is checked that this calculated hash is equal to the expectedAssertionHash, and that it doesn’t already exist in the _assertions mapping.
The new assertion is then created using the AssertionNodeLib.createAssertion function, which properly constructs the AssertionNode struct. The isFirstChild field is set to true only if the prevAssertion’s firstChildBlock is zero, meaning that there is none. The assertion status will be Pending, the createdAtBlock at the current block number, and the configHash will contain the current onchain wasm module root, the current onchain base stake, the current onchain challenge period length (confirmPeriodBlocks), the current onchain challenge manager contract reference and the nextInboxPosition as previously calculated. It is then saved in the previous assertion that a child has been created, and that the _assertions mapping is updated with the new assertion hash.
The _stakerMap is then updated to store the new latest assertion. If the assertion is not an overflow assertion, i.e. it didn’t process all messages up to the target set by the previous assertion, a minimumAssertionPeriod gets enforced, meaning that validators cannot arbitrarily post assertions at any time of any size.
If the assertion is not a first child, then the stake already present in this contract is transferred to the loserStakeEscrow contract, as only one stake is needed to be ready to be refunded from this contract.
newStakeOnNewAssertion function
This function is used to create a new assertion and stake on it if the staker is not already staked on any assertion on the same branch.
function newStakeOnNewAssertion(
uint256 tokenAmount,
AssertionInputs calldata assertion,
bytes32 expectedAssertionHash,
address _withdrawalAddress
) public
It first checks that the validator is in the whitelist or that the whitelist is disabled, and that it is not already staked. Both the stakerList and the stakerMap mappings are updated with the new staker information. In particular, the latest confirmed assertion is used as the latest staked assertion. Any pending assertion trivially sits on the same branch as this one. After this, the function flow follows the same as the stakeOnNewAssertion function. Finally, the tokens are transferred from the staker to the contract.
An alternative function signature can be found, where the msg.sender is passed as the withdrawal address:
function newStakeOnNewAssertion(
uint256 tokenAmount,
AssertionInputs calldata assertion,
bytes32 expectedAssertionHash
) external
newStake function
This function is used to join the staker set without adding a new assertion.
function newStake(
uint256 tokenAmount,
address _withdrawalAddress
) external whenNotPaused
as above, under the hood, the latest confirmed assertion is used as the latest staked assertion for this staker. The funds are then transferred from the staker to the contract.
confirmAssertion function
The function is used to confirm an assertion and make it available for withdrawals and in general L2 to L1 messages to be executed on L1.
function confirmAssertion(
bytes32 assertionHash,
bytes32 prevAssertionHash,
AssertionState calldata confirmState,
bytes32 winningEdgeId,
ConfigData calldata prevConfig,
bytes32 inboxAcc
) external onlyValidator(msg.sender) whenNotPaused
It is first checked that the challenge period has passed by comparing the current block time, the createdAtBlock value of the assertion to be confirmed and the confirmPeriodBlocks of the config of the previous assertion. The previous assertion must be the latest confirmed assertion, meaning that assertions must be confirmed in order. It is checked whether the previous assertion has only one child or not. If not, it means that a challenge took place, so it is verified that the assertion to be confirmed is the winner. To assert this, a winningEdgeId is provided to fetch an edge from the challengeManager contract, specified again in the config of the previous assertion.
The ChallengeEdge struct is defined as:
struct ChallengeEdge {
/// @notice The origin id is a link from the edge to an edge or assertion at a lower level.
/// Intuitively all edges with the same origin id agree on the information committed to in the origin id
/// For a SmallStep edge the origin id is the 'mutual' id of the length one BigStep edge being claimed by the zero layer ancestors of this edge
/// For a BigStep edge the origin id is the 'mutual' id of the length one Block edge being claimed by the zero layer ancestors of this edge
/// For a Block edge the origin id is the assertion hash of the assertion that is the root of the challenge - all edges in this challenge agree
/// that assertion hash is valid.
/// The purpose of the origin id is to ensure that only edges that agree on a common start position
/// are being compared against one another.
bytes32 originId;
/// @notice A root of all the states in the history up to the startHeight
bytes32 startHistoryRoot;
/// @notice The height of the start history root
uint256 startHeight;
/// @notice A root of all the states in the history up to the endHeight. Since endHeight > startHeight, the startHistoryRoot must
/// commit to a prefix of the states committed to by the endHistoryRoot
bytes32 endHistoryRoot;
/// @notice The height of the end history root
uint256 endHeight;
/// @notice Edges can be bisected into two children. If this edge has been bisected the id of the
/// lower child is populated here, until that time this value is 0. The lower child has startHistoryRoot and startHeight
/// equal to this edge, but endHistoryRoot and endHeight equal to some prefix of the endHistoryRoot of this edge
bytes32 lowerChildId;
/// @notice Edges can be bisected into two children. If this edge has been bisected the id of the
/// upper child is populated here, until that time this value is 0. The upper child has startHistoryRoot and startHeight
/// equal to some prefix of the endHistoryRoot of this edge, and endHistoryRoot and endHeight equal to this edge
bytes32 upperChildId;
/// @notice The edge or assertion in the upper level that this edge claims to be true.
/// Only populated on zero layer edges
bytes32 claimId;
/// @notice The entity that supplied a mini-stake accompanying this edge
/// Only populated on zero layer edges
address staker;
/// @notice The block number when this edge was created
uint64 createdAtBlock;
/// @notice The block number at which this edge was confirmed
/// Zero if not confirmed
uint64 confirmedAtBlock;
/// @notice Current status of this edge. All edges are created Pending, and may be updated to Confirmed
/// Once Confirmed they cannot transition back to Pending
EdgeStatus status;
/// @notice The level of this edge.
/// Level 0 is type Block
/// Last level (defined by NUM_BIGSTEP_LEVEL + 1) is type SmallStep
/// All levels in between are of type BigStep
uint8 level;
/// @notice Set to true when the staker has been refunded. Can only be set to true if the status is Confirmed
/// and the staker is non zero.
bool refunded;
/// @notice TODO
uint64 totalTimeUnrivaledCache;
}
where EdgeStatus can either be Pending or Confirmed.
In particular, the claimId is checked to be the assertion hash to be confirmed, the status has to be Confirmed and the confirmedAtBlock value should not be zero. On top of the challenge period, it is required that the confirmedAtBlock value is at least challengeGracePeriodBlocks old, with the purpose of being able to recover in case an invalid assertion is confirmed because of a bug.
The current assertion is checked to be Pending, as opposed to NoAssertion or Confirmed. An external call to the Outbox is made by passing the sendRoot and blockHash saved in the current assertion’s globalState. Finally, the _latestConfirmed assertion is updated with the current one and the status is updated to Confirmed.
returnOldDeposit and returnOldDepositFor functions
This function is used to initiate a refund of the staker’s deposit when its latest assertion either has a child or is confirmed.
function returnOldDeposit() external override onlyValidator(msg.sender) whenNotPaused
function returnOldDepositFor(
address stakerAddress
) external override onlyValidator(stakerAddress) whenNotPaused
In the first case, it is checked that the msg.sender is the validator itself, while in the second case that the sender is the designated withdrawal address for the staker. Then it is verified that the staker is actively staked, and that it is “inactive”. A staker is defined as inactive when their latest assertion is either confirmed or has at least one child, meaning that there is some other stake backing it.
At this point the _withdrawableFunds mapping value is increased by the staker’s deposit for its withdrawal address, as well as the totalWithdrawableFunds value. The staker is then deleted from the _stakerList and _stakerMap mappings. The funds are not actually transferred at this point.
withdrawStakerFunds function
This function is used to finalize the withdrawal of uncommitted funds from this contract to the msg.sender.
function withdrawStakerFunds() external override whenNotPaused returns (uint256)
This is done by checking the _withdrawableFunds mapping, which maps from addresses to uint256 amounts. The mapping is then set to zero, and the totalWithdrawableFunds value is updated accordingly. Finally, the funds are transferred to the msg.sender.
addToDeposit function
This function is used to add funds to the staker’s deposit.
function addToDeposit(
address stakerAddress,
address expectedWithdrawalAddress,
uint256 tokenAmount
) external whenNotPaused
The staker is supposed to be already staked when calling this function. In particular, the amountStaked is increased by the amount sent.
reduceDeposit function
This function is used to reduce the staker’s deposit.
function reduceDeposit(
uint256 target
) external onlyValidator(msg.sender) whenNotPaused
The staker is required to be inactive. The difference between the current deposit and the target is then added to the amount of withdrawable funds.
removeWhitelistAfterValidatorAfk function
If a whitelist is enabled, the system allows for its removal if all validators are inactive for a certain amount of time. The function checks whether the latest confirmed assertion, or its first child if present, is older than validatorAfkBlocks. If the validatorAfkBlocks onchain value is set to 0, this mechanism is disabled.
function removeWhitelistAfterValidatorAfk() external
If the validatorAfkBlocks is set to be greater than the challenge period (or more precisely, two times the challenge period in the worst case), then the child will be confirmed (if valid) before being used for the calculation. The first child check is likely used in case the validatorAfkBlocks is set to be smaller than the challenge period.
It’s important to note that this function is quite different from its pre-BoLD version.
There is an edge case in case the minimumAssertionPeriod is set lower than the difference between the challenge period and the validatorAfkBlocks, where the whitelist gets removed no matter what.
Under standard deployments, the validatorAfkBlocks value is set to be around twice the maximum delay caused by the challenge protocol, which is two times the challenge period.
removeWhitelistAfterFork function
This function is used to remove the whitelist in case the chain id of the underlying chain changes.
function removeWhitelistAfterFork() external
It simply checks that the deploymentTimeChainId, which is stored onchain, matches the block.chainId value.
Fast withdrawals
Fast withdrawals is a feature introduced in nitro-contracts v2.1.0 for AnyTrust chains. It allows to specify a anyTrustFastConfirmer address that can propose and confirm assertions without waiting for the challenge period to pass.
fastConfirmAssertion and fastConfirmNewAssertion functions
To immediately confirm an already proposed assertion, the fastConfirmAssertion function is used in the RollupUserLogic contract:
function fastConfirmAssertion(
bytes32 assertionHash,
bytes32 parentAssertionHash,
AssertionState calldata confirmState,
bytes32 inboxAcc
) public whenNotPaused
the function checks that the msg.sender is the anyTrustFastConfirmer address, and that the assertion is pending. The assertion is then confirmed as in the confirmAssertion function.
The anyTrustFastConfirmer is also allowed to propose new assertions without staker checks, and also immediately confirm such assertions. To do so, the fastConfirmNewAssertion function is used:
function fastConfirmNewAssertion(
AssertionInputs calldata assertion,
bytes32 expectedAssertionHash
) external whenNotPaused
Both functions, in practice, act very similar to the admin-gated forceCreateAssertion and forceConfirmAssertion functions in the RollupAdminLogic contract, see Admin operations for more details.
The EdgeChallengeManager contract
This contract implements the challenge protocol for the BoLD proof system.
createLayerZeroEdge function
This function is used to initiate a challenge between sibling assertions. All “layer zero” edges have a starting “height” of zero and a starting “length” of one.
function createLayerZeroEdge(
CreateEdgeArgs calldata args
) external returns (bytes32)
The CreateEdgeArgs struct is defined as:
struct CreateEdgeArgs {
/// @notice The level of edge to be created. Challenges are decomposed into multiple levels.
/// The first (level 0) being of type Block, followed by n (set by NUM_BIGSTEP_LEVEL) levels of type BigStep, and finally
/// followed by a single level of type SmallStep. Each level is bisected until an edge
/// of length one is reached before proceeding to the next level. The first edge in each level (the layer zero edge)
/// makes a claim about an assertion or assertion in the lower level.
/// Finally in the last level, a SmallStep edge is added that claims a lower level length one BigStep edge, and these
/// SmallStep edges are bisected until they reach length one. A length one small step edge
/// can then be directly executed using a one-step proof.
uint8 level;
/// @notice The end history root of the edge to be created
bytes32 endHistoryRoot;
/// @notice The end height of the edge to be created.
/// @dev End height is deterministic for different levels but supplying it here gives the
/// caller a bit of extra security that they are supplying data for the correct level of edge
uint256 endHeight;
/// @notice The edge, or assertion, that is being claimed correct by the newly created edge.
bytes32 claimId;
/// @notice Proof that the start history root commits to a prefix of the states that
/// end history root commits to
bytes prefixProof;
/// @notice Edge type specific data
/// For Block type edges this is the abi encoding of:
/// bytes32[]: Inclusion proof - proof to show that the end state is the last state in the end history root
/// AssertionStateData: the before state of the edge
/// AssertionStateData: the after state of the edge
/// bytes32 predecessorId: id of the prev assertion
/// bytes32 inboxAcc: the inbox accumulator of the assertion
/// For BigStep and SmallStep edges this is the abi encoding of:
/// bytes32: Start state - first state the edge commits to
/// bytes32: End state - last state the edge commits to
/// bytes32[]: Claim start inclusion proof - proof to show the start state is the first state in the claim edge
/// bytes32[]: Claim end inclusion proof - proof to show the end state is the last state in the claim edge
/// bytes32[]: Inclusion proof - proof to show that the end state is the last state in the end history root
bytes proof;
}
In practice, the number of levels is usually set to be 3, with NUM_BIGSTEP_LEVEL set to 1. The claimId corresponds to an assertion hash.
If a whitelist is enabled in the system being validated, then the msg.sender must be whitelisted. The whitelist is referenced through the assertionChain onchain value. The type of the edge is fetched based on the level: if 0 then the type is Block, if 1 then the type is BigStep, if 2 then the type is SmallStep. This section will first discuss layer zero edges of type Block.
Block-level layer zero edges
If the edge is of type Block, then the proof field is decoded to fetch two AssertionStateData structs, one for the predecessorStateData and the other for the claimStateData.
The AssertionStateData struct is defined as:
struct AssertionStateData {
/// @notice An execution state
AssertionState assertionState;
/// @notice assertion Hash of the prev assertion
bytes32 prevAssertionHash;
/// @notice Inbox accumulator of the assertion
bytes32 inboxAcc;
}
It is checked that the claimStateData produces the same hash as the claimId, and that the predecessorStateData produces the same hash as the claimStateData’s prevAssertionHash. It is then checked the provided endHistoryRoot matches the one in the claimStateData’s assertionState.
The claimStateData’s previousAssertionHash should be seen as a link to the information rivals agree on, which corresponds to the predecessorStateData.
An AssertionReferenceData struct is created, which is defined as:
struct AssertionReferenceData {
/// @notice The id of the assertion - will be used in a sanity check
bytes32 assertionHash;
/// @notice The predecessor of the assertion
bytes32 predecessorId;
/// @notice Is the assertion pending
bool isPending;
/// @notice Does the assertion have a sibling
bool hasSibling;
/// @notice The execution state of the predecessor assertion
AssertionState startState;
/// @notice The execution state of the assertion being claimed
AssertionState endState;
}
which is instantiated in the following way:
ard = AssertionReferenceData(
args.claimId,
claimStateData.prevAssertionHash,
assertionChain.isPending(args.claimId),
assertionChain.getSecondChildCreationBlock(claimStateData.prevAssertionHash) > 0,
predecessorStateData.assertionState,
claimStateData.assertionState
)
The assertion must be Pending for its edge to be created and it has to have a rival, i.e. a sibling. It is checked that both the machineStatus of the startState and endState is not RUNNING.
The proof is then decoded to fetch an inclusionProof. Hashes of both the startState and endState are computed. The startHistoryRoot is computed just by appending the startState hash to an empty merkle tree, as it is the initial state of a layer zero node. It is checked that the endState hash is included in the endHistoryRoot using the inclusionProof. The position of such hash is saved in the LAYERZERO_BLOCKEDGE_HEIGHT constant. Then it is checked that the previously computed startHistoryRoot is a prefix of endHistoryRoot by using the prefixProof.
Finally, a ChallengeEdge is created using the endState’s prevAssertionHash as the originId, the startHistoryRoot computed before, a startHeight of zero, the endHistoryRoot provided, the proper endHeight, the claimId, the msg.sender as the staker, the appropriate level, the status is set to Pending, the createdAtBlock is set to the current block number, and the confirmedAtBlock, lowerChildId, upperChildId fields are initialized to zero and the refunded field is set to false.
If the whitelist is enabled, then it is checked that a single party cannot create two layer zero edges that rival each other. If the whitelist is disabled with check is not effective as an attacker can simply use a different address.
The edge is then added to the onchain EdgeStore store after it is checked that it doesn’t exist already. The mutualId is calculated, which identifies all rival edges. If there is no rival, the edge is saved as UNRIVALED (representing a dummy edge id) in the firstRivals mapping, otherwise the current edge is saved into it.
Finally, a stake is requested to be sent to this address if there are no rivals, or to the excessStakeReceiver otherwise, which corresponds to the loserStakeEscrow contract. It is important to note that for the Block level, the stake is set to zero, while for the other levels it is set to be some fractions of the bond needed to propose an assertion.
Non-block-level layer zero edges
If the edge is not of type Block, it means that an assertion on a lower level is being proposed, and it must link to an assertion of lower level (with Block being the lowest one). It is possible to create a non-Block level layer zero edge only if the lower level edge is of length one and is rivaled. It is checked that such edge is also Pending and that the level is just one lower the one being proposed.
The proof is then decoded in the following manner:
(
bytes32 startState,
bytes32 endState,
bytes32[] memory claimStartInclusionProof,
bytes32[] memory claimEndInclusionProof,
bytes32[] memory edgeInclusionProof
) = abi.decode(args.proof, (bytes32, bytes32, bytes32[], bytes32[], bytes32[]));
It is verified that the startState is part of the startHistoryRoot of the lower level edge and that the endState is part of the endHistoryRoot of the lower level edge, so that the current edge can be considered a more fine grained version of the lower level edge. It’s important to note that it is still possible to propose an invalid higher-level edge for a valid lower-level edge, so it must be possible to propose multiple higher-level edges for the same lower-level edge.
The rest of the checks follow the same as the Block level edges, starting from the creation of the startHistoryRoot as a length one merkle tree, followed by the check that the endState is included in the endHistoryRoot using the edgeInclusionProof, and so on.
bisectEdge function
This function is used to bisect edges into two children to break down the dispute process into smaller steps. No new stake as any new edge is checked against the history root of the parent edge.
function bisectEdge(
bytes32 edgeId,
bytes32 bisectionHistoryRoot,
bytes calldata prefixProof
) external returns (bytes32, bytes32)
It is checked that the edge being bisected is still Pending and that it is rivaled. It is then verified that the bisectionHistoryRoot is a prefix of the endHistoryRoot of the edge being bisected.
Then both the lower and upper children are created, using the startHistoryRoot and bisectionHistoryRoot for the lower child, and bisectionHistoryRoot and endHistoryRoot root for the upper child. The children are then saved for the parent edge under the lowerChildId and upperChildId fields.
confirmEdgeByOneStepProof function
This function is used to confirm an edge of length one with a one-step proof.
function confirmEdgeByOneStepProof(
bytes32 edgeId,
OneStepData calldata oneStepData,
ConfigData calldata prevConfig,
bytes32[] calldata beforeHistoryInclusionProof,
bytes32[] calldata afterHistoryInclusionProof
) public
the function builds an ExecutionContext struct, which is defined as:
struct ExecutionContext {
uint256 maxInboxMessagesRead;
IBridge bridge;
bytes32 initialWasmModuleRoot;
}
where the maxInboxMessagesRead is filled with the nextInboxPosition of the config of the previous assertion, the bridge reference is taken from assertionChain, and the initialWasmModuleRoot is again taken from the config of the previous assertion. It is checked that the edge exists, that its type is SmallStep, and that its length is one.
Then the appropriate data to pass to the oneStepProofEntry contract for the onchain one step execution is prepared. In particular, the machine step corresponding to the start height of this edge is computed. Machine steps reset to zero with new blocks, so there’s no need to fetch the corresponding Block level edge. The machine step of a SmallStep edge corresponds to its startHeight plus the startHeight of its BigStep edge. Previous level edges are fetched through the originId field stored in each edge and the firstRivals mapping. It is necessary to go through the firstRivals mapping as the originId stores a mutual id of the edge and not an edge id, which is needed to fetch the startHeight.
It is made sure that the beforeHash inside oneStepData is included in the startHistoryRoot at position machineStep. The OneStepData struct is defined as:
struct OneStepData {
/// @notice The hash of the state that's being executed from
bytes32 beforeHash;
/// @notice Proof data to accompany the execution context
bytes proof;
}
The oneStepProofEntry.proveOneStep function is then called passing the execution context, the machine step, the beforeHash and the proof to calculate the afterHash. It is then checked that the afterHashis included in the endHistoryRoot at position machineStep + 1.
Finally, the edge status is updated to Confirmed, and the confirmedAtBlock is set to the current block number. Moreover, it is checked that no other rival is already confirmed through the confirmedRivals mapping inside the store, and if not the edge is saved there under its mutual id.
confirmEdgeByTime function
This function is used to confirm an edge when enough time has passed, i.e. one challenge period on the player’s clock.
function confirmEdgeByTime(bytes32 edgeId, AssertionStateData calldata claimStateData) public
Only layer zero edges can be confirmed by time.
If the edge is block-level and the claim is the first child of its predecessor, then the time between its assertion and the second child’s assertion is counted towards this edge. If this was not done, then the timer wouldn’t count the time from when the assertion is created but it would need to wait it to be challenged, which is absurd.
If the edge is unrivaled, then the time between the current block number and its creation is counted. If the edge is rivaled, and it was created before the rival, then the time between the rival’s creation and this edge’s creation is counted. If the edge is rivaled and it was created after the rival, then no time is counted.
If the edge has been bisected, i.e. it has children, then the minimum children unrivaled time is counted. The rationale is that if a child is correct but the parent is not, it would be incorrect to count the unrivaled time of the correct child towards the parent. If the honest party acts as fast as possible, then an incorrect claim’s unrivaled time would always be close to zero. If an edge is confirmed by a one step proof, then it’s unrivaled time is set to infinity (in practice type(uint64).max).
Finally, if the total time unrivaled is greater than the challenge period (expressed with confirmationThresholdBlock), then the edge is confirmed. Note that this value is a different variable compared to the confirmPeriodBlocks in the RollupProxy contract, which determines when an assertion can be confirmed if not challenged.
The way that timers across different levels affect each other is explained in the following section.
updateTimerCacheByClaim function
This function is used to update the timer cache with direct level inheritance.
function updateTimerCacheByClaim(
bytes32 edgeId,
bytes32 claimingEdgeId,
uint256 maximumCachedTime
) public
First, the total time unrivaled without level inheritance is calculated as explained in the confirmEdgeByTime function. It is then checked that the provided claimingEdgeId’s claimId corresponds to the edgeId. The claimingEdgeId unrivaled time is then added to the time unrivaled without level inheritance, and the edge unrivaled time is updated to this value only if it is greater than the current value.
Note that this effectively acts as taking the max unrivaled time of the children edges on the higher level, as any of them can be used to update the parent edge’s timer cache. The rationale is that at least one correct corresponding higher-level edge is needed to confirm the parent edge in the lower level.
updateTimerCacheByChildren function
This function is used to update the timer cache without direct level inheritance.
function updateTimerCacheByChildren(bytes32 edgeId, uint256 maximumCachedTime) public
[WIP] The OneStepProofEntry contract
This contract is used as the entry point to execute one-step proofs onchain.
proveOneStep function
This function is called from the confirmEdgeByOneStepProof function in the EdgeChallengeManager contract.
function proveOneStep(
ExecutionContext calldata execCtx,
uint256 machineStep,
bytes32 beforeHash,
bytes calldata proof
) external view returns (bytes32 afterHash)
Admin operations
Table of Contents
- The
RollupAdminLogiccontractsetChallengeManagerfunctionsetValidatorWhitelistDisabledfunctionsetInboxfunctionsetSequencerInboxfunctionsetDelayedInboxfunctionsetOutboxfunctionremoveOldOutboxfunctionsetWasmModuleRootfunctionsetLoserStakeEscrowfunctionforceConfirmAssertionfunctionforceCreateAssertionfunctionforceRefundStakerfunctionsetBaseStakefunctionsetConfirmPeriodBlocksfunctionsetValidatorAfkBlocksfunctionsetMinimumAssertionPeriodfunctionsetOwnerfunctionsetValidatorfunctionpauseandresumefunctionssetAnyTrustFastConfirmerfunction
The RollupAdminLogic contract
Calls to the Rollup proxy are forwarded to this contract if the msg.sender is the designated proxy admin.
setChallengeManager function
This function allows the proxy admin to update the challenge manager contract reference.
function setChallengeManager(
address _challengeManager
) external
The challenge manager contract is used to determine whether an assertion can be considered a winner or not when attempting to confirm it.
setValidatorWhitelistDisabled function
This function allows the proxy admin to disable the validator whitelist.
function setValidatorWhitelistDisabled(
bool _validatorWhitelistDisabled
) external
If the whitelist is enabled, only whitelisted validators can join the staker set and therefore propose new assertions.
setInbox function
This function allows the proxy admin to update the inbox contract reference.1
function setInbox(
IInboxBase newInbox
) external
setSequencerInbox function
This function allows the proxy admin to update the sequencer inbox contract reference.
function setSequencerInbox(
address _sequencerInbox
) external override
The call is forwarded to the bridge contract, specifically by calling its setSequencerInbox function. The bridge will only accept messages to be enqueued in the main sequencerInboxAccs array if the call comes from the sequencerInbox. The sequencerInboxAccs is read when creating new assertions, in particular when assigning the nextInboxPosition to the new assertion and when checking that the currently considered assertion doesn’t claim to have processed more messages than actually posted by the sequencer.
setDelayedInbox function
This function allows the proxy admin to activate or deactivate a delayed inbox.
function setDelayedInbox(address _inbox, bool _enabled) external override
The call is forwarded to the bridge contract, specifically by calling its setDelayedInbox function. The bridge contract will only accept messages to be enqueued in the delayed inbox if the call comes from an authorized inbox.
setOutbox function
This function allows the proxy admin to update the outbox contract reference.
function setOutbox(
IOutbox _outbox
) external override
The outbox contract is used to send messages from L2 to L1. The call is forwarded to the bridge contract, specifically by calling its setOutbox function.
removeOldOutbox function
This function allows the proxy admin to remove an old outbox contract reference.
function removeOldOutbox(
address _outbox
) external override
The call is forwarded to the bridge contract, specifically by calling its setOutbox function.
setWasmModuleRoot function
This function allows the proxy admin to update the wasm module root, which represents the offchain program being verified by the proof system.
function setWasmModuleRoot(
bytes32 newWasmModuleRoot
) external override
The wasmModuleRoot is included in each assertion’s configData.
setLoserStakeEscrow function
This function allows the proxy admin to update the loser stake escrow contract reference.
function setLoserStakeEscrow(
address newLoserStakerEscrow
) external override
The loser stake escrow is used to store the excess stake when a conflicting assertion is created.
forceConfirmAssertion function
This function allows the proxy admin to confirm an assertion without waiting for the challenge period, and without most validation of the assertions.
function forceConfirmAssertion(
bytes32 assertionHash,
bytes32 parentAssertionHash,
AssertionState calldata confirmState,
bytes32 inboxAcc
) external override whenPaused
The function can only be called when the contract is paused. It is only checked that the assertion is Pending.
forceCreateAssertion function
This function allows the proxy admin to create a new assertion by skipping some of the validation checks.
function forceCreateAssertion(
bytes32 prevAssertionHash,
AssertionInputs calldata assertion,
bytes32 expectedAssertionHash
) external override whenPaused
The function can only be called when the contract is paused. It skips all checks related to staking, the check that the previous assertion exists and that the minimumAssertionPeriod has passed. Since the configHash of the previous assertion is fetched from the _assertions mapping, and the current assertion’s configData in its beforeStateData is still checked against it, then this effectively acts as an existence check.
A comment in the function suggest a possible emergency procedure during which this function might be used:
// To update the wasm module root in the case of a bug:
// 0. pause the contract
// 1. update the wasm module root in the contract
// 2. update the config hash of the assertion after which you wish to use the new wasm module root (functionality not written yet)
// 3. force refund the stake of the current leaf assertion(s)
// 4. create a new assertion using the assertion with the updated config has as a prev
// 5. force confirm it - this is necessary to set latestConfirmed on the correct line
// 6. unpause the contract
forceRefundStaker function
This function allows the proxy admin to forcefully trigger refunds of stakers’ deposit, bypassing the msg.sender checks.
function forceRefundStaker(
address[] calldata staker
)
The function still checks that each staker is inactive before triggering the refund.
setBaseStake function
This function allows the proxy admin to update required stake to join the staker set and propose new assertions.
function setBaseStake(
uint256 newBaseStake
) external override
The function currently only allows to increase the base stake, not to decrease it, as an attacker might be able to steal honest funds from the contract.
setConfirmPeriodBlocks function
This function allows the proxy admin to update the challenge period length.
function setConfirmPeriodBlocks(
uint64 newConfirmPeriod
) external override
the function just checks that the new value is greater than zero.
setValidatorAfkBlocks function
This function allows the proxy admin to update the period after which the whitelist is removed if all validators are inactive.
function setValidatorAfkBlocks(
uint64 newAfkBlocks
) external override
setMinimumAssertionPeriod function
This function allows the proxy admin to set the minimum time between two non-overflow assertions.
function setMinimumAssertionPeriod(
uint64 newPeriod
) external override
setOwner function
This function allows the proxy admin to update the admin itself.
function setOwner(
address newOwner
) external override
it internally calls the _changeAdmin function.
setValidator function
This function allows the proxy admin to add or remove validators from the whitelist.
function setValidator(address[] calldata _validator, bool[] calldata _val) external override
pause and resume functions
These functions allow the proxy admin to pause and resume the contract.
function pause() external override
function resume() external override
setAnyTrustFastConfirmer function
This function allows the proxy admin to set a fast confirmer that can confirm assertions without waiting for the challenge period and propose new assertions without staking.
function setAnyTrustFastConfirmer(
address _anyTrustFastConfirmer
) external
-
TODO: explain what it is and why it is referenced here. ↩
Optimism
Table of Contents
Intro
TODO
Table of Contents
Scroll
TODO: general scroll intro
Table of Contents
Sequencing
Scroll L2 operates a centralized sequencer that accepts transactions and generates new L2 blocks. The Sequencer exposes a JSON-RPC interface for accepting L2 transactions, and is built on a fork of Geth.
Until the Euclid upgrade (April, 2025), Scroll L2 nodes maintained Clique, a Proof-of-Authority consensus with the L2 Sequencer set as authorized signer for block production. Since then, the L2 nodes read the authorized unsafe block signer from the new SystemConfig contract on L1.
The block time is set at 3 seconds and maintained on a best-effort basis, not enforced by the protocol.
Forced transactions
Messages appended to the message queue (L1MessageQueueV2) are expected to be included into a bundle by the centralized operator. Messages in the queue cannot be skipped or dropped, but the sequencer can choose to finalize a bundle without processing any queued messages. Should a permissioned sequencer not process any queued messages within the SystemConfig.maxDelayMessageQueue, anyone can include queue messages as committing and finalizing bundles becomes permissionless.
High-level flow
To force transactions on Scroll through L1, the following steps are taken:
- The EOA sends a message to the L2 through the
sendTransactionfunction on theEnforcedTxGatewaycontract. - The
sendTransactionfunction calls theappendEnforcedTransactionfunction on theL1MessageQueuecontract, which pushes the message to the queue through themessageRollingHashes(uint256 => bytes32, messageIndex => timestamp-rollingHash) mapping. - At each finalization (
finalizeBundlePostEuclidV2) the number of messages processed in the bundle (totalL1MessagesPoppedOverall) is passed as input - In the internal
_finalizeBundlePostEuclidV2function, themessageQueueHashis computed up to thetotalL1MessagesPoppedOverall - 1queue index - The
messageQueueHashis passed a public input to the verifier.
Should messages not be processed by the permissioned sequencer, the EOA waits for either:
SystemConfig.maxDelayEnterEnforcedModeto pass since the last batch finalization, orSystemConfig.maxDelayMessageQueueto pass since the first unfinalized message enqueue time. Then the EOA can finally submit a batch viacommitAndFinalizeBatchand at the same time activate the permissionless sequencing mode (UpdateEnforcedBatchMode).
EnforcedTxGateway: the sendTransaction function
This function acts as the entry point to send L1 to L2 messages from an EOA. There are two variants:
function sendTransaction(
address _target,
uint256 _value,
uint256 _gasLimit,
bytes calldata _data
)
function sendTransaction(
address _sender,
address _target,
uint256 _value,
uint256 _gasLimit,
bytes calldata _data,
uint256 _deadline,
bytes memory _signature,
address _refundAddress
)
The first variant is for direct calls, while the second allows for signed messages. Both functions validate that the caller is not paused and charge a fee based on the gas limit. For contract callers, L1-to-L2 address aliasing is applied. For EOAs and EIP-7702 delegated EOAs, the original address is used. The functions ultimately call appendEnforcedTransaction on the L1MessageQueueV2 contract.
L1MessageQueueV2: the appendEnforcedTransaction function
The appendEnforcedTransaction function can only be called by the authorized EnforcedTxGateway contract.
function appendEnforcedTransaction(
address _sender,
address _target,
uint256 _value,
uint256 _gasLimit,
bytes calldata _data
) external
The function first validates that the gas limit is within the configured bounds in SystemConfig. It then computes a transaction hash and stores it in the messageRollingHashes mapping along with the current timestamp. The mapping uses a special encoding where the lower 32 bits store the timestamp and the upper 224 bits store a rolling hash of all messages.
ScrollChain: the commitAndFinalizeBatch function
This function allows forcing inclusion of transactions when the enforced batch mode conditions are met.
function commitAndFinalizeBatch(
uint8 version,
bytes32 parentBatchHash,
FinalizeStruct calldata finalizeStruct
) external
where FinalizeStruct is defined as:
/// @notice The struct for permissionless batch finalization.
/// @param batchHeader The header of this batch.
/// @param totalL1MessagesPoppedOverall The number of messages processed after this bundle.
/// @param postStateRoot The state root after this batch.
/// @param withdrawRoot The withdraw trie root after this batch.
/// @param zkProof The bundle proof for this batch (single-batch bundle).
/// @dev See `BatchHeaderV7Codec` for the batch header encoding.
struct FinalizeStruct {
bytes batchHeader;
uint256 totalL1MessagesPoppedOverall;
bytes32 postStateRoot;
bytes32 withdrawRoot;
bytes zkProof;
}
The function first checks if either delay condition is met:
- No batch has been finalized for
maxDelayEnterEnforcedModeseconds - No message has been included for
maxDelayMessageQueueseconds
If either condition is met, it enables enforced batch mode by:
- Reverting any unfinalized batches
- Setting the enforced mode flag
- Allowing the batch to be committed and finalized with a ZK proof
Once in enforced mode, only batches with proofs can be submitted until the owner (Scroll Security Council) explicitly disables enforced mode. Moreover, the designated Sequencer can’t commit or finalize batches anymore due to the whenEnforcedBatchNotEnabled check.
Table of Contents
Proof system [TO BE EXPANDED]
Scroll’s proof system is built to validate and finalize batches of L2 transactions that are committed on L1. The system uses ZK proofs to validate state transitions and allows for both normal sequencing and enforced batch modes.
Batch Lifecycle
A batch goes through two main phases:
- Commitment: The batch is proposed and its data is made available on L1
- Finalization: The batch is proven valid with a ZK proof and finalized
Batch Commitment
Batches can be committed in two ways:
- Normal sequencing mode via
commitBatchWithBlobProof()orcommitBatches() - Enforced batch mode via
commitAndFinalizeBatch()
The key differences are:
- Normal mode requires the sequencer role
- Enforced mode can be triggered by anyone after certain delay conditions are met
- Normal mode separates commitment from finalization
- Enforced mode combines commitment and finalization in one transaction
Batch Finalization
Finalization requires a valid ZK proof and can happen through:
finalizeBundleWithProof()- For pre-EuclidV2 batchesfinalizeBundlePostEuclidV2()- For post-EuclidV2 batchescommitAndFinalizeBatch()- For enforced mode batches
The finalization process:
- Validates the batch exists and hasn’t been finalized
- Verifies the ZK proof against the batch data
- Updates state roots and withdrawal roots
- Marks messages as finalized in the L1 message queue
Enforced Mode
The system can enter enforced mode when either:
- No batch has been finalized for
maxDelayEnterEnforcedModeseconds - No message has been included for
maxDelayMessageQueueseconds
In enforced mode:
- The normal sequencer is disabled
- Anyone can submit batches with proofs via
commitAndFinalizeBatch() - Only the security council can disable enforced mode
This provides a permissionless fallback mechanism if the sequencer fails or misbehaves.
Batch Versions
The system supports multiple batch versions with different encodings:
- V0-V6: Pre-EuclidV2 formats using various chunk codecs
- V7+: Post-EuclidV2 formats using blob data
Key version transitions:
- V5: Special Euclid initial batch for ZKT/MPT transition
- V7: EuclidV2 upgrade introducing new batch format
The version determines:
- How batch data is encoded and validated
- Which finalization function to use
- What proofs are required
ZK Proof Verification
Proofs are verified by the RollupVerifier contract which:
- Takes the batch data and proof as input
- Validates the proof matches the claimed state transition
- Returns success/failure
The proof format and verification logic varies by batch version.
Security Considerations
The system prioritizes security over liveness by allowing batch reversion and enforced mode activation only after a delay.
Admin operations
Table of Contents
- The
ScrollChaincontract - The
SystemConfigcontract - The
EnforcedTxGatewaycontract - The
L1MessageQueueV2contract
The ScrollChain contract
The ScrollChain contract maintains data for the Scroll rollup and includes several admin operations that can only be executed by the contract owner.
addSequencer function
This function allows the owner to add an account to the sequencer list.
function addSequencer(address _account) external onlyOwner
The account must be an EOA (Externally Owned Account) as external services rely on EOA sequencers to decode metadata directly from transaction calldata.
removeSequencer function
This function allows the owner to remove an account from the sequencer list.
function removeSequencer(address _account) external onlyOwner
addProver function
This function allows the owner to add an account to the prover list.
function addProver(address _account) external onlyOwner
Similar to sequencers, the account must be an EOA as external services rely on EOA provers to decode metadata from transaction calldata.
removeProver function
This function allows the owner to remove an account from the prover list.
function removeProver(address _account) external onlyOwner
updateMaxNumTxInChunk function
This function allows the owner to update the maximum number of transactions allowed in each chunk.
function updateMaxNumTxInChunk(uint256 _maxNumTxInChunk) external onlyOwner
setPause function
This function allows the owner to pause or unpause the contract.
function setPause(bool _status) external onlyOwner
When paused, certain operations like committing and finalizing batches will be restricted.
disableEnforcedBatchMode function
This function allows the owner to exit from enforced batch mode.
function disableEnforcedBatchMode() external onlyOwner
The enforced batch mode is automatically enabled when certain conditions are met (like message queue delays) and can only be disabled by the owner.
revertBatch function
This function allows the owner to revert batches that haven’t been finalized yet.
function revertBatch(bytes calldata batchHeader) external onlyOwner
This function can only revert version 7 batches and cannot revert finalized batches. During commit batch, only the last batch hash is stored in storage, so intermediate batches cannot be reverted.
The SystemConfig contract
The SystemConfig contract manages various system-wide parameters for the Scroll rollup. It includes several admin operations that can only be executed by the contract owner.
updateMessageQueueParameters function
This function allows the owner to update parameters related to the message queue.
function updateMessageQueueParameters(MessageQueueParameters memory _params) external onlyOwner
The parameters include:
maxGasLimit: The maximum gas limit allowed for each L1 messagebaseFeeOverhead: The overhead used to calculate L2 base feebaseFeeScalar: The scalar used to calculate L2 base fee
updateEnforcedBatchParameters function
This function allows the owner to update parameters related to the enforced batch mode.
function updateEnforcedBatchParameters(EnforcedBatchParameters memory _params) external onlyOwner
The parameters include:
maxDelayEnterEnforcedMode: If no batch has been finalized for this duration, batch submission becomes permissionlessmaxDelayMessageQueue: If no message is included/finalized for this duration, batch submission becomes permissionless
updateSigner function
This function allows the owner to update the authorized signer address.
function updateSigner(address _newSigner) external onlyOwner
The signer is an authorized address that can perform certain privileged operations in the system.
Initialization
The contract is initialized with the following parameters:
function initialize(
address _owner,
address _signer,
MessageQueueParameters memory _messageQueueParameters,
EnforcedBatchParameters memory _enforcedBatchParameters
) external initializer
This function can only be called once during contract deployment and sets up:
- The contract owner
- The initial authorized signer
- Initial message queue parameters
- Initial enforced batch parameters
The EnforcedTxGateway contract
The EnforcedTxGateway contract manages enforced transactions that can be submitted to L2. It includes admin operations that can only be executed by the contract owner.
setPause function
This function allows the owner to pause or unpause the contract.
function setPause(bool _status) external onlyOwner
When paused, users cannot submit enforced transactions through this gateway.
The L1MessageQueueV2 contract
The L1MessageQueueV2 contract manages the queue of L1 to L2 messages after the EuclidV2 upgrade. It includes several admin operations that can only be executed by authorized contracts.
Initialization
The contract is initialized with the following parameters:
function initialize() external initializer
This function can only be called once during contract deployment and sets up:
- The initial cross-domain message indices
- The next unfinalized queue index
- The ownership structure
Message Queue Parameters
The contract relies on parameters from the SystemConfig contract to manage message processing:
- Maximum gas limit for L1 messages
- Base fee overhead and scalar for L2 fee calculation
- Message queue delay parameters
Security Model
The contract implements a strict permission model where:
- Only the L1ScrollMessenger can append cross-domain messages
- Only the ScrollChain can finalize popped messages
- Only the EnforcedTxGateway can append enforced transactions
Table of Contents
Taiko Alethia
Table of Contents
Sequencing
High-level flow
Blocks can be sequenced by everyone unless a preconfTaskManager is set. Every block references a anchorBlockId, which indicates the latest L1 state that the L2 block is based on. The anchorBlockId cannot be more than maxAnchorHeightOffset blocks behind the current block, and should be greater or equal the parent’s one. Each block’s parentMetaHash must match the metaHash of the parent block. Every time a block is sequenced, a liveness bond is taken from the proposer, which is slashed if the block is not proven in time.
The storeForcedInclusion function
This function on the ForcedInclusionStore contract allows to enqueue forced transactions and bypass preconfer censorship. It is defined as follows:
function storeForcedInclusion(
uint8 blobIndex,
uint32 blobByteOffset,
uint32 blobByteSize
)
external
payable
onlyStandaloneTx
whenNotPaused
All transactions must pay a feeInGwei fee to get included. Each forced inclusion call creates a ForcedInclusion structure, which is defined as follows:
struct ForcedInclusion {
bytes32 blobHash;
uint64 feeInGwei;
uint64 createdAtBatchId;
uint32 blobByteOffset;
uint32 blobByteSize;
uint64 blobCreatedIn;
}
The createdAtBatchId value is set to the next batch id, and the blobCreatedIn value is set to the current block number. A forced inclusion deadline is defined as being inclusionDelay batches away from either the last time a forced inclusion was processed, or the time the forced inclusion request was created, whichever is newer. In practice this means that the slowest that forced transactions are processed is every inclusionDelay batches unless the next forced inclusion request to be processed is newer. This mechanism potentially opens to the possibility of a spam attack that delays all forced transactions.
The proposeBatch function
This function on the inboxWrapper contract is the main entry point to sequence blocks on Taiko Alethia. If a preconfRouter is set, then it must be the msg.sender. The function is defined as follows:
function proposeBatch(
bytes calldata _params,
bytes calldata _txList
)
external
onlyFromOptional(preconfRouter)
nonReentrant
returns (ITaikoInbox.BatchInfo memory, ITaikoInbox.BatchMetadata memory)
The _params value is split into two parts, where the first part is intended to contain forced transactions, and the second part regular L2 sequenced transactions. The first part can be empty only if the oldest forced transaction is not over the force inclusion deadline. The function enforces that only one block can be proposed, and that the block contains at least MIN_TXS_PER_FORCED_INCLUSION transactions, among other routine checks. After this, the two parts follow the usual proposeBlock flow, separately.
The function fetches the current config, which is hardcoded in the contract, and calls the proposeBlock function of the LibProposing library. Specifically for Taiko Alethia, the config is defined as follows:
function pacayaConfig() public pure override returns (ITaikoInbox.Config memory) {
// All hard-coded configurations:
// - treasury: the actual TaikoL2 address.
// - anchorGasLimit: 1_000_000
return ITaikoInbox.Config({
chainId: LibNetwork.TAIKO_MAINNET,
// Ring buffers are being reused on the mainnet, therefore the following two
// configuration values must NEVER be changed!!!
maxUnverifiedBatches: 324_000, // DO NOT CHANGE!!!
batchRingBufferSize: 360_000, // DO NOT CHANGE!!!
maxBatchesToVerify: 16,
blockMaxGasLimit: 240_000_000,
livenessBondBase: 125e18, // 125 Taiko token per batch
livenessBondPerBlock: 0, // deprecated
stateRootSyncInternal: 4,
maxAnchorHeightOffset: 64,
baseFeeConfig: LibSharedData.BaseFeeConfig({
adjustmentQuotient: 8,
sharingPctg: 50,
gasIssuancePerSecond: 5_000_000,
minGasExcess: 1_344_899_430, // 0.01 gwei
maxGasIssuancePerBlock: 600_000_000 // two minutes: 5_000_000 * 120
}),
provingWindow: 2 hours,
cooldownWindow: 2 hours,
maxSignalsToReceive: 16,
maxBlocksPerBatch: 768,
forkHeights: ITaikoInbox.ForkHeights({
ontake: 538_304,
pacaya: 1_166_000,
shasta: 0,
unzen: 0
})
});
}
The contract stores the latest state in the state variable, which is a State structure defined as follows:
struct State {
// Ring buffer for proposed batches and a some recent verified batches.
mapping(uint256 batchId_mod_batchRingBufferSize => Batch batch) batches;
// Indexing to transition ids (ring buffer not possible)
mapping(uint256 batchId => mapping(bytes32 parentHash => uint24 transitionId)) transitionIds;
// Ring buffer for transitions
mapping(
uint256 batchId_mod_batchRingBufferSize
=> mapping(uint24 transitionId => TransitionState ts)
) transitions;
bytes32 __reserve1; // slot 4 - was used as a ring buffer for Ether deposits
Stats1 stats1; // slot 5
Stats2 stats2; // slot 6
mapping(address account => uint256 bond) bondBalance;
uint256[43] __gap;
}
where Stats1 and Stats2 are defined as follows:
struct Stats1 {
uint64 genesisHeight;
uint64 __reserved2;
uint64 lastSyncedBatchId;
uint64 lastSyncedAt;
}
struct Stats2 {
uint64 numBatches;
uint64 lastVerifiedBatchId;
bool paused;
uint56 lastProposedIn;
uint64 lastUnpausedAt;
}
and Batch is defined as follows:
struct Batch {
bytes32 metaHash; // slot 1
uint64 lastBlockId; // slot 2
uint96 reserved3;
uint96 livenessBond;
uint64 batchId; // slot 3
uint64 lastBlockTimestamp;
uint64 anchorBlockId;
uint24 nextTransitionId;
uint8 reserved4;
// The ID of the transaction that is used to verify this batch. However, if this batch is
// not verified as the last one in a transaction, verifiedTransitionId will remain zero.
uint24 verifiedTransitionId;
}
and TransitionState is defined as follows:
struct TransitionState {
bytes32 parentHash;
bytes32 blockHash;
bytes32 stateRoot;
address prover;
bool inProvingWindow;
uint48 createdAt;
}
The control then passes to the MainnetInbox’s proposeBatch function. It is first checked that numBatches is equal or greater than the config.forkHeights.pacaya value. The term “Pacaya” refers to the Pacaya upgrade. It is then checked that the numBatches is less than lastVerifiedBatchId + maxBatchesToVerify + 1, which means that blocks cannot be proposed if sequenced batches are too much ahead of the last verified batch.
The _params are then decoded into a BatchParams structure, which is defined as follows:
struct BatchParams {
address proposer;
address coinbase;
bytes32 parentMetaHash;
uint64 anchorBlockId;
uint64 lastBlockTimestamp;
bool revertIfNotFirstProposal;
// Specifies the number of blocks to be generated from this batch.
BlobParams blobParams;
BlockParams[] blocks;
}
where BlobParams and BlockParams are defined as follows:
struct BlobParams {
// The hashes of the blob. Note that if this array is not empty. `firstBlobIndex` and
// `numBlobs` must be 0.
bytes32[] blobHashes;
// The index of the first blob in this batch.
uint8 firstBlobIndex;
// The number of blobs in this batch. Blobs are initially concatenated and subsequently
// decompressed via Zlib.
uint8 numBlobs;
// The byte offset of the blob in the batch.
uint32 byteOffset;
// The byte size of the blob.
uint32 byteSize;
// The block number when the blob was created. This value is only non-zero when
// `blobHashes` are non-empty.
uint64 createdIn;
}
struct BlockParams {
// the max number of transactions in this block. Note that if there are not enough
// transactions in calldata or blobs, the block will contains as many transactions as
// possible.
uint16 numTransactions;
// The time difference (in seconds) between the timestamp of this block and
// the timestamp of the parent block in the same batch. For the first block in a batch,
// there is not parent block in the same batch, so the time shift should be 0.
uint8 timeShift;
// Signals sent on L1 and need to sync to this L2 block.
bytes32[] signalSlots;
}
Default values for proposer, coinbase, anchorBlockId and lastBlockTimestamp are set if not provided, specifically msg.sender, msg.sender, the block number previous to the current one, and the current block timestamp. It is checked that the anchorBlockId is less than the current block number, but not more than maxAnchorHeightOffset behind. It is checked that the current anchorBlockId is greater than the parents’ anchorBlockId. The same check is then performed using the _params’s timestamp value. It is checked that the timestamp in which the current block is proposed is later than the timestamp of the parent block. The timeShift of the first block must be zero, and the total timeShift across blocks must be less than the lastBlockTimestamp, and the first block timestamp (calculated as lastBlockTimestamp minus the total timeShift) should be less than maxAnchorHeightOffset times L1 block time in the past. The timestamp of the first block must be greater than the timestamp of the last block in the parent batch.
It is then checked that the parentMetaHash corresponds to the parent’s metaHash, or it’s zero.
Then, a BatchInfo structure is created, which is defined as follows:
struct BatchInfo {
bytes32 txsHash;
// Data to build L2 blocks
BlockParams[] blocks;
bytes32[] blobHashes;
bytes32 extraData;
address coinbase;
uint64 proposedIn; // Used by node/client
uint64 blobCreatedIn;
uint32 blobByteOffset;
uint32 blobByteSize;
uint32 gasLimit;
uint64 lastBlockId;
uint64 lastBlockTimestamp;
// Data for the L2 anchor transaction, shared by all blocks in the batch
uint64 anchorBlockId;
// corresponds to the `_anchorStateRoot` parameter in the anchor transaction.
// The batch's validity proof shall verify the integrity of these two values.
bytes32 anchorBlockHash;
LibSharedData.BaseFeeConfig baseFeeConfig;
}
It is recommended to check the diagram above to understand how it is constructed. A Batch structure is also populated to be then saved in the batches mapping under the numBatches % batchRingBufferSize key. A verifiedTransitionId of 0 and a nextTransitionId of 1 means that a batch has been sequenced but not yet proven. Then the numBatches is incremented, and the lastProposedIn is set to the current block number. Finally, the debitBond function is called to collect the liveness bond, which is slashed if the proposed block doesn’t get timely proven. The token used is the _bondToken, and the amount is the livenessBond value.
The verifyBlock function is then called, which is discussed in the proof system page.
Table of Contents
Proof system
TODO
Table of Contents
Admin operations
TODO
Table of Contents
DA Layers
Table of Contents
Celestia
Celestia is a public network designed to provide data availability at scale. It separates the consensus and data availability layers from execution, allowing rollups to post their transaction data to Celestia for ordering and guaranteed availability without being constrained by the execution capacity of a monolithic chain.
Overview
Celestia primarily focuses on data availability, and its primary function is to make blob data available to anyone who needs it. This is achieved through a combination of data availability sampling (DAS) and a Tendermint-based proof-of-stake consensus mechanism.
Core Concepts
- Data Availability Sampling (DAS): Celestia’s light nodes use DAS to verify that all data for a block has been published with high probability, without needing to download the entire block. This allows for a secure and scalable network of light nodes.
- Erasure Coding: Block data is erasure coded, adding redundancy that allows for the reconstruction of the full block data even if a significant portion is missing. This is fundamental to making DAS effective.
- PayForBlob Transactions: Users submit data to Celestia via a special transaction type called
PayForBlob, which separates the data payload from the transaction metadata.
Network Architecture
Blob Lifecycle
The process of getting data onto Celestia and ensuring its availability involves several key stages. For a detailed explanation of this process, see Blob Lifecycle.
- Submission: A user submits a
PayForBlobtransaction to the network. - Encoding: The block producer arranges the data into shares, applies 2D Reed-Solomon erasure coding, and generates a data availability root (
availableDataRoot). - Propagation: The proposed block is propagated through the network. Validators download all data to verify the block, while light nodes perform DAS on the block header.
- Finality: The block is finalized on Celestia via Tendermint consensus. For L2s on other chains like Ethereum, finality is achieved when a proof of data availability is verified on the settlement layer via the Blobstream bridge.
Table of Contents
Blob Lifecycle
This document provides a high-level overview of how Celestia handles blob submission, from the moment a user submits a blob to the moment it’s finalized on Ethereum. For a deeper dive into the technical details, you can refer to the official Celestia App specifications.
A diagram illustrating the lifecycle of a blob on Celestia.
Blob Submission
The lifecycle begins when a user, often an L2 sequencer, submits data to the Celestia network by sending a PayForBlob transaction. This special transaction type is used to pay the fees for one or more blobs to be included in a block. The PayForBlob transaction itself contains metadata, while the raw blob data is sent alongside it to be picked up by the current block producer. This design efficiently separates the transaction logic from the larger data payload.
Encoding and Batching
The block producer is responsible for packaging blobs into a block. This is a multi-step process designed to ensure data availability.
-
Share Creation: The block producer takes the raw blob data, along with other standard transactions, and splits it into fixed-size units called “shares”.
-
Data Squaring: These shares are arranged into a
k x kmatrix, known as the “original data square”. -
Erasure Coding: To create redundancy, the block producer applies a 2D Reed-Solomon erasure coding scheme. This extends the
k x koriginal data square into a larger2k x 2k“extended data square” by adding parity data. This process ensures that the original data can be fully reconstructed from any 50% of the shares from each row or column. -
Data Root Calculation: The producer computes Merkle roots for each row and column of the
2k x 2kextended square. These row and column roots are then themselves Merkle-ized to create a singleavailableDataRoot. -
Block Creation: The final block is assembled. It contains:
- A
Block Header, which includes theavailableDataRootas a commitment to the data. - The
availableDatafield, which contains the original, non-erasure-coded transaction and blob data.
- A
Block Data Structure
A Celestia block’s structure is specifically designed for data availability. The main components are:
Header: This contains standard block metadata likeheightandtimestamp, but critically includes theavailableDataRoot. This root is the single commitment that light clients use to verify data availability through Data Availability Sampling.AvailableDataHeader: This contains the lists ofrowRootsandcolRootsfrom the extended data square. TheavailableDataRootis the Merkle root of these two lists combined.AvailableData: This field holds the actual data submitted by users. It is separated intotransactions(standard Cosmos SDK transactions),payForBlobData(the transactions that pay for blobs), andblobData(the raw blob content). Validators and full nodes use this original data to reconstruct the extended square and verify it against theavailableDataRoot.LastCommit: This contains the validator signatures from the previous block, securing the chain’s history, as is typical in Tendermint-based consensus.
The core logic for this process in celestia-app can be found in the PrepareProposal function.
Blob Propagation
Once a block is proposed, it is propagated across the network. Different types of nodes interact with the block differently to verify data availability.
-
Validators (Consensus Nodes): These nodes ensure the validity of the proposed block. They download the entire blob data from the
availableDatafield, re-compute the extended data square, and verify that the resultingavailableDataRootmatches the one in the block header. This check is performed in theProcessProposalfunction. -
Full Nodes (DA): These are nodes running the
celestia-nodesoftware. After a block is finalized by the consensus validators, full nodes receive it. They also re-process the block’savailableData, recreate the extended data square, and verify that its root matches theavailableDataRootin the header. This verification is a critical step before they make the individual shares of the extended square available on the P2P network for light nodes to sample. This verification logic can be seen in theheaderpackage. -
Light Nodes (DA): Light nodes, also running
celestia-node, provide security with minimal resource requirements. Instead of downloading the whole block, they perform Data Availability Sampling (DAS):- They download only the
Block Header. - They randomly sample small chunks (shares) of the extended data square from Full DA nodes.
- For each received share, they use the row and column roots from the header to verify the share’s integrity and its correct placement in the square.
By successfully verifying a small number of random samples, a light node can ascertain with very high probability that the entire block’s data was published and is available, without ever downloading it.
- They download only the
Blob Finality
For a blob to be useful to a L2, its availability must be verifiable on the L2’s settlement layer (e.g., Ethereum). This creates a two-stage finality process: finality on Celestia itself, and finality on the settlement layer via the Blobstream bridge.
1. Finality on Celestia
This is the first stage, where a block containing blob data is irreversibly committed to the Celestia chain through Celestia’s Tendermint-based consensus mechanism.
- Attestation: After a validator successfully verifies a block’s data availability, it signs the block with its signature, creating an attestation.
- Consensus: These signatures are broadcast to the rest of the network. When validators representing at least 2/3 of the total voting power have signed the block, it is considered final on Celestia. This process is very fast, taking only a few seconds (current block time ~6 seconds), at which point the data is permanently archived on the Celestia blockchain.
2. Finality on the Settlement Layer (Blobstream)
For a L2 on Ethereum to use the blob data, it needs proof on Ethereum that the data was published to Celestia. This is the role of the Blobstream bridge. The original version of Blobstream relied on Celestia validators re-signing data roots for the L1. The new generation of the bridge, such as sp1-blobstream, uses ZK proofs to create a more efficient and trust-minimized on-chain light client. You can read more in the SP1 Blobstream documentation.
The process for the ZK-powered Blobstream is as follows:
- ZK Proof Generation: An off-chain operator runs the
sp1-blobstreamprogram in a ZK virtual machine (the SP1 zkVM). This program acts as a Celestia light client: it processes a range of Celestia block headers, verifies the Tendermint consensus signatures for each block transition, and computes the Merkle root of thedataRoots for that range. The entire execution generates a succinct ZK-proof. - Relaying to L1: The operator relays this ZK-proof to the
SP1Blobstreamsmart contract deployed on the settlement layer. - L1 Verification: The
SP1Blobstreamcontract uses a canonicalSP1Verifiercontract to verify the ZK-proof. This is computationally cheap on-chain. Once the proof is verified, theSP1Blobstreamcontract stores the commitment to the range ofdataRoots.
A L2’s L1 contract can now verify its state transitions by proving against the data roots stored in the Blobstream contract. From the rollup’s perspective, its transaction data is only truly final and actionable once it has been processed and verified by the Blobstream bridge on its settlement chain.
Table of Contents
EigenDA
EigenDA is a data availability (DA) service built on top of EigenLayer, designed to provide high-throughput, scalable, and secure data availability for rollups. It leverages Ethereum’s economic security through the mechanism of restaking, where Ethereum stakers can opt-in to validate additional services like EigenDA.
Overview
EigenDA’s architecture is centered around a Disperser and a network of EigenLayer operators. Rollups submit their data to the Disperser, which then encodes and distributes it among the operators. These operators store the data chunks and provide attestations of availability. The system is horizontally scalable, meaning throughput can be increased by adding more operators to the network. The final proof of data availability is anchored to Ethereum for verification.
Core Concepts
- Disperser: A trusted, centralized component responsible for accepting blobs, performing erasure coding, generating KZG commitments, and dispersing data chunks to operator nodes.
- EigenLayer Operators: Nodes run by restakers who opt-in to the EigenDA service. They are responsible for storing data chunks and providing signatures to attest to data availability.
- KZG Commitments: EigenDA uses KZG commitments to ensure data integrity and allow for efficient verification of data chunks.
- Erasure Coding: Blobs are erasure coded into chunks, providing redundancy and ensuring the original data can be reconstructed even if some chunks are unavailable.
- Data Availability Certificate: An aggregated signature from operators (e.g., a BLS signature) that serves as a cryptographic proof of data availability. This certificate can be verified by smart contracts on Ethereum.
Network Architecture
Blob Lifecycle
The process of publishing data to EigenDA and achieving finality involves several key steps. For a detailed explanation of this process, see Blob Lifecycle.
- Submission: A rollup sequencer submits blob data to the Disperser service.
- Encoding and Batching: The Disperser erasure codes the blob, generates KZG commitments, and packages it into a batch with other blobs.
- Propagation and Attestation: The Disperser pushes the data chunks directly to the assigned operators. Each operator verifies its chunk, stores it, and returns a signature attesting to its availability.
- Finality: The Disperser aggregates the operator signatures into a Data Availability Certificate. This certificate can be posted and verified on the settlement layer (Ethereum), allowing a rollup’s smart contracts to confirm that its transaction data is available.
Table of Contents
Blob Lifecycle
This document provides a high-level overview of how EigenDA handles blob submission, from the moment a user submits a blob to its final confirmation of availability on Ethereum.
A diagram illustrating the lifecycle of a blob on EigenDA.
Blob Submission
The lifecycle of a blob in EigenDA begins when a client, typically an L2 sequencer, submits data to the EigenDA Disperser service. The Disperser exposes a gRPC interface for blob submission. To simplify this process, rollups often use a client library or the provided REST proxy, which handles the raw gRPC calls, payment, and status polling.
When the Disperser receives a submission, it registers a new blob entry and assigns it a pending status while it prepares the blob for encoding and distribution across the node operators network.
Encoding and Batching
Once a blob is accepted, the Disperser processes it to ensure data availability. This involves several steps:
-
Erasure Coding: The Disperser uses Reed-Solomon encoding to split the blob into multiple smaller pieces called “chunks”. This process adds redundancy, ensuring that the original blob can be reconstructed from a sufficiently large subset of these chunks.
-
KZG Commitments: To ensure data integrity, the Disperser generates KZG (Kate-Zaverucha-Goldberg) polynomial commitments for the blob. This includes:
- A commitment to the polynomial representing the blob’s data.
- A proof confirming the polynomial’s degree, which validates the blob’s size.
- KZG opening proofs for each chunk, which allow any node to efficiently verify that a given chunk is part of the committed blob.
-
Batching: For efficiency, the Disperser can bundle multiple blobs into a single batch. It then creates a
BatchHeaderfor this collection, which contains:- A
batch_root: A 32-byte Merkle root or hash that commits to all blobs within the batch. - A
reference_block_number: An Ethereum L1 block number that anchors the batch’s attestation to the settlement layer. The Disperser will encode and disperse the blobs based on the onchain info (e.g. operator stakes) at this block number.
- A
Data Structures
EigenDA uses a set of nested data structures to organize and secure blob data throughout its lifecycle.
-
BlobHeader: Provided at submission, this contains the blob’s version, metadata about the EigenDA quorum responsible for storing it, and its KZG commitments. These commitments are crucial for verifying the blob’s size and content integrity. -
BlobCertificate: For each blob in a batch, the Disperser creates a certificate. It encapsulates theBlobHeaderand additional dispersal information, such as the identities of the validator nodes (operators) assigned to store each chunk. -
SignedBatch: This structure finalizes the batch. It contains theBatchHeaderand, eventually, an aggregated signature from the validators who have attested to storing their assigned chunks. Validators sign the batch as a whole rather than individual blobs.
At this point, the blobs have been encoded and packaged, and their status transitions to await attestations from the validators.
Blob Propagation and Attestation
With the data prepared, the Disperser distributes the chunks to the EigenDA operator nodes (validators) for storage and validation.
-
Direct Push Model: Unlike gossip-based protocols, EigenDA uses a direct “push” model. The Disperser sends each chunk directly to the specific validator responsible for storing it. This approach is designed for efficiency and horizontal scalability, as each validator only needs to store a small fraction of each blob.
-
Chunk Verification: Along with each chunk, the Disperser sends the corresponding KZG proof. Upon receiving its assigned chunk, a validator uses this proof and the blob’s public commitment (from the
BlobHeader) to cryptographically verify that the chunk is authentic and correct. -
Storage and Attestation: After successful verification, the validator stores the chunk locally. It then signs an attestation confirming that it holds its portion of the data and sends this signature back to the Disperser.
The Disperser gathers these individual attestations. Once a sufficient number of signatures have been collected to meet EigenDA’s liveness and safety threshold, the blob is considered available and ready to be published to Ethereum.
Blob Finality
A blob’s journey concludes with a two-stage finality process, ensuring its availability is guaranteed by EigenDA and verifiable on the settlement layer (Ethereum).
1. Finality on EigenDA
Finality within the EigenDA system is achieved when the Disperser successfully aggregates a sufficient number of validator signatures.
-
Signature Aggregation: The Disperser collects the individual signatures (e.g., BLS signatures) from validators and aggregates them into a single, compact signature for the entire batch.
-
Certificate Creation: This aggregated signature is combined with the
BatchHeaderand metadata about the signing quorum (e.g., a bitmap of signers and non-signers) to create a Data Availability Certificate. -
Confirmation: The certificate is only considered valid once it meets EigenDA’s safety threshold, meaning a large enough fraction of validator stake has attested to the data’s availability. At this stage, the blob’s status is updated to
ConfirmedorFinalized. The certificate now serves as a portable, cryptographic proof of availability for the batch.
2. Finality on the Settlement Layer
For a rollup on Ethereum to use the blob data, it needs proof on Ethereum that the data was published to EigenDA. This is accomplished by bridging the Data Availability Certificate to the settlement layer.
-
Relaying to L1: A client, such as the rollup itself, can post the
BatchHeaderand the aggregated signature to the EigenDA smart contracts on Ethereum. -
L1 Verification: The EigenDA contracts, which are part of the broader EigenLayer protocol, verify the submitted proof. They check the aggregated signature against the registered set of operators and their corresponding stake, confirming that the amount of stake declared in the tx input has indeed attested to the data. Currently, no minimum stake threshold is enforced, meaning that a batch can be confirmed on Ethereum with lower than the minimum safety threshold of 55%. It is responsibility of the L2 to check that their blob’s batch meets their desired safety threshold.
Once the certificate is verified on-chain, the blob’s data is considered final and can be used by the L2’s L1 contracts for state updates or other operations.
Interop
Table of Contents
Overview
The interop pipeline captures raw blockchain data, converts it into internal InteropEvents, and later matches those events into user-facing messages and transfers.
A concrete OP Stack example is included at the end of this document: an Ethereum deposit into Base via OptimismPortal, where the destination transaction emits no logs but its L2 transaction hash can still be derived deterministically from the L1 deposit log.
For resyncable plugins, the capture phase is driven by declarative data requests. This is important because it makes the plugin’s external dependencies visible without reading arbitrary TypeScript code. A plugin can state:
- which logs it wants,
- on which chains and addresses,
- and which additional same-transaction context is required.
That model works well for data that can be requested directly by range, such as EVM logs. It is not enough for cases in which processing one captured event reveals that some other piece of data must be fetched later.
This document describes the constrained v1 design that we want to implement now. It intentionally favors a small amount of code and a small race surface over a fully generic architecture.
The plugin-facing model remains declarative:
- plugins declare derived data requests,
- the runtime handles lookup and routing,
- plugin authors do not manually manage pending work.
However, the internal implementation is intentionally narrower than the fully generic design we may want later.
Derived Data Requests
Problem
Some bridges do not emit a matching destination event that can be found by a normal log query. Instead:
- a source-side event is captured,
- the plugin computes some future identifier from it,
- the destination-side proof of execution is only observable as some other data item, for example a transaction by hash.
This creates a problem for resync:
- static event requests are known up front,
- but the transaction hash is only known after a source event has been captured,
- and the chain on which the transaction will appear may already be ahead of the chain that produced the source event.
Without an explicit mechanism for storing and revisiting such follow-up work, the system can miss data during cross-chain catch-up.
Core idea
To solve this, interop supports derived data requests.
A derived data request is a declarative statement that says:
- what kind of follow-up data should be fetched,
- which internal event type can create that request,
- from which event field the lookup key should be extracted,
- and on which chain the lookup should happen.
In v1, the main example is a request of the form:
- “for this captured event, watch for a transaction with this hash on that chain”
Conceptually, the plugin declares two layers of input:
static inputs -> queried directly from chain history
derived inputs -> created only after static inputs are processed
The key design rule is that the shape of the derivation stays declarative even if the value is only known at runtime.
In v1, that declarative shape is intentionally narrow:
- only one derived request family exists: transaction lookup for an event-derived tx hash,
- the request is declared as part of
getDataRequests(), - the tx hash and target chain are read from top-level fields already stored in
InteropEvent.args, - one creator event type can define at most one derived request,
- clusters are supported, but request ownership is always tracked by the exact plugin that produced the creator event.
This keeps the plugin API small while avoiding a large internal framework.
Lifecycle
The v1 lifecycle of a derived transaction request is:
historical logs / new logs
|
v
capture()
|
v
InteropEvent created
|
v
matches derived request definition?
|
yes
|
v
creator event remains the only persisted source of truth
with "derived request fulfilled?" = false/null
|
v
add pending request to in-memory index
|
+--> do one targeted historical lookup
| |
| +--> found: call captureTx with creator context,
| save resulting events,
| mark creator event fulfilled,
| remove from memory
| |
| +--> not found: keep unresolved creator event
| and keep request active in memory
|
v
on restart: rebuild pending requests from unresolved creator events
|
v
new blocks arrive on target chain
|
v
incoming tx matches active request?
|
yes
|
v
call captureTx with tx + creator context
|
v
save resulting InteropEvents
|
v
mark creator event fulfilled
and remove pending request from memory
Three points matter here:
First, the historical lookup must happen when the derived request is created, not only when a chain reaches tip. This avoids the case in which chain B is already following new blocks while chain A is still catching up and only later discovers a request that points to an old transaction on chain B.
Second, once the request is checked historically and not found, the system does not repeatedly poll the RPC for that same missing transaction. It relies on normal tip following-mode block processing to eventually see the transaction if it appears later.
Third, as seen on the flow above, the process of checking if a transaction is “interesting” is not something to be done manually in captureTx. It happens as part of the block processing flow, and captureTx receives the creator event context from the framework.
Why persistence might be needed
In the fully generic design (which we don’t implement in v1), derived requests would be persisted in their own table rather than kept only in memory.
That table can still be a good future direction if derived data requests become common, more than one request must be produced from a single creator event, or we need richer request-local state.
V1 deliberately does not introduce that table.
Instead, v1 reuses the existing InteropEvent row as the only persisted source of truth:
- unresolved creator events are stored exactly as normal interop events,
- a small fulfillment flag on the creator event says whether the derived request has already been resolved,
- startup reconstructs pending requests by reading unresolved creator events for event types that declare a derived request.
This is less performant and less generic, but it removes a large amount of code:
- no new table,
- no new repository,
- no additional SQL lifecycle to keep in sync,
- no separate persisted request-cleanup logic.
v1 persisted state = creator event row + fulfilled flag
The tradeoff is intentional: v1 accepts a narrower model in exchange for simpler code and a smaller operational surface.
Why an in-memory handler is needed
During Following Mode (i.e. processing every block as it comes) the system may see very large numbers of transactions. For each of them, we need to answer a small question quickly:
is this tx interesting for any active derived request?
To make that cheap, each derived request type has a dedicated in-memory handler. The handler:
- rebuilds pending requests from unresolved creator events on startup,
- maintains a compact in-memory index of active tx hashes,
- performs the one-time historical check when a creator event first appears,
- tests whether a newly seen tx is interesting,
- and stays in sync with
InteropEventStorewhen creator events are added, fulfilled, matched, unsupported, expired, or deleted.
For transaction-by-hash requests, the efficient index is naturally keyed by transaction hash.
This is intentionally similar in spirit to InteropEventStore:
InteropEventSQL rows are the durable source of truth,- memory is the fast query surface used during hot-path processing.
The in-memory handler should stay small. V1 does not need a large generic handler framework.
v1 scope
The initial version should stay narrow:
- only one derived request family is implemented: transaction lookup for an event-derived hash,
- only events create derived requests,
- the transaction hash and target chain are read from fields already stored inside the creator event,
- plugins declare the derivation shape, but they do not perform ad hoc callbacks to build requests,
- one creator event type can define at most one derived request,
- one creator event instance can therefore produce at most one pending derived request,
- the pending request is not persisted in its own table,
- creator-event persistence is reused instead,
- fulfillment is tracked on the creator event itself,
- startup rebuilds pending requests from unresolved creator events,
- generic processing finds matching derived requests and passes the creator context into transaction capture,
- the system does not poll repeatedly for missing tx hashes,
- clusters are supported, but exact plugin ownership is preserved throughout capture and fulfillment.
This is the implementation target for now.
If derived data requests prove important and we need a more general system, the next step should be to introduce a dedicated persistence layer for derived requests. That future version would likely add:
- a dedicated derived-request table,
- support for multiple derived requests per creator event,
- request-local state such as “checked in history”,
- more than one derived request family,
- a more generic lifecycle around request persistence and cleanup.
V1 is intentionally smaller than that future direction, but it preserves the same declarative plugin interface so the system can evolve later without forcing plugin authors to rewrite how they declare requests.
Worked Example: OP Stack Deposit Tx Hash Derivation
One concrete use case for derived data requests is an OP Stack deposit that emits a source-side event on Ethereum, but does not emit any useful destination-side event on the L2 chain.
For the exact case analyzed here:
- source Ethereum transaction:
0x7c76adb9ebe70dfdb57f495c7172b308879f87416ebcc9f2e0438d7fe86a1bee - destination Base transaction:
0x95e44b32a03c8e146a9b4a70b3934b4efb48f3f2188e4304dc6e66f52ce4d8b8 - L1 deposit contract (
OptimismPortalon Ethereum for Base):0x49048044D57e1C92A77f79988d21Fa8fAF74E97e
The Ethereum transaction calls:
depositTransaction(address,uint256,uint64,bool,bytes)
and emits:
TransactionDeposited(address indexed from, address indexed to, uint256 indexed version, bytes opaqueData)
For this transaction, the emitted event and its L1 log metadata are:
from = 0xf70da97812CB96acDF810712Aa562db8dfA3dbEFto = 0xf70da97812CB96acDF810712Aa562db8dfA3dbEFversion = 0blockHash = 0x55102b6e8f5ceb9803bfd78b9ec84ffd3e34156c821b7690f4c2b045a9696944logIndex = 514opaqueData = 0x000000000000000000000000000000000000000000000004747bc5c731c56846000000000000000000000000000000000000000000000004747bc5c731c5684600000000000186a000
For deposit event version 0, opaqueData is parsed as:
mint uint256 = 82180496084697442374
value uint256 = 82180496084697442374
gas uint64 = 100000
isCreation uint8 = 0
data bytes = 0x
That value is 82.180496084697442374 ETH.
The destination Base transaction is an OP Stack deposit transaction of type 0x7e. It has no logs, but its hash is deterministic and can be derived from the source log.
How sourceHash is calculated
For a user deposit, OP Stack computes:
depositIdHash = keccak256(l1BlockHash || bytes32(l1LogIndex))
sourceHash = keccak256(bytes32(0) || depositIdHash)
Using the values above:
depositIdHash = keccak256(
0x55102b6e8f5ceb9803bfd78b9ec84ffd3e34156c821b7690f4c2b045a9696944 ||
bytes32(514)
)
sourceHash = keccak256(bytes32(0) || depositIdHash)
= 0xb613781250a490c408694b600c1443b4b0f12e13792551b9aa12703dfb17f879
How the Base transaction hash is calculated
The rollup node derives a deposit transaction with the following logical fields:
[
sourceHash,
from,
to,
mint,
value,
gasLimit,
isSystemTx,
data
]
For user deposits:
fromcomes from the emitted eventtocomes from the emitted event, unlessisCreation = true, in which casetois empty /nilmint,value,gasLimit, anddatacome fromopaqueDataisSystemTx = false
The final L2 transaction hash is then:
l2TxHash = keccak256(0x7e || RLP([
sourceHash,
from,
to,
mint,
value,
gasLimit,
false,
data
]))
For this example:
l2TxHash = 0x95e44b32a03c8e146a9b4a70b3934b4efb48f3f2188e4304dc6e66f52ce4d8b8
which exactly matches the observed Base transaction hash.
Why this matters for interop
This is a good fit for a derived transaction request:
- the source Ethereum log is easy to capture historically
- the destination Base transaction does not have a matching event to query by log filters
- the destination transaction hash can be derived from the source log, so the runtime can register a follow-up “watch this tx hash on Base” request
Caveats
- The full L1 transaction is not required if the captured log includes both
blockHashandlogIndex. Those two fields are necessary forsourceHash. - Decoded event arguments alone are not sufficient if
blockHashandlogIndexwere discarded during capture. - The correct
fromvalue is the one emitted inTransactionDeposited, not necessarily the original L1 transaction sender. If the depositor is a contract,OptimismPortalaliases the address before emitting the event. - The
opaqueDatalayout depends on the deposit eventversion. This example is forversion = 0. - Contract creation deposits are a special case: when
isCreation = true, the derived deposit transaction uses an emptytofield instead of the emitted address.
Permissions section
Table of Contents
- Overview
- Permissioned actors
- Permissioned actions
- Grouping actors by entity
- Possible future developments
Overview
The goal of the permissions section is to list ultimate permissioned actors such as EOAs, multisigs, governors, or equivalent, that can affect the system. What falls in the equivalent class is left to intuition and discussion.
Permissioned actors
The permission section should not list nested multisigs when controlled by a single entity as it’s not in our scope of assessment to evaluate members within one entity. For example, the OPFoundationUpgradeSafe, at the time of writing, is a 5/7 multisig that contains another 2/2 multisig as a member. Such multisig should not be listed. On the other hand, the SuperchainProxyAdminOwner is formed by two distinct entities that are relevant for the assessment, as one member is the OpFoundationUpgradeSafe and the other is the SecurityCouncilMultisig, so both should be listed. One possible solution is to always hide nested multisigs unless explicitly stated otherwise. Eventually, for the risk assessment purpose, we might want to explicitly assign entities to multisigs, so this logic for nested multisigs might eventually be built on top of that.
Each non-EOA permissioned actor should have a description of its code, independent of the connections to other contracts. For example, multisigs should show the threshold, size and eventual modules, while governors should describe their own mechanism and params like quorums and voting periods. All permissioned actors should list the ultimate permissioned actions that they can perform on the system. Actors that produce the exact same description should be grouped together in one entry.
- (3) 0x123, 0x456, 0x789
+ Can interact with Inbox to:
* sequence transactions
Permissioned actions
The “upgrade” permissioned action for each permissioned actor should group contracts based on the set of possible delays that can be used on them.
- **FoochainMultisig**
A multisig with 3/5 threshold.
+ Can upgrade with either 7d or no delay:
* FoochainPortal <via>
* L1StandardBridge <via>
+ Can upgrade with 7d delay:
* L1ERC721Bridge <via>
+ Can upgrade with no delay:
* SystemConfig <via>
Such grouping can be achieved by first grouping individual contracts by delay, and then contracts by set of delays.
[FoochainPortal] with [7d <via1>] delay
[FoochainPortal] with [no <via2>] delay
[L1StandardBridge] with [7d <via3>] delay
[L1StandardBridge] with [no <via4>] delay
[L1ERC721Bridge] with [7d <via5>] delay
[SystemConfig] with [no <via6>] delay
>>>
[FoochainPortal] with [7d <via1>, no <via2>]
[L1StandardBridge] with [7d <via3>, no <via4>]
[L1ERC721Bridge] with [7d <via5>]
[SystemConfig] with [no <via6>]
>>>
[FoochainPortal <via1_or_via2>, L1StandardBridge <via3_or_via4>] with [7d, no] delay
[L1ERC721Bridge <via5>] with [7d] delay
[SystemConfig <via6>] with [no] delays
Each <via> should show the list of intermediate contracts used to perform the ultimate permissioned action, starting with the contract closer to the permissioned actor. If any contract adds a delay, it should be listed as well. The total delay shown with the permissioned action should be the sum of all delays in this chain of contracts.
- **FoochainMultisig**
A multisig with 3/5 threshold.
+ Can upgrade with 7d delay:
* L1ERC721Bridge acting via Timelock1 with 3d delay -> Timelock2 with 4d delay -> ProxyAdmin or via Timelock3 with 7d delay -> ProxyAdmin
Permissioned actions outside of upgrades should group by contract first and then list the actions with the appropriate delays. Where possible, each action should be listed as a separate entry.
- **FoochainMultisig**
A multisig with 3/5 threshold.
+ Can interact with Timelock1 to:
* propose transactions with 3d delay <via>
* update the minimum delay with 7d delay <via>
Grouping actors by entity
As previously discussed, there’s a will to group permissioned actors by entity. While the ultimate mechanism is still to be defined, it is worth it to first consider grouping multisigs with the same members, threshold and size under the same permissioned actor. Since these are abstracted entities, they should show the immediate underlying multisigs.
At the time of writing, Arbitrum One makes use of three distinct multisigs for the Security Council, with the same set of members and threshold: L1EmergencySecurityCouncil, L2EmergencySecurityCouncil and L2ProposerSecurityCouncil. Without the grouping, the permissions section would look like this:
- **L1EmergencySecurityCouncil**
A multisig with 9/12 threshold.
+ Can upgrade with no delay:
* RollupProxy <via>
* Outbox <via>
* ...
+ Can interact with L1Timelock to:
* update the minimum delay
* manage all access control roles of the timelock
* cancel queued transactions
+ Can interact with RollupProxy to:
* pause and unpause the contract
* update sequencer management delegation
* ...
- **L2EmergencySecurityCouncil**
A multisig with 9/12 threshold.
+ Can upgrade with no delay:
* L2ERC20Gateway <via>
* L2GatewayRouter <via>
* ...
+ Can interact with L2Timelock to:
* update the minimum delay
* manage all access control roles of the timelock
- **L2ProposerSecurityCouncil**
A multisig with 9/12 threshold.
+ Can upgrade with 17d 8h delay:
* RollupProxy <via>
* Outbox <via>
+ Can interact with L2Timelock to:
* propose transactions
+ Can interact with L1Timelock to:
* propose transactions with 14d 8h delay
* update the minimum delay with 17d 8h delay
* manage all access control roles of the timelock with 17d 8h delay
* cancel queued transactions with 17d 8h delay
+ Can interact with RollupProxy to:
* pause and unpause the contract with 17d 8h delay
* update sequencer management delegation with 17d 8h delay
* ...
With the grouping, the permissions section would look like this:
- **SecurityCouncilMultisig**
A multisig with 9/12 threshold. Acts through L1EmergencySecurityCouncil, L2EmergencySecurityCouncil and L2ProposerSecurityCouncil.
+ Can upgrade with either 14d 8h or no delay:
* RollupProxy <via>
* Outbox <via>
* ...
+ Can interact with L1Timelock to:
* propose transactions with 14d 8h <via>
* update the minimum delay with either 14d 8h or no delay <via>
* manage all access control roles of the timelock with either 14d 8h or no delay <via>
* cancel queued transactions with either 17d 8h or no delay <via>
+ Can interact with L2Timelock to:
* propose transactions <via>
* update the minimum delay <via>
* manage all access control roles of the timelock <via>
+ Can interact with RollupProxy to:
* pause and unpause the contract with 17d 8h or no delay <via>
* update sequencer management delegation with 17d 8h or no delay <via>
* ...
Possible future developments
While still in the discussion phase, there’s a will to show immediate permissioned given by each contract. For example, if a contract makes use of access control, each immediate role assignment would be shown, regardless of whether it is an intermediate contract or a permissioned actor. It is likely that these entries will be displayed in the contracts section under each contract rather than the permissions section.
Contracts section
Table of Contents
Overview
The goal of the contracts section is to list all contracts in the system that are not considered ultimate permissions, as per defined by the Permissions section spec. For each contract, the most relevant information should be presented. All information for a single contract should be as local as possible to allow for modularity via the template system.
Single contract view
For each contract, the basic information to be shown is the name and the address. If a contract is a proxy, the implementation contract(s) should also be shown. Optionally, a category can be shown. If a contract has any field with a defined “interact” or “act” permission, the direct permission receiver should be shown. Note that these permissions might not be ultimate permissions. Optionally, if the design allows, the ultimate permission receiver can be shown too. Upgrade permissions should be presented more distinctly, with the ultimate permission receiver shown, and its associated ultimate delay.
Let’s pick some Arbitrum One’s contracts to present an implementation proposal that only shows direct permissions for “interact” and “act” permissions:
- **RollupProxy** [0x4DCe…Cfc0] [Implementation #1 (Upgradable)] [Implementation #2 (Upgradable)] [Admin]()
...description...
+ Can be upgraded by: [Outbox with 3d delay] [Arbitrum Security Council with no delay]()
+ <Roles>
* `owner`: [UpgradeExecutor]()
- **UpgradeExecutor** [0x1234…5678] [Admin]()
...description...
+ Can be upgraded by: [Outbox with 3d delay] [Arbitrum Security Council with no delay]()
+ <Roles>
* `executors`: [L1Timelock] [Arbitrum Security Council]()
- **L1Timelock** [0x9abc…def0] [Admin]()
...description...
+ Can be upgraded by: [Outbox with 3d delay] [Arbitrum Security Council with no delay]()
+ <Roles>
* `proposer`: [Bridge]()
* `canceller`: [UpgradeExecutor]()
- **Bridge** [0x1a2b…3c4d] [Admin]()
...description...
+ Can be upgraded by: [Outbox with 3d delay] [Arbitrum Security Council with no delay]()
+ <Roles>
* `proposer`: [L1Timelock]()
* `canceller`: [UpgradeExecutor]()
If the design allows, the description of each “role” can be shown.
Contract categories
TBD
Finality page
Table of Contents
How to calculate time to inclusion
OP Stack
The OP Stack RPC directly exposes a method, optimism_syncStatus, to fetch the latest unsafe, safe or finalized L2 block number. An unsafe block is a preconfirmed block but not yet published on L1, a safe block is a block that has been published on L1 but not yet finalized, and a finalized block is a block that has been finalized on L1.
The method can be called as follows:
cast rpc optimism_syncStatus --rpc-url <rpc-url>
Most RPCs do not support such method, but fortunately QuickNode does. An example of the output is as follows:
{
"current_l1": {
"hash": "0x2cd7146cf93bae42f59ec1718034ab2f56a5ef2dcddf576e00b0a2538f63a840",
"number": 22244495,
"parentHash": "0x217b42bbdb4924495699403d2884373d10b24e63f45d2702c6087dee7024a099",
"timestamp": 1744359995
},
"current_l1_finalized": {
"hash": "0xbd1ee29567ddd0eda260b9e87e782dbb8253de95ba8f3802a3cbf3a3cac5ee8e",
"number": 22244412,
"parentHash": "0x13eb164b6245a7dca628ccac2c8a37780df35cc5b764d6fad345c9afac3ec6ec",
"timestamp": 1744358999
},
"head_l1": {
"hash": "0x2cd7146cf93bae42f59ec1718034ab2f56a5ef2dcddf576e00b0a2538f63a840",
"number": 22244495,
"parentHash": "0x217b42bbdb4924495699403d2884373d10b24e63f45d2702c6087dee7024a099",
"timestamp": 1744359995
},
"safe_l1": {
"hash": "0x609b0ca4539ff39a6521ff8ac46fa1fee5e717bd4a5931f2efce27f1d3d6ec70",
"number": 22244444,
"parentHash": "0xdd12087c45f149036c9ad5ce8f4d1880d42ed0c6029124b0a66882a68335647e",
"timestamp": 1744359383
},
"finalized_l1": {
"hash": "0xbd1ee29567ddd0eda260b9e87e782dbb8253de95ba8f3802a3cbf3a3cac5ee8e",
"number": 22244412,
"parentHash": "0x13eb164b6245a7dca628ccac2c8a37780df35cc5b764d6fad345c9afac3ec6ec",
"timestamp": 1744358999
},
"unsafe_l2": {
"hash": "0x9c3fba7839fa336e448407f387d8945e64f363afc48a3eed675728a3f4ff941c",
"number": 134380614,
"parentHash": "0xcb092179b9158964c02761a0511fd33c72b5e75df44ba2be245653940a28a69d",
"timestamp": 1744360005,
"l1origin": {
"hash": "0xb47909b847438d13914c629a49ca9113dd3828abab1a7c01e146c2817e739ae9",
"number": 22244484
},
"sequenceNumber": 2
},
"safe_l2": {
"hash": "0x778aa29f31e03923e2f8dd85aa2570504d4d956dbb1d6c94b00379c282eea5b5",
"number": 134380401,
"parentHash": "0x30977603570f5134cdf98922630095b52d25857bf24a8aa367d8fd01fda8a56b",
"timestamp": 1744359579,
"l1origin": {
"hash": "0x7ff5dde61b11bf0af83c93d8e8a51c519373495fd7cb5b74d74a4894c1e2d9ec",
"number": 22244448
},
"sequenceNumber": 5
},
"finalized_l2": {
"hash": "0x295df0a07e17c35f93422f83c47479f856e9fafde5b9d03c7714381d627035b2",
"number": 134379981,
"parentHash": "0xe110aa8531bcfb0c4c5e529f60a99751b9a9842797ef4d293b2cf514e496a4b7",
"timestamp": 1744358739,
"l1origin": {
"hash": "0xf999ff954c1a823b10ecc679c611bb2bc0c7cc7e5ef344c5468a4529204eab33",
"number": 22244379
},
"sequenceNumber": 0
},
"pending_safe_l2": {
"hash": "0x778aa29f31e03923e2f8dd85aa2570504d4d956dbb1d6c94b00379c282eea5b5",
"number": 134380401,
"parentHash": "0x30977603570f5134cdf98922630095b52d25857bf24a8aa367d8fd01fda8a56b",
"timestamp": 1744359579,
"l1origin": {
"hash": "0x7ff5dde61b11bf0af83c93d8e8a51c519373495fd7cb5b74d74a4894c1e2d9ec",
"number": 22244448
},
"sequenceNumber": 5
},
"cross_unsafe_l2": {
"hash": "0x9c3fba7839fa336e448407f387d8945e64f363afc48a3eed675728a3f4ff941c",
"number": 134380614,
"parentHash": "0xcb092179b9158964c02761a0511fd33c72b5e75df44ba2be245653940a28a69d",
"timestamp": 1744360005,
"l1origin": {
"hash": "0xb47909b847438d13914c629a49ca9113dd3828abab1a7c01e146c2817e739ae9",
"number": 22244484
},
"sequenceNumber": 2
},
"local_safe_l2": {
"hash": "0x778aa29f31e03923e2f8dd85aa2570504d4d956dbb1d6c94b00379c282eea5b5",
"number": 134380401,
"parentHash": "0x30977603570f5134cdf98922630095b52d25857bf24a8aa367d8fd01fda8a56b",
"timestamp": 1744359579,
"l1origin": {
"hash": "0x7ff5dde61b11bf0af83c93d8e8a51c519373495fd7cb5b74d74a4894c1e2d9ec",
"number": 22244448
},
"sequenceNumber": 5
}
}
The time to inclusion of L2 blocks can be calculated by polling the method and checking when the safe_l2 block number gets updated. The safe_l2 value refers to the latest L2 block that has been published on L1, where the latest L1 block used by the derivation pipeline is the current_l1 block. Assuming that all blocks in between the previous safe_l2 value and the current safe_l2 value are included in the current_l1 block when the safe_l2 value is updated, the time to inclusion of each L2 block between the previous safe_l2+1 and the current safe_l2 value can be calculated by subtracting the L2 block timestamp from the current_l1 timestamp. The assumption has been lightly manually tested and seems to hold.
Why this approach?
The very first approach to calculate the time to inclusion was to decode L2 batches posted to L1, get the list of transactions, and then calculate the difference between the L2 transactions timestamp and the L2 batch timestamp. This required a lot of maintenance, given that the batch format is generally not stable. There are a few possible approaches, like using the batch decoder provided by Optimism, but it’s not guaranteed that OP Stack forks properly maintain the tool and we might not have access to every project’s batch decoder in the first place. A different approach involves using an external API to fetch info like shown in this Blockscout’s batches page, but they don’t seem to expose an API for that.
Example
Here an output of a PoC script that tracks the time to inclusion of L2 blocks using the optimism_syncStatus method:
------------------------------------------------------
Fetched SyncStatus:
Safe L2 Block:
• Hash : 0x504edd794afe4994f96825c2892e16e1e3cd8d68c303007b054f6ad790635c6b
• Number : 134389152
• Prod. Time: 1744377081
Current L1 Block:
• Hash : 0x45c4ef3f1d79101ba050f6a8af3073ef74917df89f84fe532ed3e8a2979619ef
• Number : 22245928
• Time : 1744377239
------------------------------------------------------
Current Safe L2 Block: 134389152 (Produced at: 1744377081)
Current L1 Head (Inclusion Time Candidate): 1744377239
New safe L2 blocks detected: Blocks 134388973 to 134389152
Using current L1 timestamp as inclusion time: 1744377239
------------------------------------------------------
Batch Statistics for New Safe L2 Blocks:
Minimum Time-to-Inclusion: 2 min 38.00 sec
Maximum Time-to-Inclusion: 8 min 36.00 sec
Average Time-to-Inclusion: 5 min 37.00 sec
------------------------------------------------------
How to calculate withdrawal times (L2 -> L1)
To calculate the withdrawal time from L2 to L1, we need to fetch the time when the withdrawal is initiated on L2 and the time when it is ready to be executed on L1 and calculate the interval.
OP Stack (with fraud proofs)
Withdrawals are initiated by either calling bridgeETH or bridgeETHTo methods for ETH, or bridgeERC20 or bridgeERC20To methods for ERC20 tokens. Both methods emit a WithdrawalInitialized event, which is defined as follows:
// 0x73d170910aba9e6d50b102db522b1dbcd796216f5128b445aa2135272886497e
event WithdrawalInitiated(address indexed l1Token, address indexed l2Token, address indexed from, address to, uint256 amount, bytes extraData)
To track the time of these events, the L2 block number in which they are emitted can be used.
On L1, the AnchorStateRegistry is the contract used to maintain the latest state root that is ready to be used for withdrawals. The anchor root is updated using the setAnchorState() function, which is defined as follows:
function setAnchorState(IDisputeGame _game) public
and emits the following event:
// 0x474f180d74ea8751955ee261c93ff8270411b180408d1014c49f552c92a4d11e
event AnchorUpdated(address indexed game)
Each game contract has a function that can be used to retrieve the L2 block number they refer to:
function l2BlockNumber() public pure returns (uint256 l2BlockNumber_)
The time when the withdrawal is ready to be executed can be calculated by tracking the AnchorUpdated event, specifically when its respective L2 block number becomes greater than the L2 block number of the WithdrawalInitiated event. If the goal is not to track the withdrawal time of a specific withdrawal but to more generally calculate an average, just tracking the AnchorStateRegistry and calculating its corresponding l2BlockNumber is good enough.
Why this approach?
Withdrawals are not directly executed based on the information in the AnchorStateRegistry, but rather based on games whose status is GameStatus.DEFENDER_WINS. Since the AnchorStateRegistry’s latest anchor root can be updated with the same condition, it is enough to track that to determine when a withdrawal is ready to be executed on L1. This assumes that the AnchorStateRegistry is always updated as soon as possible with the latest root that has been confirmed by the proof system. In practice the assumption holds since a game terminates with a closeGame() call, which also calls setAnchorState() on the AnchorStateRegistry to update the root if it is newer than the current saved one.
Another approach consists in tracking finalized withdrawals directly, but this would skew the calculation since not every withdrawal is finalized as soon as they are available to be finalized, and outliers would be introduced in the data set. For completeness, when a withdrawal is finalized, the WithdrawalFinalized event is emitted, which is defined as follows:
// 0xdb5c7652857aa163daadd670e116628fb42e869d8ac4251ef8971d9e5727df1b
event WithdrawalFinalized(bytes32 indexed withdrawalHash, bool success)
The withdrawalHash can be calculated as follows:
function hashWithdrawal(Types.WithdrawalTransaction memory _tx) internal pure returns (bytes32) {
return keccak256(abi.encode(_tx.nonce, _tx.sender, _tx.target, _tx.value, _tx.gasLimit, _tx.data));
}
where the nonce must be fetched through the SentMessage event emitted by the L2CrossDomainMessenger when a withdrawal is initiated. The SentMessage event is defined as follows:
// 0xcb0f7ffd78f9aee47a248fae8db181db6eee833039123e026dcbff529522e52a
event SentMessage(address indexed target, address sender, bytes message, uint256 messageNonce, uint256 gasLimit)
Example
Let’s take this withdrawal as an example to show how to calculate the withdrawal time. The transaction emits the WithdrawalInitiated event as expected, and the corresponding L2 block number is 134010739, whose timestamp is 1743620255 (Apr-02-2025 06:57:35 PM +UTC).
This script can be used to find the time in which the AnchorStateRegistry was updated with a root past the L2 block number of the withdrawal. This is the output:
╔══════════╤═════════════════════╤═══════════════╤═══════════╤═══════════════╤═══╗
║ Block │ Time │ Game │ L2 Block │ Tx │ ? ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22232676 │ 2025-04-09 16:55:11 │ 0xf944...d159 │ 134006190 │ 0x77ca...19ba │ ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22232975 │ 2025-04-09 17:54:59 │ 0x302d...c419 │ 134008034 │ 0x07e7...8bda │ ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22233284 │ 2025-04-09 18:56:59 │ 0x794B...bf9B │ 134010097 │ 0x147d...357c │ ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22233578 │ 2025-04-09 19:55:47 │ 0xAB7e...35b1 │ 134011549 │ 0x33eb...3693 │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22233879 │ 2025-04-09 20:56:23 │ 0xC593...45BF │ 134013468 │ 0xa687...55fd │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22234179 │ 2025-04-09 21:56:47 │ 0x05fd...9bA3 │ 134015440 │ 0xa941...cee0 │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22234474 │ 2025-04-09 22:56:11 │ 0x91c9...2e8A │ 134017191 │ 0x1011...e9c7 │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22234779 │ 2025-04-09 23:57:11 │ 0xd065...A461 │ 134018922 │ 0x28c1...080c │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22235080 │ 2025-04-10 00:57:35 │ 0x5D8e...d691 │ 134020776 │ 0xb822...1456 │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22235380 │ 2025-04-10 01:57:35 │ 0x34a2...2aA9 │ 134022724 │ 0x0ba7...db52 │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22235682 │ 2025-04-10 02:57:59 │ 0x500e...497C │ 134024336 │ 0x8109...cd76 │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22235987 │ 2025-04-10 03:58:59 │ 0xFF4E...4F10 │ 134026350 │ 0xdaed...def0 │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22236286 │ 2025-04-10 04:58:47 │ 0xce9E...0F17 │ 134028088 │ 0x2c52...9b7a │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22236586 │ 2025-04-10 05:58:47 │ 0x764B...6294 │ 134029727 │ 0x1d03...c14e │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22236891 │ 2025-04-10 06:59:59 │ 0xA066...e3F9 │ 134031693 │ 0x44d0...c506 │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22237189 │ 2025-04-10 07:59:47 │ 0x1528...120C │ 134033337 │ 0x6da3...9bce │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22237494 │ 2025-04-10 09:00:59 │ 0xC40b...7C32 │ 134035313 │ 0xed42...2687 │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22237791 │ 2025-04-10 10:00:35 │ 0xF7BC...29e8 │ 134036917 │ 0x042b...efb5 │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22238086 │ 2025-04-10 10:59:47 │ 0x4E3B...f55A │ 134038845 │ 0xf622...9348 │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22238396 │ 2025-04-10 12:01:47 │ 0xf3D4...97B8 │ 134040625 │ 0x4d74...9fcf │ X ║
╟──────────┼─────────────────────┼───────────────┼───────────┼───────────────┼───╢
║ 22238695 │ 2025-04-10 13:01:35 │ 0x1cF1...824F │ 134042382 │ 0xe3ec...523d │ X ║
╚══════════╧═════════════════════╧═══════════════╧═══════════╧═══════════════╧═══╝
i.e. 7 days, 58 mins and 12 seconds have passed between the withdrawal being initiated and the time when it was ready to be executed on L1.
Scroll
Scroll uses a ZK-rollup architecture with an asynchronous message bridge between L2 and L1.
Withdrawals (and every other L2→L1 message) follow the life-cycle illustrated by
the events below:
// Emitted on L2 when a message is queued
event AppendMessage(uint256 index, bytes32 messageHash);
// 0x5300000000000000000000000000000000000000 (Scroll: L2 Message Queue)
// Emitted on L1 when a message is executed
event RelayedMessage(bytes32 indexed messageHash);
// 0x6774bcbd5cECEf1336B5300Fb5186a12DDD8B367 (Scroll: L1 Scroll Messenger Proxy)
// Emitted on L1 when a batch proof is verified and the batch becomes final
event FinalizeBatch(
uint256 indexed batchIndex,
bytes32 indexed batchHash,
bytes32 stateRoot,
bytes32 withdrawRoot
);
Time to withdrawal calculation
-
Track initiation on L2
Listen for theAppendMessageon the L2 Message Queue, storemessageHashtogether with the L2 block timestamp in which the event was emitted. -
Track execution on L1
Listen forRelayedMessageon the L1 Scroll Messenger.
When a matchingmessageHashis found, fetch the transaction data. The calldata callsrelayMessageWithProof(...); decode it and read the_proof.batchIndexfield. -
Find the first-available timestamp
With the extractedbatchIndex, search theScrollChaincontract for the correspondingFinalizeBatchevent.
The timestamp of the transaction that emitted this event represents the moment the batch became final and every withdrawal in it could have been executed. -
Compute the intervals
• Earliest withdrawal time
FinalizeBatch.timestamp − AppendMessage.timestamp
(how long users wait until the withdrawal can be executed)• Actual withdrawal time (optional)
RelayedMessage.timestamp − AppendMessage.timestamp
(includes any additional delay introduced by the relayer)
Orbit Stack
When users initiate a withdrawal on L2 (for example, calling ArbSys.withdrawEth() or an ERC-20 bridge’s withdraw function), the sequence of events is:
- The withdrawal triggers an L2-to-L1 message. Internally this is done via ArbSys.sendTxToL1, which emits an L2 event and adds the message to Arbitrum’s outgoing message Merkle tree.
- The L2 transaction (and its outgoing message) get included in a rollup assertion that the validator posts to the L1 rollup contract (SequencerInbox/Bridge) in a batch. This marks the inclusion of the withdrawal request on L1.
- Once the assertion is posted, it enters the dispute window on L1. For Arbitrum One this is roughly 7 days (currently ~6.4 days in seconds). During this period, any validator can challenge the posted state if they detect fraud. In the normal case with no fraud proofs, the assertion simply “ages” for the full challenge period.
- Once the dispute period expires without a successful challenge, at least one honest validator will confirm the rollup assertion on L1. The Arbitrum Rollup contract finalizes the L2 state root and posts the assertion’s outgoing message Merkle root to the Outbox contract on L1. At this point the L2 transaction’s effects are fully finalized on L1 (equivalent to an L1 transaction’s finality, aside from Ethereum’s own finalization delay). The withdrawal message is now provably included in the Outbox’s Merkle root of pending messages.
Time to withdrawal calculation
-
Track initiation on L2
Listen for theL2ToL1Txevent from theArbSyspre-compile
(0x0000000000000000000000000000000000000064):event L2ToL1Tx( address caller, // sender on L2 address indexed destination, // receiver on L1 uint256 indexed hash, // unique message hash uint256 indexed position, // (level<<192)|leafIndex uint256 arbBlockNum, // L2 block number uint256 ethBlockNum, // 0 at emission uint256 timestamp, // L2 timestamp uint256 callvalue, // ETH value bytes data // calldata for L1 );Store:
position- the global message indexhash- unique identifier (for quick look-ups)timestamp- L2 time of initiation
From
positionyou can extract:level = position >> 192(always 0 in Nitro)leafIndex = position & ((1<<192) - 1)arbBlockNum- the L2 block number where the withdrawal was initiated.
-
Detect when the withdrawal becomes executable
After the ≈7-day fraud-proof window a validator confirms the rollup assertion. Assertion confirmation could also incur in a “challenge grace period” delay, which allows the Security Council to intervene at the end of a dispute in case of any severe bugs in the OneStepProver contracts. During assertion confirmation the Rollup contract emits:event AssertionConfirmed( bytes32 indexed assertionHash, bytes32 indexed blockHash, // L2 block hash of the assertion's end bytes32 sendRoot // root of the Outbox tree );The confirmation routine calls
Outbox.updateSendRoot(sendRoot, l2ToL1Block), which emits:event SendRootUpdated( bytes32 indexed outputRoot, // == sendRoot above bytes32 indexed l2BlockHash // L2 block hash corresponding to this root );This
l2BlockHashsignifies that all L2-to-L1 messages initiated in L2 blocks up to and including the L2 block represented by thisl2BlockHashare now covered by theoutputRootand are executable.To check if the specific withdrawal (with
leafIndexandarbBlockNumfrom Step 1) is executable:- Find the
SendRootUpdatedevent. - Get the L2 block number corresponding to
SendRootUpdated.l2BlockHash. - If
your_withdrawal.arbBlockNum <= L2_block_number_of_SendRootUpdated_event, then your withdrawal (identified by itsleafIndex) can be executed. The L1 timestamp of thisSendRootUpdatedevent is the earliest time your withdrawal becomes executable.
- Find the
-
Compute the intervals
- Earliest withdrawal time
SendRootUpdated.timestamp − L2ToL1Tx.timestamp(where SendRootUpdated meets the condition in Step 2)
- Earliest withdrawal time
This PoC script calculates the time to withdrawal for Arbitrum One.
Stages edge cases
Table of Contents
- Introduction
- Liveness failure upper bound
- Forced transaction delay upper bound
- Frontrunning risk
- Based preconfs
Introduction
The goal of this document is to describe certain edge cases that are not explicitly covered by the Stage 1 requirements, either voluntarily or because they were not considered at the time of writing, and start a discussion on how to handle them. The document is not exhaustive, and it is expected that more edge cases will be added in the future.
Liveness failure upper bound
The new Stage 1 requirement announced here is presented as follows:
➡️ The only way (other than bugs) for a rollup to indefinitely block an L2→L1 message (e.g. a withdrawal) or push an invalid L2→L1 message (e.g. an invalid withdrawal) is by compromising ≥75% of the Security Council.
⚠️ Assumption: if the proposer set is open to anyone with enough resources, we assume at least one live proposer at any time (i.e. 1-of-N assumption with unbounded N). We don’t assume it to be non-censoring.
While “indefinitely” corresponds to permanent liveness failures, it is unreasonable to classify chains as Stage 1 if they allow “bounded” liveness failures of a million years, hence the need to define an acceptable upper bound. Bounded liveness failures are allowed in Stage 1 to allow teams to quickly respond to threats by pausing the system and handing over control to the Security Council (SC).
As a reminder, in the new Stage 1 principle it is assumed that at least a quorum blocking minority of the Security Council is honest and can be trusted to prevent permanent liveness failures. This can either be implemented by allowing the minimum quorum blocking minority or lower to unpause, or indirectly by implementing an expiration mechanism for the pause plus a cooldown mechanism to prevent repeated pauses. If the Security Council minority is employed, no upper bound is needed to be defined. If the second strategy is used, the expiration time defines the upper bound for the liveness failure. A cooldown period of zero would convert any bounded liveness failure into a permanent one, hence the need to define a minimum cooldown period too.
Pause mechanisms can differ, as they can either affect withdrawals, deposits, or both. For this reason, it’s worth evaluating whether different upper bounds are needed for each of them.
Case study 1: Optimism
Optimism describes the way a standard OP stack is supposed to satisfy the new Stage 1 requirements in their specs. A “guardian” (i.e. the Security Council in the Superchain) can trigger a pause. The guardian can delegate such role to another actor called the “pause deputy” via a “deputy pause” Safe module. The pause automatically expires and cannot be triggered again unless the mechanism is explicitly reset by the guardian, meaning that the cooldown period is infinite. The pause can either be activated globally (e.g. Superchain-wide pause) or locally to the single chain. The expiration time of a standard OP stack is defined to be 3 months. Since both pauses can be chained, the liveness failure bound is actually 6 months. The guardian can explicitly unpause the system, and if so, the pause mechanism can be reused immediately. In addition to this mechanism, the guardian can always unilaterally revoke the deputy guardian (and with it the deputy pauser) role.
It’s important to note that the pause only affects withdrawals, and not deposits or forced transactions. If a user needs to perform any action on the L2, for example to save an open position, they are still allowed to do so after the usual forced transaction delay in the worst case.
Case study 2: Scroll
At the time of writing, Scroll allows a non-SC actor to pause the system. Scroll is assessed as a Stage 1 system with the old requirements because the Security Council majority can always recover from a malicious pause by revoking the non-SC role that allows to pause the system. With the new requirements, either a minority of the SC should be allowed to unpause and revoke, or the pause should expire. More importantly, the pause mechanism also affects forced transactions as it would not be possible to call the depositTransaction function in the EnforcedTxGateway contract. In such case, users would not be able to perform any action on the L2.
Let’s assume for a moment that these issues with the pause mechanism are fixed. An explicit pause mechanism is not the only way to cause a liveness failure. The protocol currently employs a permissioned sequencer that can ignore forced transactions, up until the “enforced liveness mechanism” gets activated and everyone can then submit and prove blocks. If the delay is 7d, then the mechanism effectively acts as a pause that lasts 7 days and should be handled accordingly.
Forced transaction delay upper bound
OP stack and Orbit stack notably have a 12h and a 24h max forced transaction delay, respectively. Given that years long delays are unreasonable, an upper bound must be defined. While the forced transaction delay is taken into consideration when calculating exit windows, if a project exclusively relies on a Security Council for upgrades, such delays are not accounted for anywhere. Moreover, some protocols don’t have well defined boundaries on the delay, like the recent forced transaction mechanism introduced by Taiko, where in the worst case a forced transaction is processed from the queue every 255 batches, but if the queue is too long, the delay for a specific forced transaction can be much longer.
Given that forced transaction delays effectively act as a temporary pause from the perspective of censored users, it should be considered whether the upper bound value should be aligned with the liveness failure upper bound for sequencing (e.g. 7d).
Frontrunning risk
Intuition: if there are two permissioned provers, one being a SC minority and one being a non-SC actor, the non-SC might frontrun the SC and prevent them from, for example, enforcing censorship resistance if no other mechanism is present. The threat model should be made more explicit on whether malicious actors have the ability to continuously frontrun other actors. For reference, Arbitrum’s BoLD paper is described around this threat model.
Based preconfs
Pre-Pacaya Taiko uses free-for-all based sequencing, and therefore does not need a forced transaction mechanism. After Pacaya, a “preconf router” can be added with the intent of restricting sequencing rights to a staked whitelist of opted-in L1 proposers. It’s important to remember that most of censorship resistance guarantees today come from the long tail of self-building proposers that do not use mev-boost, or in the future by FOCIL. Arguably, proposers that do not opt-in to mev-boost will also not opt-in to stake to the preconf router, and therefore will not be able to provide censorship resistance, and neither would transactions originated from users through FOCIL as the sequencing call is gated. Because of this, the addition of a preconf router suggests the need of an additional forced transaction mechanism.
Diffovery
Table of Contents
Diff filtering
Diffovery takes two addresses, fetches their source code, flattens it and diffs it side by side. This has multiple use cases, but the main one is determining to what extent contracts share source code. A potential use case is checking if two deployments are the same across chains or time. We might want to know if a contract deployed 5 years ago uses the same source code as a contract deployed a few days ago. We already know that both of them fulfil the same role or exhibit the same behaviour, but we want to find out if their source is the same.
This works very well for sources deployed close in time. In most cases the source is exactly the same and results in an empty diff. Sometimes source code that was written a long time ago is still used. This creates problems because with time tooling changes and things like formatters change the source while keeping behaviour the same. Determining if two sources are equal is now harder because we are going to see many differences in comment formatting or simply in used white space.
Our goal is to provide tools that will speed up work associated with understanding the differences between two contracts. A part of it is exposing the actual difference in the source that results in behaviour changes. This means that diff entries that only show white space changes are noise. To solve this issue we use a mechanism called diff filtering.
How it works
Diffovery uses Monaco (the VSCode engine) to display text, it comes with some niceties included, like a builtin diff viewer. We would much rather use something like difftastic. We can’t, so we settle for Monaco because the quality is good enough and reinventing the wheel ourselves is not worth it. The middle ground to reduce noise is to keep using Monaco’s diff computer but to do extra logic to determine if we want to show that diff entry or not. A computed diff is an array of ranges, each range is a start and an end line in the left file and the right file. We can think of it as if we are filtering ranges out with a predicate:
.filter((range) => importantDiff(range))
To determine if the computed difference is important to us we run two checks. The first check is very simple, we fetch all tokens that fall within the range on both sides and we only show the entry if the tokens between the left and right side are different. If the tokens are the same, this means that the change is purely in the white space. The second check is conditional, we only run it if the user selected to ignore comments. If they did, all comment tokens are stripped from both sides before we compare, so any difference that lives entirely in comments gets ignored. This applies both to lines that are purely comments and to trailing comments on lines that also contain code.
The actual behaviour is a little bit more complex than just accepting or rejecting a diff entry. For each entry, we are going to try to narrow the range down from the left and right side. You can think of it as trimming the range, but the logic used for trimming is:
if(!importantDiff(line in range)) trimThisLine()
It’s the same logic function as described above that works on white space and comment changes. If we narrow the range down to nothing, the entire entry is filtered out.
What are tokens
By tokens we mean the result of lexing the source code of Solidity. For our purpose, we only care about the string content and whether the token is a comment or something else, we don’t actually care about what this something else is. When checking for equivalence, we only compare the content of each token. The lexer is a small piece of code written by us, we don’t use a library. We can allow ourselves this freedom because we assume that diffovery will only ever show Solidity contracts.
Table of Contents
TokenDB
TokenDB is the system that holds L2BEAT’s canonical token catalogue —
Abstract Tokens (the asset, e.g. “USDC”) and Deployed Tokens
(individual (chain, address) instances of an asset), plus the
relations between deployed tokens. It is served by the token-backend package and
edited through the token-ui package.
The docs in this folder describe how TokenDB is kept correct:
- Automatic token ingestion — the background loop that discovers new deployed tokens from interop transfers, links them to abstract tokens (via transfer evidence and CoinGecko), and surfaces conflicts/errors to humans.
- Token relations — how relations between
deployed tokens are observed from non-swapping interop transfers, why
their ingestion is deliberately separate from the token ingestion
queue, and why the table has no foreign keys to
DeployedToken. - Intent / Plan / Execute — the intent → plan → commands pipeline behind every human-driven write from token-UI, and why it exists (visible blast radius + concurrency safety).
- Abstract token merging — why duplicate abstract tokens arise (CoinGecko splits, ingestion fallback), why an abstract token keeps multiple CoinGecko entries, and how merging resolves relation conflicts.
Two “planning” subsystems — what they share, what they don’t
TokenDB has two pipelines with plan-like steps in their names, and it
is worth being explicit about why they exist and how they relate, because
the distinction is load-bearing for any future work in this area.
intent → plan → execute(intent_plan_execute.md) is a UX construct. The plan exists so the user can see the full blast radius of their edit before clicking Confirm; the re-plan-and- compare inside the SERIALIZABLE transaction exists so what they confirmed is exactly what gets written. It is not really about “planning writes” — it is about showing writes and guaranteeing they don’t drift between dialog and click.plan → fetch → apply(automatic_token_ingestion.md) in the ingestion processor is a cost / separation construct.planis RPC-free so the queue page can predict every row’s outcome cheaply;fetchis the only place external calls happen;applywrites. The reasons are different, and the deep-equality check from the other pipeline would actively hurt this one — CoinGecko coin map updates would invalidate plans constantly.
So merging the two into a single “plan-and-execute” pipeline would be the wrong target. Forcing ingestion through the intent pipeline would mean rebuilding a user-confirmation construct around something that has no user; conversely, dropping the intent pipeline would not actually simplify ingestion.
What the two pipelines do share — and what should remain shared — is
the write boundary below them: the Command primitives and a single
commitTokenChanges helper in
packages/token-backend/src/commitTokenChanges.ts.
Both pipelines — and token relation ingestion as
a third writer — translate their work into Command[] and funnel it
through this helper. That means:
- There is exactly one place that writes to TokenDB’s three core tables
(
AbstractToken,DeployedToken,TokenRelation). - Future cross-cutting concerns (history, audit log, write proofs) plug in here once and cover every writer automatically.
- Each pipeline still owns its own concurrency story (the intent pipeline re-plans inside the SERIALIZABLE transaction; ingestion just wraps the writes in SERIALIZABLE), because they have different guarantees to provide.
Each pipeline attaches an AbstractTokenAssignmentProof to deployed-token
commands at plan time, so the proof is visible in the diff the user
sees before clicking Confirm (and in the ingestion preview dialog):
{ kind: 'manual'; user } (with the logged-in user’s email) for user
plans, and { kind: 'coingecko' } or
{ kind: 'non-swapping-transfer'; transfer } for ingestion plans. The
proof lands on the DeployedToken.abstractTokenAssignmentProof JSON
column; commitTokenChanges does not modify commands, it just routes
them. The non-swapping-transfer proof carries the full transfer
because the interop transfer table is a sliding 7-day window; BigInt raw
amounts are stored in JSON as decimal strings. A persistent history
table will land in a follow-up change.
Table of Contents
- Automatic Token Ingestion
- Overview
- Drain guard and monitoring
- The only input is an address
- Queue states
- Approval mode
- Processing one entry: plan + fetch + apply
- Abstract token resolution
- Outcomes
- Shared write boundary
- Token DB history
- Propagation
- Reading the interop transfer table
- CoinGecko: never called from
plan - CoinGecko symbol casing and punctuation
- Resolving CoinGecko symbol conflicts
- Address normalization
- What runs where
- What this replaces
- Future: persistent trace audit
Automatic Token Ingestion
This document describes how TokenDB is kept in sync automatically: how new deployed tokens are added, how they are linked to abstract tokens, and how conflicts and errors surface to humans.
Overview
A background loop in the token-backend service ticks every minute. Each tick does three things in order:
- Token relation ingestion. Materialize
TokenRelationrows from interop transfers inserted since the previous tick. This is a separate subsystem that deliberately does not use the queue below — see Token relations for how it works and, more importantly, why it is not part of this queue. - Pre-step. Scan the interop transfer table for transfers inserted since the previous tick and enqueue both token addresses from each transfer.
- Drain. Repeatedly take the next pending queue entry and process it until the queue is empty or the per-run safety cap is reached.
The steps run sequentially (never in parallel) so that logs stay separated and a failure is attributable to a single step. That’s the entire shape. Everything else is a detail of how a single entry gets processed.
Drain guard and monitoring
The drain has a hard per-run processing cap
(TOKEN_INGESTION_MAX_PROCESSED_PER_RUN, default 1000). This is a safety
guard against accidental propagation cycles: if processing one entry keeps
re-enqueueing the same connected set of tokens, one tick still terminates.
When the cap is reached, the loop peeks once more. If the queue is empty
exactly at the cap, the run is considered successful. If another pending
entry remains, the loop stops, leaves all remaining entries as pending, and
logs a warning with limitReached: true, processed,
remainingPending, maxProcessedPerRun, outcome counts, repeated-address
count, and duration. Every completed drain also logs the same summary at
info level with limitReached: false, so Elasticsearch can graph processed
entries and queue emptiness per run.
The only input is an address
The unit of work is a token address — the pair (chain, address). Not
a transfer, not a CoinGecko id, not a manual form submission. Whenever any
part of the system wants the ingestion process to reconsider a token, it
calls enqueue(address), which means exactly:
There is potentially new knowledge about this address. Please reprocess it.
The address might be brand new, already in TokenDB, or already have an abstract token. The queue does not pre-judge — it lets the processing logic figure out what, if anything, needs to change.
This framing is load-bearing: every external trigger collapses into the same act (enqueue). Conflicts in already-resolved tokens, propagation between linked tokens, manual retries — all of them work because the processor is one piece of code exercised by every trigger.
Queue states
The queue holds at most one row per (chain, address). States:
- pending — approved for processing; the drain loop only takes these.
- staged — discovered by the pre-step but not yet approved. The
automatic drain ignores these. A human can promote one to
pendingfrom the UI. Used only during rollout (see Approval mode). - conflict — disagreement was detected; left for a human to resolve. The CoinGecko-symbol variety can be resolved directly from the queue UI — see Resolving CoinGecko symbol conflicts.
- error — processing tried but could not fetch required data (e.g.
symbol,decimals,deploymentTimestamp). Left for a human.
Enqueueing an address that is already queued in any state is a no-op —
conflict and error are sticky until a human clears them. There is no
“could not resolve” state: if a tick cannot find an abstract token for an
address, the entry is simply removed. New knowledge later may re-enqueue
it and we try again from scratch.
Approval mode
For production rollout the pre-step enqueues discovered addresses as
staged by default. A researcher inspects each one and approves it from
the queue UI. Long-term, TOKEN_INGESTION_AUTOAPPROVE=true makes the loop
fully autonomous by enqueueing new addresses as pending, and the UI
focuses on conflict / error.
Processing one entry: plan + fetch + apply
The processor splits each tick into three phases:
plan(entry)— fast and local. Looks at TokenDB, walks the in-memory interop transfer index, and consults the in-memory CoinGecko coin map. No external calls: no RPC, no explorer, and no per-coin CoinGecko endpoints (getCoinDataById/getCoinMarketChartRange). Produces anIngestionTrace: an ordered list of decisionstepsplus a singleoutcome. The trace also carriesexistingDeployedToken— the result of the TokenDB lookupplanperforms for every entry — as a structured field, so consumers (such as the queue page’s already-in-TokenDB indicator) read it directly instead of scanning the human-readablesteps. When the outcome can’t be made terminal without an external call — either we’d insert a new token (needs RPC facts) or we’d materialize a new abstract from a CoinGecko coin we haven’t seen before (needs CoinGecko per-coin endpoints) — the outcome ispending, which carries the operation (insertorupdate) and the abstract intent (existingid, ornew-coingeckowith just the coin id and symbol).fetch(trace)— the only place external calls happen. Pass-through for every outcome exceptpending. Forpending,fetchmaterializes the new abstract record (when needed) via CoinGecko, then — foroperation: insert— calls the RPC/explorer fact fetcher. It either upgrades the outcome towrite(with a full deployed-token record), downgrades it toconflictwhen a newly materialized CoinGecko abstract has a different symbol than the deployed token, or downgrades it toerrorwhen CoinGecko data or required deployed-token facts cannot be fetched.apply(entry, trace)— writes only. Switches on the final outcome and does the corresponding TokenDB and queue mutations. Throws if it ever seespending(a signfetchwas skipped). For thewriteoutcome,applytranslates the trace intoCommand[]and funnels them through the sharedcommitTokenChangeswrite boundary, which is the same primitive that the user-drivenintent → plan → executepipeline uses. See Shared write boundary below.
process(entry) is the 4-line composition: plan then fetch then
apply.
The split is the entire shape of the implementation. It exists because:
- Fast queue-wide prediction. Because
planis RPC-free, the queue page can run it for every row on the visible page (inline insidegetPage) to populate a “Will do” column without paying RPC cost. - Dry-run for free. Calling
plan()+fetch()alone produces a full trace without touching the database. The queue page exposes a “Preview” button on every row that does exactly this. - The trace is its own audit log. Steps describe why a particular abstract token was chosen, which transfer evidence was used, whether CoinGecko hit, which facts were fetched, and what warnings arose. The outcome describes what would change. No separate logger threading through RPC/CoinGecko/explorer calls is needed.
- No
dryRun: booleanflag. The phase boundaries are the toggles.
The trace and outcome shapes are defined in
packages/token-backend/src/ingestion/IngestionTrace.ts.
That file is the canonical reference for what a trace contains — keep it
small and readable.
Abstract token resolution
Inside plan(), the abstract token for an address is resolved in three
strategies, tried in order:
- Non-swapping transfers. For every transfer involving this address
where the bridge type is
lockAndMintorburnAndMint, look up the other side in TokenDB and collect itsabstractTokenId. If there’s exactly one unique id, use it. If there are several, the outcome isconflict. Swap-based bridges are ignored here because the two sides are not the same asset. If the stored abstract on the existing deployed token disagrees with what transfers say, alsoconflict. - The token’s existing abstract. If the deployed token already exists in TokenDB and has an abstract assigned, reuse it.
- CoinGecko platform lookup. Search CoinGecko’s coin list for a coin
that lists this
(chain, address)as a platform address. If found and we already have anAbstractTokenwith thatcoingeckoId, reuse it. Otherwise build a newAbstractToken(withreviewed: false) from the coin’s data, but only commit the new abstract/deployed-token assignment if the deployed token symbol matches the new abstract token symbol. Since inserts learn the deployed token symbol from RPC/explorer facts, this mismatch check happens infetch; a mismatch becomesconflict, while a missing deployed token symbol remains anerror. The symbol comparison ignores casing and punctuation — see CoinGecko symbol casing and punctuation below.
If none of the three resolves anything, the outcome is skip and the
entry is removed. RPC/explorer fact fetching only runs in the fetch
phase, and only for pending-insert outcomes — there’s no point
fetching decimals for an address we’re going to drop or for one whose
existing record only needs its abstract pointer updated.
Outcomes
plan produces one of: skip, conflict, noop, write (update
with an already-existing abstract), or pending. fetch either passes
the outcome through or converts pending into write (insert or
update, possibly with a newly built CoinGecko abstract), conflict
(new CoinGecko abstract symbol differs from deployed token symbol), or error.
apply only ever sees the five terminal outcome kinds:
skip— no abstract resolvable, or address could not be normalized.applyremoves the queue entry. No write.conflict— disagreement detected.applymoves the entry to theconflictstate with a message.error— abstract resolution required CoinGecko data that could not be fetched, or the abstract resolved but the deployed-token facts are incomplete (missingsymbol,decimals, ordeploymentTimestamp).applymoves the entry toerrorwith a message.noop— token already exists with the resolved abstract; nothing to write.applyremoves the queue entry.write—applyinserts/updates the deployed token and, if needed, inserts a new abstract token in the same transaction. It then re-enqueues every neighbor token from the address’s transfers (propagation) and removes the queue entry.
Shared write boundary
This pipeline, the user-driven intent → plan → execute pipeline, and
token relation ingestion ultimately write to the
same TokenDB core tables (AbstractToken, DeployedToken, and
TokenRelation). To make sure all paths produce the same writes — and so
that future cross-cutting concerns like a persistent history table land
in exactly one place — they share a single primitive,
commitTokenChanges,
that takes a list of Commands and dispatches each to the matching
repository method.
commitTokenChanges is a pure router: every command arrives with whatever
fields it needs already populated, including any abstract-token
assignment proof. Each pipeline decides the proof at plan time:
- The user planner sets
abstractTokenAssignmentProof: { kind: 'manual', user: <email> }on anyAddDeployedTokenCommand/UpdateDeployedTokenCommandthat introduces or changesabstractTokenId, andnullwhen the assignment is cleared. - The ingestion planner sets the proof returned by abstract-token
resolution (
{ kind: 'coingecko' }or{ kind: 'non-swapping-transfer', transfer }) onto the deployed-token write produced by the same plan step. Forpendingoutcomes the proof is held on the pending variant and transferred onto the deployed-token write byfetch.
The plan-time stamp means the proof shows up in the diff the user sees before clicking Confirm in the UI, and in the ingestion preview dialog that renders predicted outcomes from the queue.
The proof is persisted on the DeployedToken.abstractTokenAssignmentProof
JSON column. Commands that don’t touch the assignment leave the column
alone. Setting abstractTokenId to null clears the proof.
AbstractTokenAssignmentProof today is one of:
{ kind: 'manual'; user }— written by user-driven plans.useris the email of whoever was logged in when the plan was confirmed.{ kind: 'coingecko' }— ingestion resolved the abstract from CoinGecko’s platform lookup. The CoinGecko id is already on the abstract token itself, so the proof carries no extra data.{ kind: 'non-swapping-transfer'; transfer }— ingestion resolved from non-swapping transfer evidence. The proof carries the full transfer row, not just an id, because the interop transfer table is a sliding 7-day window — by the time someone reviews the assignment, the row may already be gone. Because the proof is stored as JSON, BigInt raw amounts in that transfer are persisted as decimal strings.
The column itself is typed as JSON (unknown) at the repository layer
so old proofs continue to read even if the typed shape evolves; the
strong AbstractTokenAssignmentProof type is only used at plan time.
commitTokenChanges does not own its surrounding transaction — each
pipeline opens its own, because the user pipeline also needs to re-plan
inside that transaction while ingestion does not. The helper logs every
command it executes and records one row per executed command in the
TokenDbHistory table, so the audit trail
covers both pipelines uniformly.
Token DB history
Every executed command is recorded in TokenDbHistory. The shape is
deliberately small:
timestamp— when the command was applied.source—'manual'(user-driven plan) or'ingestion'.userEmail— set whensource = 'manual', otherwisenull.commandType— theCommand.typeliteral (e.g.AddDeployedTokenCommand), denormalized off the JSON for indexed filtering.command— the executedCommandstored verbatim as JSON.
The Command already carries everything an audit reader needs: Add*
commands hold the record being inserted, Update* commands hold both
existing (the row as planned against) and update (the patch), and
Delete* commands hold existing (the row about to be deleted). So
history needs no separate “before” / “after” columns — command is the
audit row. Re-rendering an Update* row as a diff is { existing, update }; an Add* row is record; a Delete* row is existing with a
deletion marker. DeleteAll* commands carry only type and are stored
as-is.
source is supplied as a third argument to commitTokenChanges. The
user-driven executePlan / planAndExecute pass
{ kind: 'manual', user } (the email is already required for proof
stamping); ingestion passes { kind: 'ingestion' }. The proofs introduced
in the previous slice continue to ride on individual commands and are
captured inside command — so anyone reading history sees both what
changed and why the abstract assignment was chosen.
Propagation
After a successful write, the processor re-enqueues the other side of
every transfer involving the just-written address. This is how the queue
drains the dependency graph: if A↔B started with both unknown, processing
B (via CoinGecko) writes B, propagation re-enqueues A, and the next
iteration resolves A from non-swapping transfer evidence (B is now in
TokenDB). noop outcomes do not propagate — that would cause
ping-pong cycles between two stable tokens.
Reading the interop transfer table
For each tick, the drain builds an in-memory index keyed by normalized
(chain, address) from a SQL aggregation over the interop transfer table:
one row per unique group of (src token, dst token, bridge-type evidence),
carrying the group’s transfer count and a sample transfer id. Each
processed entry looks up its own routes from this index — no per-entry DB
queries for transfer evidence. Aggregating in SQL keeps the index size
proportional to the number of distinct bridged token pairs, not to
transfer volume — the table retains ~7 days of transfers, and loading full
rows for all of them (as an earlier version of this index did) caused
out-of-memory crashes when retention grew from one day to seven.
The one consumer that needs a full transfer row — the
non-swapping-transfer assignment proof — fetches the group’s sample
transfer by primary key, and only for outcomes that persist the proof
(write and pending). Plans that end in noop or conflict — the
common steady-state outcomes — never pay the lookup.
The drain refresh happens immediately after the pre-step and immediately before processing the queue. Do not replace it with a stale cached read: tokens enqueued from newly inserted transfers must be planned against an index that can see those same transfers. The refreshed index is kept on the processor as the latest UI cache. Preview and queue-page prediction reuse that cached index, falling back to building and caching one on demand when the loop has not run yet.
The pre-step uses a separate insertion-order cursor
(interop-transfers:lastSerialId, stored in TokenDbSettings) to find
transfers added since the previous tick.
CoinGecko: never called from plan
CoinGecko calls cost money and are rate-limited. The client is configured
with a calls-per-minute limiter, and the processor builds a chain-keyed
map of all coin platforms once (per processor instance) and looks up
addresses in-memory after that. For new abstract tokens, getCoinDataById
and the listing-timestamp lookup are deferred to the fetch phase — they
are never called from plan, so populating the queue page’s “Will do”
column for hundreds of rows costs zero per-coin CoinGecko calls.
Per-coin calls in fetch are intentionally not cached. In the normal
auto-approve path, a new abstract is materialized once, and later tokens
with the same CoinGecko id reuse the stored abstract token from TokenDB.
The listing timestamp is optional; if the market-chart call fails,
ingestion keeps the abstract with a null listing timestamp.
The processor instance is hoisted to server startup so it lives for the whole server lifetime — that is, the coin map cache spans the whole session, not just one tick. The CoinGecko client itself rate-limits but does not cache; caching is the processor’s concern.
There is no “we tried this address and dropped it” record. A dropped address simply disappears from the queue. If audit history is needed later, the natural place to store it is alongside the deployed token itself — see Future: persistent audit.
CoinGecko symbol casing and punctuation
The two CoinGecko endpoints this pipeline calls — /coins/list
(used by plan to find the coin from (chain, address)) and
/coins/{id} (used by fetch to materialize a new abstract token) —
both return the symbol field lower-cased regardless of the token’s
actual casing (susde for sUSDe, wsteth for wstETH, susd for
sUSD). The name field preserves casing across both endpoints, but
it’s a full name rather than a symbol. The RPC call against the
deployed token returns the true casing, so RPC is the source of truth
for the symbol.
Beyond casing, CoinGecko symbols also routinely differ from the on-chain
symbol only in punctuation: $-prefixed meme coins ($PEPE vs PEPE,
in either direction), stray spaces, dots and similar. The production
conflict backlog showed such pairs make up roughly 40% of all
CoinGecko-symbol mismatches, and none of them describe a different
asset.
The fetch phase therefore treats symbols that match after lower-casing
and stripping everything that is not a Unicode letter or digit as the
same symbol rather than a conflict (two symbols that are all
punctuation never match — there is no comparable content). What ends up
on the new abstract token depends on how the raw values differed:
- Casing-only difference: the deployed-token casing is copied onto
the abstract record, and a
corrected-coingecko-symbol-casingstep is appended to the trace. - Punctuation difference: the deployed-token symbol is adopted
(it is on-chain truth and carries real casing) with edge whitespace
stripped — an invisible stray space on an abstract symbol would
spuriously conflict with clean deployments of the same asset later.
An
adopted-deployed-token-symbolstep is appended to the trace, and the CoinGecko spelling is recorded in the abstract token’scommentso the substitution stays traceable on the record itself. (The deployed-token record keeps its RPC symbol verbatim; only the abstract token symbol is curated. The manual resolution flow below trims its chosen symbol for the same reason.) - Genuine difference (e.g.
USDCvs.DAI,WKASvs.KAS): still aconflict.
All of this lives inside the existing fetch phase: that’s the moment we
already have both the materialized CoinGecko abstract and the
deployed-token symbol in hand, so no additional plumbing is needed. If
CoinGecko ever starts returning properly cased symbols, the casing
substitution becomes a no-op (the values already match) and can be
removed without touching the rest of the pipeline.
Resolving CoinGecko symbol conflicts
When the symbols genuinely differ, the entry lands in the sticky
conflict state as before — but this particular conflict kind is
resolvable from the queue UI. Production data shows the remaining
mismatches are dominated by wrapped/bridged variants (WKAS vs KAS,
aEthUSDT vs AUSDT) and CoinGecko-side renames (SAI vs DAI,
LUNC vs LUNA), where a researcher has to decide which spelling the
abstract token should carry; a small tail are CoinGecko platform-mapping
errors (a scam contract mapped onto a real coin) that must not be
written automatically — which is why this stays a human decision instead
of blindly preferring CoinGecko.
The conflict fires precisely when ingestion is about to create the abstract token — an existing abstract found by CoinGecko id is linked without any symbol comparison (see Abstract token resolution). So the resolution is not a special ingestion mode; it is simply: a human creates the abstract token, then the entry is retried. The re-plan finds the abstract by its CoinGecko id and links the deployed token through the ordinary existing-abstract path, and the conflict never fires again. No decision is transported through the pipeline, so there is nothing to go stale and nothing to guard — the pipeline keeps its core invariant of re-deriving everything from current state on every run, and queue entries keep carrying only status, never decisions.
The moving parts:
- The conflict outcome produced by
fetchcarries a structuredsymbolConflictfield (CoinGecko id + both symbols) next to the human-readable message, so the UI never parses message strings. For stored queue entries, thegetPageroute flags rows withresolvableSymbolConflict, derived from the fresh plan it already computes per row: the CoinGecko-symbol conflict is the only conflict kind that can fire while the plan wants to create a new abstract token from CoinGecko, sostate = conflictplus anew-coingeckopending plan identifies it — no message-format knowledge anywhere. Because the flag is derived from current evidence rather than the stored message, it also disappears on its own once the conflict stops applying (e.g. a sibling chain’s resolution already created the abstract token). - The queue page shows a Resolve button on flagged rows. The dialog
re-runs
plan+fetch(thepreviewroute) to get a fresh structured conflict and offers three choices: the CoinGecko symbol (upper-cased — CoinGecko loses casing), the deployed-token symbol, or a custom value (pre-filled with the CoinGecko symbol so fixing its casing is a two-keystroke edit). Because the abstract token is shared by every deployment of the coin, the dialog nudges towards a chain-neutral symbol (aWBTC, notaArbWBTC). If re-planning no longer produces this conflict — evidence changed while the entry sat in the queue, or a sibling chain’s entry was already resolved — the dialog says so and points at Preview / Retry instead. - Confirming reuses the ordinary manual write path: an
AddAbstractTokenIntent(plan.generate+plan.execute) creates the abstract token with the chosen symbol, the coin’s CoinGecko id, icon and listing timestamp (fetched via the samechecksroute the Add-abstract-token form uses), and acommentrecording both original symbols and the choice. Then the entry is retried. There is no dedicated resolution endpoint, and the ingestion pipeline knows nothing about resolutions. - The audit trail is the ordinary one: the manual insert lands in
TokenDbHistorywithsource = manual, the researcher’s email and the intent; the subsequent link write lands as a normalingestionrow with its trace log. The abstract token’scommentkeeps the decision visible on the record itself.
Failure modes degrade to visible, recoverable states instead of wrong writes. If the evidence changes before the retry runs (CoinGecko remapped the address or delisted the coin), the retry just reports the new reality and the manually created abstract sits unlinked — reviewable and deletable like any other token. Two researchers racing on sibling entries of the same coin can at worst create a spare abstract for that coin (the dialog previews on open, so a conflict that was already resolved shows as no-longer-resolvable instead of offering a second resolution); the spare is equally visible and deletable.
The queue page also has a Retry conflicts on this page bulk action
(retryMany): after the punctuation normalization above shipped, a large
share of the accumulated conflict backlog resolves automatically on
re-processing, and conflicts whose cause still holds simply come back
with a refreshed message. It is also the natural way to clear sibling
entries after one chain’s conflict was resolved — their re-plan now
finds the abstract token and links to it.
Address normalization
Interop transfer tokens are stored as bytes32; TokenDB stores 20-byte EVM addresses. Normalization:
- Lowercases everything.
- Cuts bytes32 → 20-byte for EVM addresses.
- Treats
0xandAddress32.ZEROas “no address” — these get dropped before entering the queue. - Keeps non-
0xliteral addresses (e.g."native") as-is for non-EVM chains.
What runs where
- Pre-step: reads from the interop database (
db). - Drain: reads from both
db(interop transfers) andtokenDb(TokenDB state, queue, settings). Writes go totokenDb. The drain always refreshes the transfer index fromdbimmediately before planning and updates the processor’s cached index for UI use. - Preview:
plan+fetch, called from thetokenIngestionQueue.previewtRPC route. Uses the processor’s cached transfer index, or builds and caches one on demand if no drain has warmed it yet. - Queue page predicted outcomes:
planonly, called once per row from inside thetokenIngestionQueue.getPagetRPC route. Uses the same cached transfer index/fallback path as preview. The same per-rowplancall also powers the row’s already-in-TokenDB indicator via the trace’sexistingDeployedToken.
What this replaces
The two cards on the legacy Token UI suggestions page.
Future: persistent trace audit
TokenDbHistory already gives a per-command audit trail of what
changed and who changed it. The next step, if needed, is persisting the
full IngestionTrace next to write events so the reasoning (every
decision step) is queryable too — useful when a researcher wants to
understand why a particular abstract was chosen long after the interop
transfer that justified it has rolled out of the 7-day window. The trace
already exists at apply time, so this is a low-cost follow-up.
Table of Contents
- Token Relations
- Observations, not catalogue entries
- A transfer has a direction; a relation has roles
- The table
- How relations are ingested
- Why this is NOT part of the token ingestion queue
- Why the burn/mint flags are NOT columns
- Why there are NO foreign keys to DeployedToken
- Deleting a deployed token leaves its relations in place
- Display implications
- Relations graph
- Human edits
- Known limitations
Token Relations
A token relation records that we witnessed a non-swapping interop
transfer between two token addresses: the same abstract asset moved between
them via a specific plugin, classified as a specific non-swapping
bridgeType. Relations are the raw material for reasoning about which
deployed tokens represent the same asset — including, crucially, for
spotting places where our current abstract-token assignments are wrong.
Observations, not catalogue entries
This is the single mental model that explains every design decision below:
A token relation is an observation; the token catalogue is an interpretation.
A relation says “this transfer happened on-chain”. That is a fact,
true regardless of whether we have catalogued either address as a
DeployedToken, and true especially when our abstract-token assignments
disagree with it. A token-ingestion conflict is precisely the situation
where our interpretation (two different abstract tokens) disagrees with the
observations (non-swapping transfers between them). The whole point of
collecting relations is to surface those disagreements — for example as a
graph where an edge between tokens of different abstract tokens is drawn
red, telling a human “these abstract tokens should probably be merged”
(see abstract token merging).
It follows that observation recording must never be gated on the interpretation being consistent. Any design where a token-level conflict can suppress a relation destroys the primary use of relations.
A transfer has a direction; a relation has roles
This is the second load-bearing distinction, and getting it wrong has already cost us one bug:
Which way a transfer moved is a property of the transfer. Which token is escrowed and which is a minted representation is a property of the pair.
An interop transfer travels from a source to a destination — a fact about one transaction, at one moment, in one direction. A relation says two token addresses are the same asset, bridged by a given mechanism. That is not directional in the transfer sense: users bridge both ways over the same route all day, and each of those transfers is evidence of the same relation.
What a lockAndMint relation does have is an asymmetry between the two
tokens: one is locked (escrowed) and released, the other is minted and
burned. Take a lockAndMint bridge between token X on ethereum, which is
escrowed, and token Y on arbitrum, which is its minted representation:
| observed transfer | srcWasBurned | dstWasMinted |
|---|---|---|
| deposit, X → Y | false (X locked) | true (Y minted) |
| withdrawal, Y → X | true (Y burned) | false (X released) |
Both transfers say exactly the same thing about the pair: X is escrowed,
Y is its representation. So the roles are a stable property of the pair,
while the direction is not — and the roles are complementary, so naming
one endpoint names both. That is the one bit of information a relation
needs, and lockedToken is where it lives.
The earlier design instead stored the endpoints in the order the sample transfer happened to travel and let readers infer roles from that order. That silently made every arrow on the graph and every role in the deployed token’s Relations tab a coin flip, and it stored one real relation twice — once per observed direction — in opposite orientations that nothing could tell apart. Do not reintroduce a directional reading of the endpoint columns.
The table
TokenRelation is keyed by the identity of the pair:
(tokenAChain, tokenAAddress, tokenBChain, tokenBAddress,
plugin, bridgeType)
tokenA*/tokenB* are not a direction. They are two slots holding an
unordered pair, always in lexicographic order of (chain, address), so that
a pair has exactly one possible identity and the primary key enforces one row
per pair. They are named A and B rather than from/to precisely so that no
reader can mistake a slot for an origin: A is simply whichever endpoint sorts
first. A CHECK constraint on the table rejects any other order,
which is also how anyone reading the schema learns the columns are not a
direction. normalizeTokenRelation in
TokenRelationRepository.ts
puts a relation into that order, and every write path calls it. Addresses
are stored normalized (lowercase, Address32 cropped to Ethereum
addresses, same normalization as token ingestion).
These columns were once called tokenFrom*/tokenTo*. That spelling survives
in one place and will forever: TokenDbHistory stores the executed command as
an immutable JSON snapshot, so entries recorded before the rename still use the
old field names. The history page reads both spellings for that reason — see
LEGACY_ENDPOINT_FIELDS in
TokenHistoryPage.tsx.
Nothing else should ever accept the old names.
The remaining columns:
-
bridgeTypeisNOT NULL— a relation only exists for non-swapping types, so every row has one. -
lockedTokennames the slot holding the locked token:'A','B', orNULL— aCHAR(1), since one letter says everything there is to say. It is deliberately outside the primary key, which is what makes both of the following possible:NULLcan later be resolved by a plain update, and the two observed directions of one route cannot fragment into two rows.NULLmeans “no endpoint is identified as the locked one”, which covers two cases that are the same thing to every reader, disambiguated bybridgeType:bridgeTypelockedTokenNULLmeansburnAndMintnothing is locked — the pair is symmetric, both sides burn and mint. A terminal value, not a gap. lockAndMintno observation has identified the locked endpoint yet. Resolved as soon as one does. -
transferholds one full sample interop transfer as evidence. It is embedded (not referenced by id) because the interop transfer table is a sliding ~7-day window — the same reasoning as thenon-swapping-transferassignment proof onDeployedToken. The sample keeps its own observed direction; readsrcChain/dstChainfrom inside the JSON when displaying it, never the relation’s endpoint columns.
A pair of identical endpoints (same chain, same address) is not recorded: a token is trivially the same asset as itself, so the row would carry no information, and it has no canonical order.
The table size is bounded by the number of distinct bridged pairs, not by transfer volume.
How relations are ingested
TokenRelationIngestion
(TokenRelationIngestion.ts)
runs as the first step of the same one-minute background loop that drives
automatic token ingestion. The steps run
sequentially — never in parallel — so failures and logs are attributable
to a single step. The order (relations before the queue drain) is not a
correctness requirement, since relations do not depend on the token
catalogue at all; relations simply go first because the step is fast and
bounded while the drain can run long.
The algorithm is deliberately trivial:
- Read the cursor (
token-relations:lastSerialIdinTokenDbSettings) — a separate cursor from the queue pre-step’sinterop-transfers:lastSerialId, so either step can fail without stalling the other. - Page through interop transfers with
serialIdgreater than the cursor, in fixed-size batches, ordered byserialId. Do not replace the paging with one big read: loading full rows for all retained transfers has caused out-of-memory crashes before. - For each transfer: normalize both token addresses (skip if either side
has none, or if both sides are the same token), classify the bridge type
(stored value, or inferred from the burn/mint flags), and keep only
non-swapping types (
lockAndMint,burnAndMint). - Derive
lockedTokenfrom the transfer’s burn/mint flags viaInteropTransferClassifier.inferLockedTransferSide, then normalize the pair into stored order. This is the only place transfer semantics are translated into relation semantics. Downstream code never opens the evidence JSON to work out roles or direction — that is precisely the mistake this step exists to prevent. - For each candidate pair not already present in
TokenRelation, commit anAddTokenRelationCommandthroughcommitTokenChanges— the shared write boundary — so every relation insert lands inTokenDbHistorylike every other TokenDB write. - For each candidate that already exists with
lockedToken = NULL, where this transfer does identify the locked endpoint, commit anUpdateTokenRelationCommandfilling it in. An already-identifiedlockedTokenis never overwritten, so a relation’s role cannot flap. - Advance the cursor after each batch.
There is no staging, approval state, or conflict concept. Relations are observations; there is nothing to approve, and the history table provides the audit trail.
The insert is check-then-insert rather than an upsert so that it can go through the write boundary. The race window is irrelevant: this loop is the only automatic writer and runs serially; if a human inserts the same relation in the same instant, the tick fails loudly and the next tick sees the relation exists.
Why this is NOT part of the token ingestion queue
An earlier version of this feature materialized relations inside the token ingestion processor, as a side effect of processing a queue entry. Do not go back to that design. It was removed for two reasons, and both still apply:
- The queue’s unit of work is the wrong shape. A queue entry is a token address — “there is potentially new knowledge about this address, reprocess it”. A relation is a property of a transfer (a route between two addresses). Deriving relations from an address-keyed queue meant every entry had to re-scan its transfer evidence, deduplicate candidate relations against both endpoints, and thread relation lists through every plan/fetch/apply outcome. It roughly doubled the processor’s complexity for what is, standalone, a ~40-line loop over new transfers.
- Token-level conflicts must never suppress relation evidence. In
the embedded design, relations were only written when a queue entry
reached a successful outcome. Entries that ended in
conflict— which is common in production, and is exactly the situation relations are meant to diagnose — wrote nothing. The result observed in production: the relations most needed for merging wrongly-split abstract tokens were systematically the ones missing. That defeats the purpose of the table.
If some future requirement seems to demand coupling relation creation to token ingestion again, re-read the observation/interpretation model above first: the requirement is almost certainly about interpreting relations, and belongs in a read path, not in ingestion.
Why the burn/mint flags are NOT columns
An earlier version stored sourceWasBurned and destinationWasMinted as
NOT NULL boolean columns, both part of the primary key. This was a bug;
do not add them back as columns.
The interop transfer table’s srcWasBurned / dstWasMinted are
nullable. Null means “we did not observe this side” — routinely the case
for one-sided transfers, where only the source or only the destination
event was captured. In production, ~85% of stored-lockAndMint transfers
have at least one of these flags null. The old code coerced null to
false (transfer.srcWasBurned ?? false), which was wrong in three ways:
- It fabricated observations.
falseasserts “we saw that it was not burned”. We saw no such thing. In a table whose entire justification is “relations are facts”, inventing a fact is the cardinal sin. - It self-contradicted. A stored-
lockAndMinttransfer with both flags null became a row withbridgeType = lockAndMintand flags(false, false)— flags the classifier itself reads asnonMinting. - It fragmented routes. Because the flags were in the primary key, one real-world route observed once via a two-sided transfer and once via a one-sided one produced two rows, split by an artifact of which events happened to be indexed rather than by anything on-chain.
The fix: the flags are not relation columns at all. A relation’s identity
is (pair, plugin, bridgeType), and bridgeType is the authoritative,
plugin-declared (or, absent that, inferred) classification of the bridge’s
mechanism. The honestly-observed flags are not lost — they remain in the
transfer evidence JSON exactly as seen: present when observed, absent
when not. No tri-state column, nothing to fabricate, no pair
fragmentation.
Note this means a stored bridgeType is trusted even when the flags are
unobserved — deliberately, and consistently with how the token ingestion
processor already trusts a stored bridgeType as non-swapping-transfer
assignment evidence. Demanding observed flags instead would drop the
~80k+ one-sided non-swapping transfers and recreate the very
“missing relations” problem this subsystem exists to fix.
The flags are also not copied verbatim onto the relation under new names, which is the other tempting shortcut. Two reasons:
- They are transfer-shaped, not pair-shaped.
srcWasBurnedis a fact about one leg of one transaction; a relation summarizes an unbounded set of transfers over a pair. Copying one sample’s flags onto the row would assert that they are a property of the pair. - They carry no information the relation needs beyond
lockedToken. Two nullable booleans encode nine states, of which only two identify a locked endpoint for alockAndMintpair — and every read site would have to re-run that truth table.lockedTokenis that truth table applied once, at ingestion.
Nothing is lost by not copying them: the flags stay in the evidence JSON verbatim, so any future question about a specific sample can still be answered from there. A future question about the pair should get its own pair-shaped column derived at ingestion, not a raw transfer field.
Why there are NO foreign keys to DeployedToken
TokenRelation.tokenA* / tokenB* deliberately do not reference
DeployedToken. This is not an oversight — the constraints existed and
were removed. Do not “fix” the schema by adding them back without
re-reading this section.
- Relations must be recordable before their endpoints are catalogued. The typical case: a transfer reveals a brand-new token, but cataloguing it hits an ingestion conflict that takes a human days or weeks to resolve. The relation observation is valid the whole time. With enforced foreign keys it cannot be stored, and by the time the conflict is resolved the source transfers may have aged out of the 7-day retention window — the evidence is gone forever. Without the constraints, the edges are already sitting in the table when the token is finally added; the moment it appears, its graph neighborhood is complete. The alternative (skip un-insertable relations and re-scan history later) is confusing — a freshly resolved token would appear with zero edges despite transfers having driven its creation — and fixing that requires a deferred-relations side table or periodic re-scans: real machinery to reproduce what “no constraint” gives for free.
- Postgres has no partial foreign key. A foreign key on non-nullable
columns strictly requires the referenced row to exist at insert time
(
NOT VALIDonly skips validating pre-existing rows). The choice is binary: either the constraint blocks relations for unknown tokens, or there is no constraint. - The constraints provided no query capability. All queries are
handwritten Kysely joining on
(chain, address); the two endpoint indexes serve them. Prisma relation fields were only used for migrations, not queries.
What is given up, honestly:
- No database-level guarantee that a relation’s endpoints exist as deployed tokens. Endpoint existence is resolved at read time (see below).
- No
RESTRICTprotection when deleting a deployed token — which is actually the semantics we want (next section). - A garbage address in a transfer would be persisted. Mitigated by the same address normalization token ingestion uses, and bounded by the primary key (one row per pair).
Deleting a deployed token leaves its relations in place
Deleting a DeployedToken does not delete relations that mention its
address (the user planner used to cascade-delete them when the foreign
keys demanded it; it no longer does). The transfers still happened —
deleting the catalogue entry does not un-happen them. The relation simply
degrades to mentioning an uncatalogued address, and if the token is ever
re-added its edges are intact. Bogus relations can still be deleted
individually via the relation delete intent.
Display implications
Because endpoints may be uncatalogued, read paths that show relations
resolve endpoints against DeployedToken at query time and must tolerate
a miss. The deployed-token getRelations endpoint returns
otherToken: null for unknown endpoints and the UI renders the raw
address instead of a token link.
The deployed-token set is small enough to resolve in memory; this small read-time cost is the entire price paid for the foreign-key decision above.
getRelations returns one flat list, not an inbound/outbound split: the
endpoint columns are not a direction, so there is nothing to split on. Each
entry instead carries the queried token’s role in that relation, derived
from bridgeType and lockedToken:
role | meaning |
|---|---|
locked | this token is escrowed; the other is its minted representation |
minted | this token is minted by the plugin: the representation side of a lockAndMint pair, or either side of a burnAndMint pair |
unknown | a lockAndMint pair whose locked endpoint is not identified |
There is deliberately no symmetric role (there used to be one, shown for
burnAndMint pairs). A burnAndMint pair is symmetric, but from each
endpoint’s point of view that fact reads “minted” — the question the role
answers — and the bridge type, shown alongside, is what carries the
symmetry. A relation that is neither burnAndMint nor lockAndMint (a
human-added nonMinting row; ingestion never writes one) mints nothing and
shows unknown.
This is the answer to “which plugin minted this token, and which token is it
a representation of” — the question the Relations tab exists for. Read it
from role, never by comparing the endpoint columns.
The narrower question “which plugins mint this token” — asked by the public
frontend, which reads the token database directly rather than through
token-backend — is answered by
TokenRelationRepository.getMintingPluginsFor: the distinct plugins of the
relations where the token’s role is minted. Deliberately excluded: relations
where the token is locked, relations with an unknown role (one of their
endpoints is minted, but nothing says it is this one), and human-added
nonMinting relations, which mint nothing. Token-UI exposes the same query
as deployedTokens.getMintingPlugins and shows the list above the Relations
table, so the summary can be eyeballed against the roles in the table.
Relations graph
The graph page in token-ui is a view of the relation observations resolved
against the current token catalogue. Every observed (chain, address)
endpoint is a node, including endpoints that do not yet have a
DeployedToken row. Catalogued nodes are green and labelled on two lines with
their deployed token symbol and chain; uncatalogued nodes are orange and use a
shortened address followed by their chain. An edge is an observed token
relation: burn-and-mint edges are blue and non-directional, while lock-and-mint
edges are pink and carry an arrowhead pointing from the locked token to the
minted one. The arrow follows lockedToken, never the endpoint column order
— that order is lexicographic and says nothing about roles. A lock-and-mint
edge whose lockedToken is unidentified is drawn without an arrowhead rather
than guessing which token is the original. Nodes can be dragged and the canvas
can be panned or zoomed. Edge stroke widths remain constant while zooming, and
node visuals stop growing beyond 2x zoom so additional zoom creates useful
space between them. Above 2.5x zoom, each edge shows its relation plugin name at
its midpoint.
Before drawing, the UI treats every connected component as a cluster and sorts the clusters by endpoint count (largest first, with a stable id tie-break). Each cluster gets its own force simulation, which is run to completion in memory so clusters do not repel each other and users never see the graph settle. The finished clusters are placed left-to-right in a square-ish grid, starting at the top-left, then the whole grid is fitted into the viewport. At low zoom levels each cluster is overlaid with its most common catalogued deployed-token symbol. The overlay stays readable through the mid-zoom range, then shrinks and fades at extreme zoom-out to avoid overlapping nearby cluster labels.
Clicking a node keeps the node, its incident edges, and its neighbors prominent while dimming the rest of the graph. A non-modal details panel loads that one deployed token and its abstract token on demand; the initial graph payload does not contain full token records. The panel also lists the relations already present in the graph — one list, each entry labelled with the selected token’s role — rather than issuing a second database query for the neighborhood. Uncatalogued nodes show their raw endpoint information instead of token details.
Edges are independently hoverable and clickable. Clicking one highlights its two endpoints and loads only that relation’s full transfer evidence, including source and destination transaction hashes used for explorer links. This keeps the evidence JSON out of the initial graph response. The evidence panel labels those hashes with the chains recorded inside the evidence, because the sample transfer keeps its own observed direction, which the relation’s endpoint order does not describe.
The relation panel also carries a delete button — the tool for removing a
bogus observation that a buggy plugin’s interop transfer ingested. Any
relation can be deleted this way, not only anomalies. The button goes through
the standard delete intent (see Human edits), so the user
confirms a plan first; the confirmation notes that the executed command lands
in TokenDbHistory with the full removed record, from which the relation can
be recovered in the worst case. On success the edge simply disappears from the
drawing and from the panel’s relation lists. The layout is deliberately not
re-run. Removing an edge can split a cluster in two, and re-clustering —
new cluster grid, new cluster labels, reset viewport — would yank the graph
out from under a user mid-investigation on a view that takes seconds to
build. Refreshing the page is how one sees the re-clustered graph. For the
same reason, the graph query is never refetched automatically while the page
is open — executing a plan only marks it stale without refetching active
instances, and the page opts out of the window-focus and reconnect refetches
that would otherwise pick that staleness up — so fresh data loads only on
the next visit to the page.
The graph header can search catalogued deployed tokens by symbol, chain, or address using the already-loaded graph payload. Choosing a result selects the node, opens its existing details panel, and animates the viewport to a readable zoom around it. Full token and abstract-token details remain selection-time queries rather than being added to the initial payload.
An edge is an assignment anomaly when both endpoints are assigned to abstract tokens and those abstract token IDs differ. An unassigned or uncatalogued endpoint is not considered an anomaly. The default view keeps the bridge-type colors and does not draw anomalies red. An anomaly switch changes conflicting edges to red and mutes other edges to gray, so anomaly inspection does not compete with the default bridge-mechanism view.
The initial graph query reads only relation identity fields, lockedToken,
and the minimal endpoint display data. It deliberately excludes full
deployed/abstract token records and the transfer evidence JSON; dedicated
selection-time queries fetch one node or one relation detail record when
requested. lockedToken is in that payload precisely so the graph never has
to open the evidence to know which way an arrow points.
Human edits
Humans can add, update, and delete relations through the standard
intent → plan → execute pipeline. The add intent still validates that
both endpoints exist as deployed tokens — a human hand-typing a relation
to an uncatalogued address is almost certainly a mistake, while the
ingestion loop observing one is the whole point. Validation belongs to
the pipeline, not the storage.
The add planner normalizes the record before storing it: a human names the
two endpoints in whichever order they happen to think of them, and the pair
is unordered, so the stored order is derived rather than taken. lockedToken
moves with the endpoints when they are swapped, so the role a human stated is
preserved. The update intent can also set lockedToken directly, which is
how a human corrects a role the flags got wrong.
Known limitations
- The serial-id cursor can permanently skip a transfer whose row committed out of order (same accepted risk as the queue pre-step’s cursor).
- Relations only capture routes observed while the loop runs; transfers that aged out of the ~7-day retention before the loop first ran are not represented.
- A
lockAndMintrelation can sit atlockedToken = NULLindefinitely if no transfer on that route ever identifies a side. Two things cause this: a plugin that declaresbridgeType: 'lockAndMint'while its burn/mint flags stay unobserved, and a plugin whose declaredlockAndMintcontradicts the flags it did observe (both sides locked, or both supply-changing). Such relations are still recorded — identity is the table’s primary purpose and matters more than the role — they just display asunknown. The ingestion log’sresolvedLockedTokenscounter shows how often this self-corrects.
All are acceptable on a living system: active routes recur, and a missed observation is re-created by the next transfer on the same route.
Table of Contents
Intent / Plan / Execute
This document describes how user-driven edits to TokenDB flow through the
token-backend package. It is the model that sits behind every “Add
token”, “Update token”, “Delete token”, and token-relation edit coming
from the token-UI.
If you’re looking for how tokens are added automatically from interop transfers, that’s a different subsystem — see Automatic Token Ingestion.
TL;DR
Every write to TokenDB goes through three artefacts:
Intent ─► Plan ─► Commands ─► TokenDB
plan execute
- Intent (intents.ts) — what the user wants. A small, validated union: add/update/delete an abstract token, deployed token, or token relation, plus merging one abstract token into another.
- Plan (planning.ts) — what will happen if the intent is carried out: the original intent plus the ordered list of low-level Commands the backend will run. Generating a plan is read-only — it never mutates the DB.
- Commands (commands.ts) — the primitive write operations TokenDB knows how to execute (insert, update, delete on the three core tables).
- Execute (execution.ts) —
takes a
Planand applies its commands in a singleSERIALIZABLEtransaction. Before applying anything, it regenerates the plan from the same intent and refuses to proceed unless the new plan is byte-for- byte identical to the one the user confirmed.
The two tRPC procedures that expose this are plan.generate (intent → plan)
and plan.execute (plan → success/error), wired up in
trpc/routers/plan/index.ts.
On the frontend, PlanConfirmationDialog.tsx
shows the plan to the user, and only on Confirm does it call
plan.execute.
Each command executed from a confirmed manual plan is stored as its own
TokenDbHistory row, together with the intent that produced the plan. This
keeps history entries primitive and easy to inspect/revert, while preserving
the higher-level reason for multi-command operations such as abstract-token
merges.
Why this shape?
Two reasons, both load-bearing.
1. Show the user the full blast radius of their edit
A single user intent can — in principle — touch more than one record. Imagine a researcher reassigns a deployed token to a different abstract token. Depending on what’s connected to that deployed token (interop transfers, sibling deployments, etc.), the backend may want to propagate that change to keep the graph consistent. The user typed one edit; the system might need to write five.
The plan/confirm pattern means the user always sees the full set of
writes before approving. The confirmation dialog literally enumerates
each Command (“Deployed token X will be updated”, “Abstract token Y
will be added”) with diffs. There are no surprise mutations executed
after a click.
Most planners today are 1:1 — one intent produces one command. Multi-command plans are used when the user action has a larger blast radius. For example, merging one abstract token into another updates the target token’s additional CoinGecko entries, reassigns deployed tokens, and then deletes the source abstract token. The dialog already knows how to render the full command list.
2. Make concurrent edits safe without manual locking
Multiple researchers can be editing tokens in token-UI at the same time. Without protection, the classic race is:
- Alice opens token X, makes a change, gets a plan, takes a coffee.
- Bob edits token X meanwhile and saves.
- Alice clicks Confirm. Her plan was computed against state that no longer exists.
executePlan defends against this with two layers:
SERIALIZABLEtransaction. Postgres treats the whole execute step as if no other transaction were running. No interleaving with concurrent writers from other sessions.- Plan re-generation and deep-equality check. Inside the transaction
it calls
generatePlan(intent)again and compares the result to the plan the user is confirming usingisDeepStrictEqual. If anything has changed — a referenced row no longer exists, a uniqueness check now fails, or a propagation rule would now produce different commands — the execute fails with “Plan is no longer valid due to recent changes to the database” and the user is asked to re-plan.
The combination guarantees: what the user confirmed is exactly what gets written, or nothing gets written. There is no “we executed half the plan and then a conflict appeared”.
Table of Contents
Abstract token merging & additional CoinGecko entries
TokenDB’s core invariant is that every deployed token belongs to exactly one abstract token — abstract tokens are the “class”, deployed tokens the “instances”. In practice we accumulate duplicate abstract tokens: several abstract tokens that are really one asset. This document explains why that happens and why the fix is a first-class merge operation backed by multiple CoinGecko entries per abstract token.
Why duplicate abstract tokens exist
Two causes, both rooted in CoinGecko:
- CoinGecko splits assets we consider one. CoinGecko sometimes lists
what is, from our point of view, a single asset as two or three
separate coins, for reasons we don’t understand or agree with. As long
as an abstract token could hold only a single
coingeckoId, each extra CoinGecko coin forced us to create a separate abstract token. - Ingestion falls back to creating abstracts. Most tokens are added by automatic token ingestion. When it cannot resolve an existing abstract token — neither from transfer evidence nor by the CoinGecko id — but CoinGecko does know the deployed token’s address, it materializes a new abstract token from the CoinGecko coin. If the asset already existed under a different CoinGecko id, that’s a duplicate.
How duplicates surface
A non-swapping transfer is, by definition, a transfer of the same
abstract token — only the deployed token changes across it. So a
non-swapping relation between deployed tokens assigned to two
different abstract tokens contradicts the invariant. These show up as
conflict entries in the ingestion queue and as red edges in the
token-UI relations graph (see token relations).
Occasionally the plugin misclassified the transfer and it should have
been a swap — then the plugin is what needs fixing. But usually the
diagnosis is: two abstract tokens that should be one. Merging is how a
human resolves that.
Additional CoinGecko entries
An abstract token keeps its main coingeckoId plus a list of
additionalCoingeckoEntries. Only the main entry is used for pricing
and the icon. The additional entries serve two purposes:
- Informational — we keep the data from the absorbed CoinGecko coins in case it’s ever needed.
- Load-bearing for ingestion — ingestion resolves abstract tokens by CoinGecko id, so every CoinGecko id the asset is known under must stay attached to the abstract token. Drop one, and the next deployed token CoinGecko lists under that id would make ingestion recreate the duplicate we just merged away.
The merge operation
Merging abstract token B (source) into A (target) does three things, in
order: copy B’s CoinGecko entries onto A as additional entries, reassign
all of B’s deployed tokens to A, delete B. A Merged from <id>:<issuer>:<symbol> (...) note is also appended to A’s comment — this
happens even when B has no CoinGecko data and no entries get copied, so
the target always shows at a glance what was absorbed into it. It is a
single intent in the
intent → plan → execute pipeline, so the
user sees the full command list — every reassigned deployed token —
before confirming. The operation has no automatic undo; history retains
enough information to reconstruct the source token manually if a merge
turns out to be wrong.