RIP-7755: Cross-L2-Call
Contract standard for cross-L2 calls facilitation
Abstract
Contracts for facilitating request, fulfillment, and fulfillment reward of cross-L2 calls.
Motivation
Cross-chain actions are an increasingly important part of crypto user experience. Today, most solutions for Ethereum layer 2s (L2s) have one or more of the following drawbacks.
- Reliance on privatized relayers with offchain access and incentives.
- Reliance on protocols outside of Ethereum and its rollups.
- High-level, intent-based systems that do not allow specifying exact calls to make.
Ethereum L2s, which all write state to a shared execution environment, are uniquely positioned to offer an alternative. Ethereum L2 users should have access to a public, decentralized utility for making cross L2 calls.
From any L2 chain, users should be able to request a call be made on any other L2 chain. Users should be able to guarantee a compensation for this call being made, and thus be able to control the likelihood this call will be made.
User should have full assurance that compensation will only be paid if the call was made. This assurance should depend ONLY on onchain information.
Specification
To only rely on onchain information, we use
- Layer 1 (L1), i.e. Ethereum Mainnet, blockhashes or beacon roots on the L2.
- We take as an assumption that every Ethereum L2 should have a trusted L1 blockhash or state representation in the execution environment.
- Ethereum L2 blockhashes or state roots on L1.
- e.g. via an L2 Output Oracle Contract
Using these inputs, on any Ethereum L2, we can trustlessly verify ERC-1186 storage proofs of any other Ethereum L2.
Our contracts' job, then, is to represent call requests and fulfillment in storage on each chain.
Structs
Flow Diagrams
Happy Case
- User calls to an
RIP7755Outbox
contract withCrossChainRequest
and reward funds RIP7755Outbox
emits event for fulfillers to discover- Fulfiller relays
CrossChainRequest
toRIP7755Inbox
contract, including any funds possibly needed to successfully complete the call - If included,
RIP7755Inbox
makes a precheck call to validate fulfillment condition(s) RIP7755Inbox
makes the call as specified byCrossChainRequest
RIP7755Inbox
write to storage theFulfillmentInfo
receipt of the call- After
CrossChainRequest.finalityDelaySeconds
have elapsed, the fulfiller can submit the proof - If the proof is valid and the call was successfully made, fulfiller is paid reward
RIP7755Outbox Contract
On the origin chain, there is an outbox contract to receive cross-chain call requests and payout rewards on proof of their fulfillment.
RIP7755Inbox Contract
On the destination chain, there is an inbox contract to store a receipt of the call fulfillment.
PrecheckContract Interface
On the destination chain, any valid precheck contract must adhere to the following interface.
Storage Proof Validation
The implementation details for successful storage proof validation will vary depending on the destination chain. However, all implementations will adhere to the following fundamental pattern:
- Verify that the beacon root used for the proof corresponds to the root exposed in the source chain's execution environment.
- Validate the L1 execution client's state root against the beacon root.
- Validate the destination chain's output contract storage root against the L1 execution client's state root.
- Validate the destination chain's state root against the destination chain's output contract storage root.
- Validate the destination chain's inbox contract storage root against the destination chain's state root.
- Validate the
FulfillmentInfo
struct at the correct storage key against the destination chain's inbox contract storage root.
It is important to note that not all L2 chains directly store their state root on L1. In certain cases, the L2 chain stores an abstract "output root" which must be connected to its state root in some manner. In these instances, the storage proof validation necessitates an intermediate step between steps 4 and 5. This step involves providing the destination chain's state root along with custom logic to derive the output root using that state root and any other required information. This step is only considered successful if the derived output root matches the value proven to be stored in the destination chain's output contract on L1.
Example Usage
These examples are not intended to be comprehensive of every detail. First example is more verbose, in hopes of giving helpful understanding for all examples.
Transfer native asset across chains.
User at Address A on Chain X wants to send 0.1 ether to Address B on Chain Y.
On Chain X, Address A calls requestCrossChainCall
on a RIP7755Outbox
contract of their choosing. CrossChainRequest.calls
contains a single call.
The CrossChainRequest
includes info about the destination chain and RIP7755Inbox
contract the user wants the call to be made through.
The destination chain has a 7 day challenge period, and so the user sets CrossChainRequest.finalityDelaySeconds
to a 7 day equivalent for maximum security.
When calling to requestCrossChainCall
on origin chain, the user sends 0.1001 ether in value, which matches CrossChainRequest.rewardAmount
. The excess above 0.1 ether is intended to exceed the gas cost of the call on destination chain and serve as a compensation to the fulfiller. This reward amount would need to provide sufficient incentive for the fulfiller to wait CrossChainRequest.finalityDelaySeconds
, in this case 7 days, to get their reward.
Include custom exclusivity period for a specified fulfiller
To enhance the previous example, we introduce a precheck condition where Address A authorizes only a specific fulfiller, referred to as Fulfiller A, to submit the transaction to the destination chain for a specified period of time.
To implement this, custom exclusivity logic must be incorporated into a precheck smart contract (PCSM) deployed on the destination chain. The CrossChainRequest
will include the PCSM address concatenated with the encoded data for exclusivity validation in the extraData
field.
Once Address A invokes requestCrossChainCall
on the origin chain, if an unintended fulfiller attempts to submit the transaction on the destination chain within the exclusivity period, the fulfill
function call will revert due to the precheck failure. This ensures that only Fulfiller A can call fulfill
before the expirationTimestamp
.
Transfer ERC20 asset across chains.
User at Address A on Chain X wants to send 100 USDC to Address B on Chain Y.
ERC20 transfers have unique challenges in our paradigm, because
- The caller needs to specify the exact calls to make.
- The calls must be made through the inbox contract.
We show two example solutions below.
1. With known fulfiller address
The following example requires the caller to know ahead of time the fulfillers address. This is not ideal because (1) the calls will only work for one fulfiller (2) requires offchain pre-coordination.
- Origin Chain:
- Pre-steps:
- Address A calls to USDC contract on origination chain to approve
RIP7755Outbox
to move 100 USDC.
- Address A calls to USDC contract on origination chain to approve
- Address A calls to outbox, with a two calls in
CrossChainRequest.calls
- Pre-steps:
- Destination Chain
- Pre-steps
- Fulfiller calls to USDC contract on destination chain to approve
RIP7755Inbox
to move 100 USDC.
- Fulfiller calls to USDC contract on destination chain to approve
- Fulfiller calls
fulfill
onRIP7755Inbox
- In the first call of
CrossChainRequest.calls
, 100 USDC is sent from fulfiller toRIP7755Inbox
. - In second call, 100 USDC is sent from
RIP7755Inbox
to Address B.
- In the first call of
- Pre-steps
2. With helper contract
We could also solve the challenge by introducing a helper contract for facilitating the ERC20 transfer. This helper contract would accept calls in the format transfer(address asset, address to, uint256 amount)
and then would use tx.origin
to determine the from
for the ERC20 transferFrom
call (and check msg.sender
is some known RIP7755Inbox
contract). This would rely on fulfillers pre-depositing ERC20s in this helper contract, or having approvals set so it can pull funds at any time.
[!NOTE]
In future drafts, we may specify an implementation ofHelperContract
. It may also be convenient for fulfillers to be able to auth with signature. This could maybe be accomplished via somecontext
that could be passed tofulfill
and stored inRIP7755Inbox
for the duration of the call.
- Origin Chain:
- Pre-steps:
- Address A calls to USDC contract on origination chain to approve
RIP7755Outbox
to move 100 USDC.
- Address A calls to USDC contract on origination chain to approve
- Address A calls to outbox, with a one call in
CrossChainRequest.calls
- Pre-steps:
- Destination Chain
- Pre-steps
- Fulfiller calls to USDC contract on destination chain to approve
HelperContract
to move 100 USDC.
- Fulfiller calls to USDC contract on destination chain to approve
- Fulfiller calls
fulfill
onRIP7755Inbox
- Call transfers 100 USDC to Address B via
HelperContract
.
- Call transfers 100 USDC to Address B via
- Pre-steps
ERC20 swap on Chain B using assets from Chain A.
TODO
Pay gas on Chain A for a smart account transaction on Chain B.
TODO
Example _validate implementation
OP Stack
The following library is an example of how storage proof validation can be implemented for an OP Stack chain.
Where the StateValidator
structs are defined as follows:
This implementation example is designed with the assumption that the chain it is deployed on supports EIP-4788. It adheres to the general storage proof validation pattern previously outlined. The following lines highlight the specifics that are unique to the OP Stack:
This example leverages Optimism's AnchorStateRegistry
contract to utilize the most recent anchor state available at the time of proof. The anchor state represents the finalized state of the destination L2 chain. To verify the state root of the destination chain, we must provide the pre-image of the chain's anchor state output root. As of this writing, the pre-image consists of four bytes32
values: version
, l2StateRoot
, l2MessagePasserStorageRoot
, and l2BlockHash
.
The l2StateRoot
and l2BlockHash
are extracted and derived from the RLP-encoded block headers array. Specifically, the l2StateRoot
is the fourth element in this array, while the l2BlockHash
is the hash of the entire RLP-encoded block headers array. With this information, we can compute the expected output root.
If the derived output root does not match the value stored in the L1 storage slot, the proof is deemed invalid.
Arbitrum
The following library is an example of how storage proof validation can be implemented for Arbitrum.
The process of verifying this proof is similar to the OP Stack example, but with specific nuances for Arbitrum. Below are the lines of code that are tailored for Arbitrum's unique architecture:
This step is crucial for verifying the authenticity of the destination L2 chain's state root. In Arbitrum, the confirmData
field within an RBlock is utilized as the L1 storage value that we have proven. Similar to the OP Stack example, we must derive the expected storage value on L1 using the state root. Specifically, this is achieved by hashing the l2BlockHash
together with the sendRoot
found in the proof data structure. Given that the L2 state root is included in the pre-image of the block hash, a match between the derived value and the verified storage value confirms the authenticity of the state root.