September 1, 2023
Recently I had the pleasure of learning to use Tenderly’s Simulation endpoint to simulate a complex transaction: I had to fake a Safe multisig vault owner signing and proposing a transaction to a Compound Governor-style DAO, and the general vault address wasn’t an approved proposer, nor did I have any private keys to sign with.
Along the way I had to calculate the storage slot and value of a mapping(mapping==>struct) where the struct variables were packed in a single slot.
If you are trying to calculate a complex storage location, here is how to do so, with a state override example to fool the voting token contract (COMP or similar) into believing the multisig vault sender actually has cast enough previous votes to qualify for submitting a new proposal.
The state overrides being submitted to the COMP contract on Tenderly are as follows (but stay tuned for the additional Safe threshold override--we’ll get to that later):
COMP Token contract: checkpoints[<MULTISIG_MAIN_ADDRESS>][0].votes = 25000000000000000000005
COMP Token contract: checkpoints[<MULTISIG_MAIN_ADDRESS>][0].fromBlock = 17035677
Where our Checkpoint struct is:
struct checkpoint {
uint32 fromBlock;
uint96 votes;
}
It is critical to note that only a single slot address and value pair are given for both state overrides on the 2-item checkpoint struct in the COMP Token override in code, since these are packed in adjacent slots in EVM storage. It looks something like:
const STATE_OVERRIDE_TWO_VALUES = {
['0x<SOME_STORAGE_ADDRESS>']:'0x000000000000000000000000000000000000054b40b1f852bda000050103f19d'
}
There are several ways to calculate this, either in the Tenderly simulation UI or with solc's --storage-layout flag, evm.storage; or see example at https://docs.soliditylang.org/en/v0.8.15/internals/layout_in_storage.html#mappings-and-dynamic-arrays. We can also do it by hand.
The storage slot calculation is as follows:
The mapping declaration for checkpoints
on COMP is at slot 3 (go ahead and verify on evm.storage by pasting in COMP’s address).
keccak256(k . p) is where the mapping from slot 3 (that’s p) stores members of key k, where p is concatenated to k
…note that for value types the bits are stored little-endian and must be left-padded with zeros, while for strings and byte arrays it's just unpadded data, big-endian style.
Now concatenate the padded uint256 slot number after uint256 representation of the key:
keccak256(uint256(<SENDER_MULTISIG_VAULT_ADDRESS> . uint256(3)))
Now concatenate this with the second value of mapping[a][b]
with the same key-value rules:
keccak256(uint256(0).keccak256(uint256(<MULTISIG_ADDRESS> . uint256(3))))
this gets us slot 0 of our struct:
struct checkpoint {
uint32 fromBlock;
uint96 votes;
}
but how do we write data if two struct members are sharing the same storage slot!?
Well, since the EVM packs struct members in slots, we know these two are sharing a slot but only using the last 128 bits, which will be packed from first to last in the lowest digits as a base-16 hex representation, where each hex digit represents 4 bits of our 128 bits of the 256-bit memory slot used by the struct.
So uint32 fromBlock should have 8 hex digits and be packed first in the 64-digit hex number. If we use block number 17035671, that should look like:
0x00000000..........0103F19D
....and looking at the hex representation 25000000000000000000005 for our uint96 votes variable, it's:
0x0000000...54B40B1F852BDA00005
Now concatenate them, making sure you're capturing the single zero on the left of the fromBlock since value types are left-padded with zeros à la little-endian:
0x000000000000000000000000000000000000054b40b1f852bda000050103f19d
Et voilà: you now have the data payload for the state override to slot keccak256(uint256(0).keccak256(uint256(<SENDER_MULTISIG_VAULT_ADDRESS> . uint256(3)))) on the COMP Token contract's checkpoint mapping so that your Multisig sender can appear to be a legitimate proposer when running a Tenderly transaction simulation.
P.S. If you found this article while actually trying to simulate a DAO proposal on a Safe vault, you’ll also need to override the vault’s voting threshold to 1 signature and spoof a prevalidated signature. First you’ll do something like this for the storage value of the state override:
const THRESHOLD_ONE_STORAGE_OVERRIDE = {
[`0x${'4'.padStart(64, '0')}`]: `0x${'1'.padStart(64, '0')}`,
}
…and then you’ll need to fake a pre-validated tx signature of a vault owner/spoofed msg.sender like this:
const getPreValidatedSignature = (address: string): string => {
return `0x000000000000000000000000${address.replace(
'0x',
'',
)}000000000000000000000000000000000000000000000000000000000000000001`
}
and include that signature in the SignedSafeTransaction extension of SafeTransaction for programmatic usage:
const signedSafeTransaction: SignedSafeTransaction = {
...safeTransaction,
signatures: getPreValidatedSignature(tx.spoofedSigner),
}
The above SignedSafeTransaction is:
interface SignedSafeTransaction extends SafeTransaction {
signatures: string
}
and is an extension of SafeTransaction, both of which we cite as interfaces in the script:
interface SafeTransaction {
to: string
value: string
data: string
safeTxGas: string
baseGas: string
gasPrice: string
gasToken: string
refundReceiver: string
nonce: string
operation: string
}
So remember to submit a prevalidated signature from one of the vault keyholders when sending the simulation transaction on a Safe vault and you should be ready to go.
Thanks for reading this, and I hope it helps you! Feel free to reach out on Twitter/X @morganjweaver or LinkedIn /in/morganjweaver. You can find me building web3 security automation at OpenZeppelin.