I completely agree! The need for a standard is what is driving this proposal. A common spec will encourage an ecosystem of governance interfaces, analytics, aggregators, and whatever else people can dream up. Tally and other DAO aggregators will be able to scale with us into our multi-chain future. Let’s keep this conversation going.
Based on what you said, it sounds like the Gnosis Safe has a lot of modules that will make implementing the spec pretty straightforward. Having many reusable parts allows developers to move much more quickly. However: what is the “whole” that these parts form? If we view it like a vehicle, then we see that Gnosis has a great engine and nice wheels but what is the driving experience? How are the engine and wheels controlled? This is what we need to determine. This is the “aggregator” module you mention.
Finding the Abstraction
The specification needs to capture the right level of abstraction; it needs to be narrow in scope such that it is useful but also composable.
To me there are two important features to multi-chain governance:
1. Proposals include state changes across multiple chains
2. Token holders across multiple L2s and chains can vote on the proposals
Given that we don’t fully agree on how to tackle voting, let’s put voting to the side for the moment and focus on the first point: how can we standardize multi-chain proposals. This is where we have the most commonality right now. No matter how you vote, code needs to be agreed upon and executed on multiple chains.
Multi-Chain Proposals
At a bare minimum we should have a common interface to introspect multi-chain state change proposals. A user of the spec must be able to:
- Easily find proposals
- View the state changes
- Know whether a proposal has been executed
All signalling should be done through events and on-chain data (this is an EIP after all).
Find Proposals
Proposal data should not be stored on-chain. There could be many state changes in a proposal, which would make storage prohibitively expensive. Instead, we can identify proposals using a content hash: the contents of the proposal is hashed to form a unique and verifiable identifier. Let’s call this identifier the proposal hash
.
Depending on the implementation, proposal data includes state change data as well as consensus data. However, being a multi-chain system, execution and consensus may occur on different chains. We should separate that data. Let’s introduce another content hash for the state change data called the state change hash
.
We now have:
stateChangeHash = hash( stateChangeData )
proposalHash = hash( stateChangeHash, consensusData )
To make this data available off-chain we’re going to need two events.
Let’s emit the first event from a contract we will call the StateChangeOrigin
:
interface StateChangeOrigin {
event StateChangeCreated( stateChangeHash, ...stateChangeData );
}
This event includes the computed stateChangeHash
as an indexed topic to make proposal discovery easier.
The second event captures the consensus data. By its nature, consensus must occur in a single place, so let’s call the second contract the ConsensusRoot
interface ConsensusRoot {
event ProposalCreated( proposalHash, stateChangeHash, ...consensusData);
}
The ProposalCreated event emits the proposal hash as an indexed topic as well.
By listening for events from a group of StateChangeOrigin
and ConsensusRoot
contracts a viewer will be able to put together the whole picture of the proposal. These contracts may or may not live on the same chain, or they could even be the same contract!
View State Changes
Users must be able to see what the state changes are. We need to standardize the data format for multi-chain calls. What do we need to know for a call? At a minimum, we need to know:
- The
chainId
on which the call is occurring
- The
caller
who is the one doing the calling
- The
target
of the call
- The
calldata
for the call
We can define the “state change data” as being an array of structs of the above:
struct Call {
uint chainId;
address caller;
address target;
bytes callData;
}
interface StateChangeOrigin {
event StateChangeCreated( bytes32 indexed stateChangeHash, Call[] calls );
}
Now users can see what changes a proposal is going to make. For now, let’s hand-wave the details of stateChangeHash = hash(calls)
.
Know the Proposal State
Users need to know whether a proposal was successfully executed. This data should be available both on-chain and off-chain through events.
Given that a proposal can be executed on multiple chains, we’ll need to track execution per caller.
The Caller
contract is the one that executes the proposal, so it must provide an event and on-chain accessor:
interface Caller {
event Executed(bytes32 indexed proposalHash);
function wasExecuted(bytes32 proposalHash) external view returns (bool);
}
The Executed
event must be emitted when the execution occurs. The wasExecuted
function allows on-chain contracts to determine if a proposal passed, and behave accordingly.
Summary
Let’s bring the above all together:
struct Call {
uint chainId;
address caller;
address target;
bytes callData;
}
interface StateChangeOrigin {
event StateChangeCreated(
bytes32 indexed stateChangeHash,
Call[] calls
);
}
interface ConsensusRoot {
event ProposalCreated(
bytes32 indexed proposalHash,
bytes32 indexed stateChangeHash,
bytes consensusData
);
}
interface Caller {
event Executed(bytes32 indexed proposalHash);
function wasExecuted(bytes32 proposalHash) external view returns (bool);
}
These interfaces will allow a third party to:
- Find proposals (via indexing)
- View the state changes (by interpreting encoded event data)
- Know whether a proposal has been executed (by looking at multi-chain callers)
Open Questions
- Is this enough for a multi-chain proposal MVP? Does this spec need more? Is it too much?
- what details are missing?
- Add
value
and delegateCall
on the Call struct?
- Add
bytes extraData
on the event StateChangeOrigin
for extensibility?
- Is this spec flexible enough to support multiple voting EIPs?
Note on Gas
You can ballpark the gas usage using evm.codes. The most expensive part will be the hashing of calldata, so let’s calculate the hashing costs:
- Estimate 6 words per Call struct
- Estimate 6 calls per Proposal
- Estimate each proposal has 4 words of consensus data
Total bytes per proposal: (6*6+4) * 32 = 1280
Plug that into the SHA3
opcode and it costs 393 gas (worst-case). A cold SLOAD is 2100, so it’s cheaper than loading from storage.
Summary
This pared-down version of the EIP keeps the proposal standard, but is flexible enough for implementations to tackle consensus their own way.
What do you all think of this? Is this more narrow scope a better starting point?