The following is a specification for the Ether Deck Mk2 (deck). The deck is a smart contract account that can run external calls and extend its functionality with a mutable dispatcher.
Smart contract accounts for individuals are currently limited in selection and bloated in implementation due to excessive modularity. The minimal functionality of a smart contract account's core is the ability to make external calls and to extend its functionality with delegate calls. A reasonable extension to this is to batch both; allowing an externally owned account (EOA) to make multiple calls in a single transaction.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.
The application binary interface used for the deck is Solidity's ABI Specification.
The mod
address of a selector
to dispatch
if the selector
does not match
one of the selectors on the core deck.
- name: dispatch
type: function
stateMutability: view
inputs:
- name: selector
type: bytes4
outputs:
- name: mod
type: uint256
The runner
address that is authorized to take action on the deck.
The runner
MAY delegate actions through mod
addresses.
- name: runner
type: function
stateMutability: view
inputs: []
outputs:
- name: runner
type: address
Runs a call to a target
with a payload
and a callvalue
.
MAY forward entire callvalue
.
MAY return returndata
from target
.
MUST revert if caller is not runner
.
- name: run
type: function
stateMutability: payable
inputs:
- name: target
type: address
- name: payload
type: bytes
Runs an array of calls to targets
with payloads
and values
.
MAY return returndata
from each target
.
MUST revert if caller is not runner
.
- name: runBatch
type: function
stateMutability: payable
inputs:
- name: targets
type: address[]
- name: values
type: uint256[]
- name: payloads
type: bytes[]
outputs: []
Writes a mod
to a given selector
in dispatch
to be dispatched
if an external call to the deck does not contain a matching selector on the core
deck.
- name: setDispatch
type: function
stateMutability: nonpayable
inputs:
- name: selector
type: bytes4
- name: target
type: address
outputs: []
Writes a selectors
array a targets
array.
- name: setDispatchBatch
type: function
stateMutability: nonpayable
inputs:
- name: selectors
type: bytes4[]
- name: targets
type: address[]
outputs: []
The fallback
loads the mod from the dispatch
for the given
selector
and delegatecalls to it.
MUST forward all calldata to the mod
.
SHOULD return the returndata
from the delegatecall.
MUST revert if the dispatch
address for the selector
is zero.
- type: fallback
stateMutability: payable
inputs: []
outputs: []
Logged when setDispatch
sets a new address for the
dispatch
.
- name: DispatchSet
type: event
inputs:
- name: selector
indexed: true
type: bytes4
- name: target
indexed: true
type: address
Mods MUST use a custom, namespaced storage layout to avoid collisions with other
mods and the core deck. The following slot_preimage
is the string to be hashed
then the hash digest is subtracted by one to make the slot value's preimage
unknown.
<slot_preimage> = "EtherDeckMk2", ".", <mod_name>, <variable_name>;
<mod_name> = <solc_ident>;
<variable_name> = <solc_ident>;
<solc_ident> = (* solidity identifier *);
Example:
uint256 slot = uint256(keccak256("EtherDeckMk2.MyMod.myVariable")) - 1;
The rationale was to begin with minimal enshrined features and allow mods to be the extend the functionality of the deck.
Minimal restrictions were placed on the run
and runBatch
functions, as these
could contain additional logic including payload decoding.
ERC-4337 is bad.
No backward compatibility issues found.
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity 0.8.24;
contract EtherDeckMk2 {
event DispatchSet(bytes4 indexed selector, address indexed target);
mapping(bytes4 => address) public dispatch;
address public runner;
constructor(address firstRunner) payable {
runner = firstRunner;
}
function run(address target, bytes calldata payload) external payable {
require(runner == msg.sender);
(bool success,) = target.call{ value: msg.value }(payload);
require(success);
}
function runBatch(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata payloads
) external payable {
require(runner == msg.sender);
require(targets.length == values.length && targets.length == payloads.length);
for (uint256 i; i < targets.length; i++) {
(bool success,) = targets[i].call{ value: values[i] }(payloads[i]);
require(success);
}
}
function setDispatch(bytes4 selector, address target) external payable {
require(runner == msg.sender);
dispatch[selector] = target;
emit DispatchSet(selector, target);
}
function setDispatchBatch(bytes4[] calldata selectors, address[] calldata targets) external payable {
require(runner == msg.sender);
require(selectors.length == targets.length);
for (uint256 i; i < selectors.length; i++) {
dispatch[selectors[i]] = targets[i];
emit DispatchSet(selectors[i], targets[i]);
}
}
fallback(bytes calldata) external payable returns (bytes memory) {
address mod = dispatch[msg.sig];
require(mod != address(0));
(bool success, bytes memory returndata) = mod.delegatecall(msg.data);
require(success);
return returndata;
}
receive() external payable { }
}
Mods have complete write access to the deck, be aware of all storage interactions.
Mods that selfdestruct may selfdestruct the deck if set to the dispatcher.