The following standard provides a mechanism in which smart contracts can request various tasks to be resolved by an external handler. This provides a mechanism in which protocols can reduce the gas fees associated with storing data on mainnet by deferring the handling of it to another system/network. These external handlers act as an extension to the core L1 contract.
This standard outlines a set of handler types that can be used for managing the execution and storage of mutations (tasks), as well as their corresponding tradeoffs. Each handler type has associated operational costs, finality guarantees, and levels of decentralization. By further specifying the type of handler that the mutation is deferred to, the protocol can better define how to permission and secure their system.
This standard can be implemented in conjunction with EIP-3668 to provide a mechanism in which protocols can reside on and be interfaced through an L1 contract on mainnet, while being able to resolve and mutate data stored in external systems.
EIP-3668 provides a mechanism by which off-chain lookups can be defined inside smart contracts in a transparent manner. In addition, it provides a scheme in which the resolved data can be verified on-chain. However, there lacks a standard by which mutations can be requested through the native contract, to be performed on the off-chain data. Furthermore, with the increase in L2 solutions, smart contract engineers have additional tools that can be used to reduce the storage and transaction costs of performing mutations on the Ethereum mainnet.
A specification that allows smart contracts to defer the storage and resolution of data to external handlers facilitates writing clients agnostic to the storage solution being used, enabling new applications that can operate without knowledge of the underlying handlers associated with the contracts they interact with.
Examples of this include: - Allowing the management of ENS domains externally resolved on an L2 solution or off-chain database as if they were native L1 tokens. - Allowing the management of digital identities stored on external handlers as if they were in the stored in the native L1 smart contract.
There are two main handler classifications: L2 Contract and Off-Chain Database. These are determined based off of where the handler is deployed. The handler classifications are used to better define the different security guarantees and requirements associated with its deployment.
From a high level: - Handlers hosted on an L2 solution are EVM compatible and can use attributes native to the Ethereum ecosystem (such as address) to permission access. - Handlers hosted on an Off-Chain Database require additional parameters and signatures to correctly enforce the authenticity and check the validity of a request.
A deferred mutation can be handled in as little as two steps. However, in some cases the mutation might be deferred multiple times.
In step 1, a standard blockchain call operation is made to the contract. The contract either performs the operation as intended or reverts with an error that specifies the type of handler that the mutation is being deferred to and the corresponding parameters required to perform the subsequent mutation. There are two types of errors that the contract can revert with, but more may be defined in other EIPs:
StorageHandledByL2(chainId, contractAddress)
StorageHandledByOffChainDatabase(sender, url, data)
In step 2, the client builds and performs a new request based off of the type of error received in (1). These handshakes are outlined in the sections below:
In some cases, the mutation may be deferred multiple times - Storage Deferred Twice L1 > L2 > Off-Chain
┌──────┐ ┌───────────┐
│Client│ │L1 Contract│
└──┬───┘ └─────┬─────┘
│ │
│ somefunc(...) │
├─────────────────────────►│
│ │
│ response │
│◄─────────────────────────┤
│ │
In the case in which no reversion occurs, data is stored in the L1 contract when the transaction is executed.
┌──────┐ ┌───────────┐ ┌─────────────┐
│Client│ │L1 Contract│ │ L2 Contract │
└──┬───┘ └─────┬─────┘ └──────┬──────┘
│ │ │
│ somefunc(...) │ │
├────────────────────────────────────────────────────►│ │
│ │ │
│ revert StorageHandledByL2(chainId, contractAddress) │ │
│◄────────────────────────────────────────────────────┤ │
│ │ │
│ Execute Tx [chainId] [contractAddress] [callData] │ │
├─────────────────────────────────────────────────────┼──────────────►│
│ │ │
│ response │ │
│◄────────────────────────────────────────────────────┼───────────────┤
│ │ │
The call or transaction to the L1 contract reverts with the StorageHandledByL2(chainId, contractAddress)
error.
In this case, the client builds a new transaction for contractAddress
with the original callData
and sends it to a RPC of their choice for the corresponding chainId
. The chainId
parameter corresponds to an L2 Solution that is EVM compatible.
Suppose a contract has the following method:
function setAddr(bytes32 node, address a) external;
Data for this mutations is stored and tracked on an EVM compatible L2. The contract author wants to reduce the gas fees associated with the contract, while maintaining the interoperability and decentralization of the protocol. Therefore, the mutation is deferred to a off-chain handler by reverting with the StorageHandledByL2(chainId, contractAddress)
error.
One example of a valid implementation of setAddr
would be:
function setAddr(bytes32 node, address a) external {
revert StorageHandledByL2(
10,
_l2HandlerContractAddress
);
}
For example, if a contract returns the following data in an StorageHandledByL2
:
chainId = 10
contractAddress = 0x0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff
The user, receiving this error, creates a new transaction for the corresponding chainId
, and builds a transaction with the original callData
to send to contractAddress
. The user will have to choose an RPC of their choice to send the transaction to for the corresponding chainId
.
┌──────┐ ┌───────────┐ ┌────────────────────┐
│Client│ │L1 Contract│ │ Off-Chain Database │
└──┬───┘ └─────┬─────┘ └──────────┬─────────┘
│ │ │
│ somefunc(...) │ │
├────────────────────────────────────────────────────►│ │
│ │ │
│ revert StorageHandledByOffChainDatabase(sender, | │
│ urls, requestParams) │ │
│◄────────────────────────────────────────────────────┤ │
│ │ │
│ HTTP Request [requestParams, signature] │ │
├─────────────────────────────────────────────────────┼──────────────────►│
│ │ │
│ response │ │
│◄────────────────────────────────────────────────────┼───────────────────┤
│ │ │
The call or transaction to the L1 contract reverts with the StorageHandledByOffChainDatabase(sender, url, data)
error.
In this case, the client performs a HTTP POST request to the gateway service. The gateway service is defined by url
. The body attached to the request is a JSON object that includes sender
, data
, and a signed copy of data
denoted signature
. The signature is generated according to a EIP-712, in which a typed data signature is generated using domain definition, sender
, and the message context, data
.
sender
ia an ABI-encoded struct defined as:
/**
* @notice Struct used to define the domain of the typed data signature, defined in EIP-712.
* @param name The user friendly name of the contract that the signature corresponds to.
* @param version The version of domain object being used.
* @param chainId The ID of the chain that the signature corresponds to (ie Ethereum mainnet: 1, Goerli testnet: 5, ...).
* @param verifyingContract The address of the contract that the signature pertains to.
*/
struct domainData {
string name;
string version;
uint64 chainId;
address verifyingContract;
}
data
ia an abi encoded struct defined as:
/**
* @notice Struct used to define the message context used to construct a typed data signature, defined in EIP-712,
* to authorize and define the deferred mutation being performed.
* @param functionSelector The function selector of the corresponding mutation.
* @param sender The address of the user performing the mutation (msg.sender).
* @param parameter[] A list of <key, value> pairs defining the inputs used to perform the deferred mutation.
*/
struct messageData {
bytes4 functionSelector;
address sender;
parameter[] parameters;
uint256 expirationTimestamp;
}
/**
* @notice Struct used to define a parameter for Off-Chain Database Handler deferral.
* @param name The variable name of the parameter.
* @param value The string encoded value representation of the parameter.
*/
struct parameter {
string name;
string value;
}
signature
is generated by using the sender
& data
parameters to construct an EIP-712 typed data signature.
The body used in the HTTP POST request is defined as:
{
"sender": "<abi encoded domainData (sender)>",
"data": "<abi encoded messageData (data)>",
"signature": "<EIP-712 typed data signature of corresponding message data & domain definition>"
}
Suppose a contract has the following method:
function setAddr(bytes32 node, address a) external;
Data for this mutations is stored and tracked in some kind of off-chain database. The contract author wants the user to be able to authorize and make modifications to their Addr
without having to pay a gas fee. Therefore, the mutation is deferred to a off-chain handler by reverting with the StorageHandledByOffChainDatabase(sender, url, data)
error.
One example of a valid implementation of setAddr
would be:
function setAddr(bytes32 node, address a) external {
IWriteDeferral.parameter[] memory params = new IWriteDeferral.parameter[](3);
params[0].name = "node";
params[0].value = BytesToString.bytes32ToString(node);
params[1].name = "coin_type";
params[1].value = Strings.toString(coinType);
params[2].name = "address";
params[2].value = BytesToString.bytesToString(a);
revert StorageHandledByOffChainDatabase(
IWriteDeferral.domainData(
{
name: WRITE_DEFERRAL_DOMAIN_NAME,
version: WRITE_DEFERRAL_DOMAIN_VERSION,
chainId: 1,
verifyingContract: address(this)
}
),
_offChainDatabaseUrl,
IWriteDeferral.messageData(
{
functionSelector: msg.sig,
sender: msg.sender,
parameters: params,
expirationTimestamp: block.timestamp + _offChainDatabaseTimeoutDuration
}
)
);
}
For example, if a contract reverts with the following:
StorageHandledByOffChainDatabase(
(
"CoinbaseResolver",
"1",
1,
0x32f94e75cde5fa48b6469323742e6004d701409b
),
"https://example.com/r/{sender}",
(
0xd5fa2b00,
0x727f366727d3c9cc87f05d549ee2068f254b267c,
[
("node", "0x418ae76a9d04818c7a8001095ad01a78b9cd173ee66fe33af2d289b5dc5f4cba"),
("coin_type", "60"),
("address", "0x727f366727d3c9cc87f05d549ee2068f254b267c")
],
181
)
)
The user, receiving this error, constructs the typed data signature, signs it, and performs that request via a HTTP POST to url
.
Example HTTP POST request body including requestParams
and signature
:
{
"sender": "<abi encoded domainData (sender)>",
"data": "<abi encoded messageData (data)>",
"signature": "<EIP-712 typed data signature of corresponding message data & domain definition>"
}
Note that the message could be altered could be altered in any way, shape, or form prior to signature and request. It is the backend's responsibility to correctly permission and process these mutations. From a security standpoint, this is no different then a user being able to call a smart contract with any params they want, as it is the smart contract's responsibility to permission and handle those requests.
┌──────┐ ┌───────────┐ ┌─────────────┐ ┌────────────────────┐
│Client│ │L1 Contract│ │ L2 Contract │ │ Off-Chain Database │
└──┬───┘ └─────┬─────┘ └──────┬──────┘ └──────────┬─────────┘
│ │ │ │
│ somefunc(...) │ │ │
├────────────────────────────────────────────────────►│ │ │
│ │ │ │
│ revert StorageHandledByL2(chainId, contractAddress) │ │ │
│◄────────────────────────────────────────────────────┤ │ │
│ │ │ │
│ Execute Tx [chainId] [contractAddress] [callData] │ │ │
├─────────────────────────────────────────────────────┼──────────────►│ │
│ │ │ │
│ revert StorageHandledByOffChainDatabase(sender, url, data) │ │
│◄────────────────────────────────────────────────────┼───────────────┤ │
│ │ │ │
│ HTTP Request {requestParams, signature} │ │ │
├─────────────────────────────────────────────────────┼───────────────┼───────────────────►│
│ │ │ │
│ response │ │ │
│◄────────────────────────────────────────────────────┼───────────────┼────────────────────┤
│ │ │ │
The call or transaction to the L1 contract reverts with the StorageHandledByL2(chainId, contractAddress)
error.
In this case, the client builds a new transaction for contractAddress
with the original callData
and sends it to a RPC of their choice for the corresponding chainId
.
That call or transaction to the L2 contract then reverts with the StorageHandledByOffChainDatabase(sender, url, data)
error.
In this case, the client then performs a HTTP POST request against the gateway service. The gateway service is defined by url
. The body attached to the request is a JSON object that includes sender
, data
, and signature
-- a typed data signature corresponding to EIP-712.
When making changes to core variables of the handler, the corresponding event MUST be emitted. This increases the transparency associated with different managerial actions. Core variables include chainId
and contractAddress
for L2 solutions and url
for Off-Chain Database solutions. The events are outlined below in the WriteDeferral Interface.
Below is a basic interface that defines and describes all of the reversion types and their corresponding parameters.
pragma solidity ^0.8.13;
interface IWriteDeferral {
/*//////////////////////////////////////////////////////////////
EVENTS
//////////////////////////////////////////////////////////////*/
/// @notice Event raised when the default chainId is changed for the corresponding L2 handler.
event L2HandlerDefaultChainIdChanged(uint256 indexed previousChainId, uint256 indexed newChainId);
/// @notice Event raised when the contractAddress is changed for the L2 handler corresponding to chainId.
event L2HandlerContractAddressChanged(uint256 indexed chainId, address indexed previousContractAddress, address indexed newContractAddress);
/// @notice Event raised when the url is changed for the corresponding Off-Chain Database handler.
event OffChainDatabaseHandlerURLChanged(string indexed previousUrl, string indexed newUrl);
/*//////////////////////////////////////////////////////////////
STRUCTS
//////////////////////////////////////////////////////////////*/
/**
* @notice Struct used to define the domain of the typed data signature, defined in EIP-712.
* @param name The user friendly name of the contract that the signature corresponds to.
* @param version The version of domain object being used.
* @param chainId The ID of the chain that the signature corresponds to (ie Ethereum mainnet: 1, Goerli testnet: 5, ...).
* @param verifyingContract The address of the contract that the signature pertains to.
*/
struct domainData {
string name;
string version;
uint64 chainId;
address verifyingContract;
}
/**
* @notice Struct used to define the message context used to construct a typed data signature, defined in EIP-712,
* to authorize and define the deferred mutation being performed.
* @param functionSelector The function selector of the corresponding mutation.
* @param sender The address of the user performing the mutation (msg.sender).
* @param parameter[] A list of <key, value> pairs defining the inputs used to perform the deferred mutation.
*/
struct messageData {
bytes4 functionSelector;
address sender;
parameter[] parameters;
uint256 expirationTimestamp;
}
/**
* @notice Struct used to define a parameter for off-chain Database Handler deferral.
* @param name The variable name of the parameter.
* @param value The string encoded value representation of the parameter.
*/
struct parameter {
string name;
string value;
}
/*//////////////////////////////////////////////////////////////
ERRORS
//////////////////////////////////////////////////////////////*/
/**
* @dev Error to raise when mutations are being deferred to an L2.
* @param chainId Chain ID to perform the deferred mutation to.
* @param contractAddress Contract Address at which the deferred mutation should transact with.
*/
error StorageHandledByL2(
uint256 chainId,
address contractAddress
);
/**
* @dev Error to raise when mutations are being deferred to an Off-Chain Database.
* @param sender the EIP-712 domain definition of the corresponding contract performing the off-chain database, write
* deferral reversion.
* @param url URL to request to perform the off-chain mutation.
* @param data the EIP-712 message signing data context used to authorize and instruct the mutation deferred to the
* off-chain database handler.
* In order to authorize the deferred mutation to be performed, the user must use the domain definition (sender) and message data
* (data) to construct a type data signature request defined in EIP-712. This signature, message data (data), and domainData (sender)
* are then included in the HTTP POST request, denoted sender, data, and signature.
*
* Example HTTP POST request:
* {
* "sender": <abi encoded domainData (sender)>,
* "data": <abi encoded message data (data)>,
* "signature": <EIP-712 typed data signature of corresponding message data & domain definition>
* }
*
*/
error StorageHandledByOffChainDatabase(
domainData sender,
string url,
messageData data
);
}
In some cases the contract might conditionally defer and handle mutations, in which case a transaction may be required. It is simple to use this method for sending transactions that may result in deferral reversions, as a client should receive the corresponding reversion while preflighting
the transaction.
This functionality is ideal for applications that want to allow their users to define the security guarantees and costs associated with their actions. For example, in the case of a decentralized identity profile, a user might not care if their data is decentralized and chooses to defer the handling of their records to the off-chain handler to reduce gas fees and on-chain transactions.
revert
to convey call informationEIP-3668 adopted the idea of using a revert
to convey call information. It was proposed as a simple mechanism in which any pre-existing interface or function signature could be satisfied while maintain a mechanism to instruct and trigger an off-chain lookup.
This is very similar for the write deferral protocol, defined in this EIP; without any modifications to the ABI or underlying EVM, revert
provides a clean mechanism in which we can "return" a typed instruction - and the corresponding elements to complete that action - without modifying the signature of the corresponding function. This makes it easy to comply with pre-existing interfaces and infrastructure.
By further defining the class of the handler, it gives the developer increased granularity to define the characteristics and different guarantees associated storing the data off-chain. In addition, different handlers require different parameters and verification mechanisms. This is very important for the transparency of the protocol, as they store data outside of the native ethereum ecosystem. Common implementations of this protocol could include storing non-operational data in L2 solutions and off-chain databases to reduce gas fees, while maintaining open interoperability.
Existing contracts that do not wish to use this specification are unaffected. Clients can add support for Cross Chain Write Deferrals to all contract calls without introducing any new overhead or incompatibilities.
Contracts that require Cross Chain Write Deferrals will not function in conjunction with clients that do not implement this specification. Attempts to call these contracts from non-compliant clients will result in the contract throwing an exception that is propagated to the user.
Deferred mutations should never resolve to mainnet ethereum. Such attempts to defer the mutation back to ETH could include hijacking attempts in which the contract developer is trying to get the user to sign and send a malicious transaction. Furthermore, when a transaction is deferred to an L2 system, it must use the original calldata
, this prevents against potentially malicious contextual changes in the transaction.
As all deferred mutations will include the msg.sender
parameter in data
, it is possible that StorageHandledByOffChainDatabase
reversions could fingerprint wallet addresses and the corresponding IP address used to make the HTTP request. The impact of this is application-specific and something the user should understand is a risk associated with off-chain handlers. To minimize the security impact of this, we make the following recommendations:
StorageHandledByOffChainDatabase
calls - either by rewriting them to use a proxy service, or by denying them entirely. This mechanism or another should be written so as to easily facilitate adding domains to allowlists or blocklists.We encourage applications to be as transparent as possible with their setup and different precautions put in place.
Copyright and related rights waived via CC0.