Commit e1ee8249 authored by mergify[bot]'s avatar mergify[bot] Committed by GitHub

Merge branch 'develop' into fix/sequencer-fee-vault-layout

parents b2512c7e 31532ce1
---
'@eth-optimism/contracts-bedrock': patch
---
Add invariant test for the L1 XDM's `relayMessage` minimum gas limits.
...@@ -263,6 +263,10 @@ jobs: ...@@ -263,6 +263,10 @@ jobs:
name: storage snapshot name: storage snapshot
command: yarn storage-snapshot && git diff --exit-code .storage-layout command: yarn storage-snapshot && git diff --exit-code .storage-layout
working_directory: packages/contracts-bedrock working_directory: packages/contracts-bedrock
- run:
name: invariant docs
command: yarn autogen:invariant-docs && git diff --exit-code ./invariant-docs/*.md
working_directory: packages/contracts-bedrock
bedrock-echidna-build: bedrock-echidna-build:
docker: docker:
......
...@@ -98,8 +98,8 @@ func (t *TransactionManager) CraftTx(ctx context.Context, data []byte) (*types.T ...@@ -98,8 +98,8 @@ func (t *TransactionManager) CraftTx(ctx context.Context, data []byte) (*types.T
return nil, err return nil, err
} }
ctx, cancel := context.WithTimeout(ctx, networkTimeout) childCtx, cancel := context.WithTimeout(ctx, networkTimeout)
nonce, err := t.l1Client.NonceAt(ctx, t.senderAddress, nil) nonce, err := t.l1Client.NonceAt(childCtx, t.senderAddress, nil)
cancel() cancel()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get nonce: %w", err) return nil, fmt.Errorf("failed to get nonce: %w", err)
...@@ -121,6 +121,8 @@ func (t *TransactionManager) CraftTx(ctx context.Context, data []byte) (*types.T ...@@ -121,6 +121,8 @@ func (t *TransactionManager) CraftTx(ctx context.Context, data []byte) (*types.T
} }
rawTx.Gas = gas rawTx.Gas = gas
ctx, cancel = context.WithTimeout(ctx, networkTimeout)
defer cancel()
return t.signerFn(ctx, rawTx) return t.signerFn(ctx, rawTx)
} }
......
...@@ -9,6 +9,10 @@ import ( ...@@ -9,6 +9,10 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/ethereum-optimism/optimism/op-node/eth"
"github.com/ethereum-optimism/optimism/op-node/rollup/derive"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/rawdb"
...@@ -195,7 +199,22 @@ func main() { ...@@ -195,7 +199,22 @@ func main() {
return err return err
} }
if err := genesis.PostCheckMigratedDB(postLDB, migrationData, &config.L1CrossDomainMessengerProxy, config.L1ChainID); err != nil { if err := genesis.PostCheckMigratedDB(
postLDB,
migrationData,
&config.L1CrossDomainMessengerProxy,
config.L1ChainID,
config.FinalSystemOwner,
&derive.L1BlockInfo{
Number: block.NumberU64(),
Time: block.Time(),
BaseFee: block.BaseFee(),
BlockHash: block.Hash(),
BatcherAddr: config.BatchSenderAddress,
L1FeeOverhead: eth.Bytes32(common.BigToHash(new(big.Int).SetUint64(config.GasPriceOracleOverhead))),
L1FeeScalar: eth.Bytes32(common.BigToHash(new(big.Int).SetUint64(config.GasPriceOracleScalar))),
},
); err != nil {
return err return err
} }
......
package crossdomain
import (
"fmt"
"github.com/ethereum-optimism/optimism/op-bindings/predeploys"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/log"
)
// PreCheckWithdrawals checks that the given list of withdrawals represents all withdrawals made
// in the legacy system and filters out any extra withdrawals not included in the legacy system.
func PreCheckWithdrawals(db *state.StateDB, withdrawals []*LegacyWithdrawal) ([]*LegacyWithdrawal, error) {
// Convert each withdrawal into a storage slot, and build a map of those slots.
slotsInp := make(map[common.Hash]*LegacyWithdrawal)
for _, wd := range withdrawals {
slot, err := wd.StorageSlot()
if err != nil {
return nil, fmt.Errorf("cannot check withdrawals: %w", err)
}
slotsInp[slot] = wd
}
// Build a mapping of the slots of all messages actually sent in the legacy system.
var count int
slotsAct := make(map[common.Hash]bool)
err := db.ForEachStorage(predeploys.LegacyMessagePasserAddr, func(key, value common.Hash) bool {
// When a message is inserted into the LegacyMessagePasser, it is stored with the value
// of the ABI encoding of "true". Although there should not be any other storage slots, we
// can safely ignore anything that is not "true".
if value != abiTrue {
// Should not happen!
log.Error("found unknown slot in LegacyMessagePasser", "key", key.String(), "val", value.String())
return true
}
// Slot exists, so add it to the map.
slotsAct[key] = true
count++
return true
})
if err != nil {
return nil, fmt.Errorf("cannot iterate over LegacyMessagePasser: %w", err)
}
// Log the number of messages we found.
log.Info("Iterated legacy messages", "count", count)
// Iterate over the list of actual slots and check that we have an input message for each one.
for slot := range slotsAct {
_, ok := slotsInp[slot]
if !ok {
return nil, fmt.Errorf("unknown storage slot in state: %s", slot)
}
}
// Iterate over the list of input messages and check that we have a known slot for each one.
// We'll filter out any extra messages that are not in the legacy system.
filtered := make([]*LegacyWithdrawal, 0)
for slot := range slotsInp {
_, ok := slotsAct[slot]
if !ok {
log.Info("filtering out unknown input message", "slot", slot.String())
continue
}
filtered = append(filtered, slotsInp[slot])
}
// At this point, we know that the list of filtered withdrawals MUST be exactly the same as the
// list of withdrawals in the state. If we didn't have enough withdrawals, we would've errored
// out, and if we had too many, we would've filtered them out.
return filtered, nil
}
...@@ -8,13 +8,9 @@ import ( ...@@ -8,13 +8,9 @@ import (
"github.com/ethereum-optimism/optimism/op-chain-ops/genesis/migration" "github.com/ethereum-optimism/optimism/op-chain-ops/genesis/migration"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/state" "github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethdb" "github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/rlp"
"github.com/ethereum/go-ethereum/trie"
) )
var ( var (
...@@ -33,166 +29,29 @@ var ( ...@@ -33,166 +29,29 @@ var (
} }
) )
func MigrateLegacyETH(db ethdb.Database, stateDB *state.StateDB, addresses []common.Address, allowances []*migration.Allowance, chainID int, noCheck bool) error { func MigrateLegacyETH(ldb ethdb.Database, db *state.StateDB, addresses []common.Address, chainID int, noCheck bool) error {
// Set of addresses that we will be migrating.
addressesToMigrate := make(map[common.Address]bool)
// Set of storage slots that we expect to see in the OVM ETH contract.
storageSlotsToMigrate := make(map[common.Hash]int)
// Chain params to use for integrity checking. // Chain params to use for integrity checking.
params := migration.ParamsByChainID[chainID] params := migration.ParamsByChainID[chainID]
if params == nil { if params == nil {
return fmt.Errorf("no chain params for %d", chainID) return fmt.Errorf("no chain params for %d", chainID)
} }
// Log the chain params for debugging purposes.
log.Info("Chain params", "chain-id", chainID, "supply-delta", params.ExpectedSupplyDelta) log.Info("Chain params", "chain-id", chainID, "supply-delta", params.ExpectedSupplyDelta)
// Iterate over each address list, and read the addresses they // Deduplicate the list of addresses by converting to a map.
// contain into memory. Also calculate the storage slots for each deduped := make(map[common.Address]bool)
// address.
for _, addr := range addresses { for _, addr := range addresses {
addressesToMigrate[addr] = true deduped[addr] = true
storageSlotsToMigrate[CalcOVMETHStorageKey(addr)] = 1
}
for _, allowance := range allowances {
addressesToMigrate[allowance.From] = true
storageSlotsToMigrate[CalcAllowanceStorageKey(allowance.From, allowance.To)] = 2
}
if chainID == 1 {
// Some folks sent money to this address ages ago, permanently locking it
// there. This contract never transacted on a modern network, so hardcode
// this to ensure that all storage slots are accounted for.
// This address was once the OVM_SequencerEntrypoint contract.
seqEntryAddr := common.HexToAddress("0x4200000000000000000000000000000000000005")
addressesToMigrate[seqEntryAddr] = true
storageSlotsToMigrate[CalcOVMETHStorageKey(seqEntryAddr)] = 1
}
headBlock := rawdb.ReadHeadBlock(db)
root := headBlock.Root()
// Read mint events from the database. Even though Geth's balance methods
// are instrumented, mints from the bridge happen in the EVM and so do
// not execute that code path. As a result, we parse mint events in order
// to not miss any balances.
log.Info("reading mint events from DB")
logProgress := ProgressLogger(100, "read mint events")
err := IterateMintEvents(db, headBlock.NumberU64(), func(address common.Address, headNum uint64) error {
addressesToMigrate[address] = true
storageSlotsToMigrate[CalcOVMETHStorageKey(address)] = 1
logProgress("headnum", headNum)
return nil
})
if err != nil {
return wrapErr(err, "error reading mint events")
}
// Make sure all addresses are accounted for by iterating over
// the OVM ETH contract's state, and panicking if we miss
// any storage keys. We also keep track of the total amount of
// OVM ETH found, and diff that against the total supply of
// OVM ETH specified in the contract.
backingStateDB := state.NewDatabaseWithConfig(db, &trie.Config{
Preimages: true,
})
if err != nil {
return wrapErr(err, "error opening state DB")
}
storageTrie := stateDB.StorageTrie(OVMETHAddress)
storageIt := trie.NewIterator(storageTrie.NodeIterator(nil))
logProgress = ProgressLogger(10000, "iterating storage keys")
totalFound := new(big.Int)
totalSupply := getOVMETHTotalSupply(stateDB)
for storageIt.Next() {
_, content, _, err := rlp.Split(storageIt.Value)
if err != nil {
panic(err)
}
k := common.BytesToHash(storageTrie.GetKey(storageIt.Key))
v := common.BytesToHash(content)
sType := storageSlotsToMigrate[k]
switch sType {
case 1:
// This slot is a balance, increment totalFound.
totalFound = totalFound.Add(totalFound, v.Big())
case 2:
// This slot is an allowance, ignore it.
continue
default:
// Check if this slot is a variable. If it isn't, abort.
if !ignoredSlots[k] {
if noCheck {
log.Error("missed storage key", "k", k.String(), "v", v.String())
} else {
log.Crit("missed storage key", "k", k.String(), "v", v.String())
}
}
}
logProgress()
}
// Verify that the total supply is what we expect. We allow a hardcoded
// delta to be specified in the chain params since older regenesis events
// had supply bugs.
delta := new(big.Int).Sub(totalSupply, totalFound)
if delta.Cmp(params.ExpectedSupplyDelta) != 0 {
if noCheck {
log.Error(
"supply mismatch",
"migrated", totalFound.String(),
"supply", totalSupply.String(),
"delta", delta.String(),
"exp_delta", params.ExpectedSupplyDelta.String(),
)
} else {
log.Crit(
"supply mismatch",
"migrated", totalFound.String(),
"supply", totalSupply.String(),
"delta", delta.String(),
"exp_delta", params.ExpectedSupplyDelta.String(),
)
} }
}
log.Info(
"supply verified OK",
"migrated", totalFound.String(),
"supply", totalSupply.String(),
"delta", delta.String(),
"exp_delta", params.ExpectedSupplyDelta.String(),
)
log.Info("performing migration") // Migrate the legacy ETH to ETH.
log.Info("Migrating legacy ETH to ETH", "num-accounts", len(addresses))
log.Info("trie dumping started", "root", root)
tr, err := backingStateDB.OpenTrie(root)
if err != nil {
return err
}
it := trie.NewIterator(tr.NodeIterator(nil))
totalMigrated := new(big.Int) totalMigrated := new(big.Int)
logAccountProgress := ProgressLogger(1000, "imported accounts") logAccountProgress := ProgressLogger(1000, "imported accounts")
migratedAccounts := make(map[common.Address]bool) for addr := range deduped {
for it.Next() {
// It's up to us to decode trie data.
var data types.StateAccount
if err := rlp.DecodeBytes(it.Value, &data); err != nil {
panic(err)
}
addrBytes := tr.GetKey(it.Key)
addr := common.BytesToAddress(addrBytes)
migratedAccounts[addr] = true
// Get the OVM ETH balance based on the address's storage key.
ovmBalance := getOVMETHBalance(stateDB, addr)
// No accounts should have a balance in state. If they do, bail. // No accounts should have a balance in state. If they do, bail.
if data.Balance.Sign() > 0 { if db.GetBalance(addr).Sign() > 0 {
if noCheck { if noCheck {
log.Error("account has non-zero balance in state - should never happen", "addr", addr) log.Error("account has non-zero balance in state - should never happen", "addr", addr)
} else { } else {
...@@ -200,54 +59,52 @@ func MigrateLegacyETH(db ethdb.Database, stateDB *state.StateDB, addresses []com ...@@ -200,54 +59,52 @@ func MigrateLegacyETH(db ethdb.Database, stateDB *state.StateDB, addresses []com
} }
} }
// Pull out the OVM ETH balance.
ovmBalance := getOVMETHBalance(db, addr)
// Actually perform the migration by setting the appropriate values in state. // Actually perform the migration by setting the appropriate values in state.
stateDB.SetBalance(addr, ovmBalance) db.SetBalance(addr, ovmBalance)
stateDB.SetState(predeploys.LegacyERC20ETHAddr, CalcOVMETHStorageKey(addr), common.Hash{}) db.SetState(predeploys.LegacyERC20ETHAddr, CalcOVMETHStorageKey(addr), common.Hash{})
// Bump the total OVM balance. // Bump the total OVM balance.
totalMigrated = totalMigrated.Add(totalMigrated, ovmBalance) totalMigrated = totalMigrated.Add(totalMigrated, ovmBalance)
// Log progress.
logAccountProgress() logAccountProgress()
} }
// Take care of nonce zero accounts with balances. These are accounts // Make sure that the total supply delta matches the expected delta. This is equivalent to
// that received OVM ETH as part of the regenesis, but never actually // checking that the total migrated is equal to the total found, since we already performed the
// transacted on-chain. // same check against the total found (a = b, b = c => a = c).
logNonceZeroProgress := ProgressLogger(1000, "imported zero nonce accounts") totalSupply := getOVMETHTotalSupply(db)
log.Info("importing accounts with zero-nonce balances") delta := new(big.Int).Sub(totalSupply, totalMigrated)
for addr := range addressesToMigrate { if delta.Cmp(params.ExpectedSupplyDelta) != 0 {
if migratedAccounts[addr] {
continue
}
ovmBalance := getOVMETHBalance(stateDB, addr)
totalMigrated = totalMigrated.Add(totalMigrated, ovmBalance)
stateDB.AddBalance(addr, ovmBalance)
stateDB.SetState(predeploys.LegacyERC20ETHAddr, CalcOVMETHStorageKey(addr), common.Hash{})
logNonceZeroProgress()
}
// Make sure that the amount we migrated matches the amount in
// our original state.
if totalMigrated.Cmp(totalFound) != 0 {
if noCheck { if noCheck {
log.Debug( log.Error(
"total migrated does not equal total OVM eth found", "supply mismatch",
"migrated", totalMigrated, "migrated", totalMigrated.String(),
"found", totalFound, "supply", totalSupply.String(),
"delta", delta.String(),
"exp_delta", params.ExpectedSupplyDelta.String(),
) )
} else { } else {
log.Crit( log.Crit(
"total migrated does not equal total OVM eth found", "supply mismatch",
"migrated", totalMigrated, "migrated", totalMigrated.String(),
"found", totalFound, "supply", totalSupply.String(),
"delta", delta.String(),
"exp_delta", params.ExpectedSupplyDelta.String(),
) )
} }
} }
// Set the total supply to 0 // Set the total supply to 0. We do this because the total supply is necessarily going to be
stateDB.SetState(predeploys.LegacyERC20ETHAddr, getOVMETHTotalSupplySlot(), common.Hash{}) // different than the sum of all balances since we no longer track balances inside the contract
// itself. The total supply is going to be weird no matter what, might as well set it to zero
// so it's explicitly weird instead of implicitly weird.
db.SetState(predeploys.LegacyERC20ETHAddr, getOVMETHTotalSupplySlot(), common.Hash{})
log.Info("Set the totalSupply to 0") log.Info("Set the totalSupply to 0")
// Fin.
return nil return nil
} }
package ether
import (
"fmt"
"math/big"
"github.com/ethereum-optimism/optimism/op-bindings/predeploys"
"github.com/ethereum-optimism/optimism/op-chain-ops/genesis/migration"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/log"
)
// PreCheckBalances checks that the given list of addresses and allowances represents all storage
// slots in the LegacyERC20ETH contract. We don't have to filter out extra addresses like we do for
// withdrawals because we'll simply carry the balance of a given address to the new system, if the
// account is extra then it won't have any balance and nothing will happen.
func PreCheckBalances(ldb ethdb.Database, db *state.StateDB, addresses []common.Address, allowances []*migration.Allowance, chainID int, noCheck bool) ([]common.Address, error) {
// Chain params to use for integrity checking.
params := migration.ParamsByChainID[chainID]
if params == nil {
return nil, fmt.Errorf("no chain params for %d", chainID)
}
// We'll need to maintain a list of all addresses that we've seen along with all of the storage
// slots based on the witness data.
addrs := make([]common.Address, 0)
slotsInp := make(map[common.Hash]int)
// For each known address, compute its balance key and add it to the list of addresses.
for _, addr := range addresses {
addrs = append(addrs, addr)
slotsInp[CalcOVMETHStorageKey(addr)] = 1
}
// For each known allowance, compute its storage key and add it to the list of addresses.
for _, allowance := range allowances {
addrs = append(addrs, allowance.From)
slotsInp[CalcAllowanceStorageKey(allowance.From, allowance.To)] = 2
}
// Add the old SequencerEntrypoint because someone sent it ETH a long time ago and it has a
// balance but none of our instrumentation could easily find it. Special case.
sequencerEntrypointAddr := common.HexToAddress("0x4200000000000000000000000000000000000005")
addrs = append(addrs, sequencerEntrypointAddr)
slotsInp[CalcOVMETHStorageKey(sequencerEntrypointAddr)] = 1
// Also extract addresses/slots from Mint events. Our instrumentation currently only looks at
// direct balance changes inside of Geth, but Mint events mutate the ERC20 storage directly and
// therefore aren't picked up by our instrumentation. Instead of updating the instrumentation,
// we can simply iterate over every Mint event and add the address to the list of addresses.
log.Info("Reading mint events from DB")
headBlock := rawdb.ReadHeadBlock(ldb)
logProgress := ProgressLogger(100, "read mint events")
err := IterateMintEvents(ldb, headBlock.NumberU64(), func(address common.Address, headNum uint64) error {
addrs = append(addrs, address)
slotsInp[CalcOVMETHStorageKey(address)] = 1
logProgress("headnum", headNum)
return nil
})
if err != nil {
return nil, wrapErr(err, "error reading mint events")
}
// Build a mapping of every storage slot in the LegacyERC20ETH contract, except the list of
// slots that we know we can ignore (totalSupply, name, symbol).
var count int
slotsAct := make(map[common.Hash]common.Hash)
err = db.ForEachStorage(predeploys.LegacyERC20ETHAddr, func(key, value common.Hash) bool {
// We can safely ignore specific slots (totalSupply, name, symbol).
if ignoredSlots[key] {
return true
}
// Slot exists, so add it to the map.
slotsAct[key] = value
count++
return true
})
if err != nil {
return nil, fmt.Errorf("cannot iterate over LegacyERC20ETHAddr: %w", err)
}
// Log how many slots were iterated over.
log.Info("Iterated legacy balances", "count", count)
// Iterate over the list of known slots and check that we have a slot for each one. We'll also
// keep track of the total balance to be migrated and throw if the total supply exceeds the
// expected supply delta.
totalFound := new(big.Int)
for slot := range slotsAct {
slotType, ok := slotsInp[slot]
if !ok {
if noCheck {
log.Error("ignoring unknown storage slot in state", "slot", slot)
} else {
log.Crit("unknown storage slot in state: %s", slot)
}
}
// Add balances to the total found.
switch slotType {
case 1:
// Balance slot.
totalFound.Add(totalFound, slotsAct[slot].Big())
case 2:
// Allowance slot.
continue
default:
// Should never happen.
if noCheck {
log.Error("unknown slot type", "slot", slot, "type", slotType)
} else {
log.Crit("unknown slot type: %d", slotType)
}
}
}
// Verify the supply delta. Recorded total supply in the LegacyERC20ETH contract may be higher
// than the actual migrated amount because self-destructs will remove ETH supply in a way that
// cannot be reflected in the contract. This is fine because self-destructs just mean the L2 is
// actually *overcollateralized* by some tiny amount.
totalSupply := getOVMETHTotalSupply(db)
delta := new(big.Int).Sub(totalSupply, totalFound)
if delta.Cmp(params.ExpectedSupplyDelta) != 0 {
if noCheck {
log.Error(
"supply mismatch",
"migrated", totalFound.String(),
"supply", totalSupply.String(),
"delta", delta.String(),
"exp_delta", params.ExpectedSupplyDelta.String(),
)
} else {
log.Crit(
"supply mismatch",
"migrated", totalFound.String(),
"supply", totalSupply.String(),
"delta", delta.String(),
"exp_delta", params.ExpectedSupplyDelta.String(),
)
}
}
// Supply is verified.
log.Info(
"supply verified OK",
"migrated", totalFound.String(),
"supply", totalSupply.String(),
"delta", delta.String(),
"exp_delta", params.ExpectedSupplyDelta.String(),
)
// We know we have at least a superset of all addresses here since we know that we have every
// storage slot. It's fine to have extras because they won't have any balance.
return addrs, nil
}
This diff is collapsed.
This diff is collapsed.
...@@ -14,18 +14,21 @@ import ( ...@@ -14,18 +14,21 @@ import (
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
) )
// UntouchablePredeploys are addresses in the predeploy namespace
// that should not be touched by the migration process.
var UntouchablePredeploys = map[common.Address]bool{
predeploys.GovernanceTokenAddr: true,
predeploys.WETH9Addr: true,
}
// UntouchableCodeHashes contains code hashes of all the contracts // UntouchableCodeHashes contains code hashes of all the contracts
// that should not be touched by the migration process. // that should not be touched by the migration process.
type ChainHashMap map[uint64]common.Hash type ChainHashMap map[uint64]common.Hash
var UntouchableCodeHashes = map[common.Address]ChainHashMap{ var (
// UntouchablePredeploys are addresses in the predeploy namespace
// that should not be touched by the migration process.
UntouchablePredeploys = map[common.Address]bool{
predeploys.GovernanceTokenAddr: true,
predeploys.WETH9Addr: true,
}
// UntouchableCodeHashes represent the bytecode hashes of contracts
// that should not be touched by the migration process.
UntouchableCodeHashes = map[common.Address]ChainHashMap{
predeploys.GovernanceTokenAddr: { predeploys.GovernanceTokenAddr: {
1: common.HexToHash("0x8551d935f4e67ad3c98609f0d9f0f234740c4c4599f82674633b55204393e07f"), 1: common.HexToHash("0x8551d935f4e67ad3c98609f0d9f0f234740c4c4599f82674633b55204393e07f"),
5: common.HexToHash("0xc4a213cf5f06418533e5168d8d82f7ccbcc97f27ab90197c2c051af6a4941cf9"), 5: common.HexToHash("0xc4a213cf5f06418533e5168d8d82f7ccbcc97f27ab90197c2c051af6a4941cf9"),
...@@ -34,7 +37,22 @@ var UntouchableCodeHashes = map[common.Address]ChainHashMap{ ...@@ -34,7 +37,22 @@ var UntouchableCodeHashes = map[common.Address]ChainHashMap{
1: common.HexToHash("0x779bbf2a738ef09d961c945116197e2ac764c1b39304b2b4418cd4e42668b173"), 1: common.HexToHash("0x779bbf2a738ef09d961c945116197e2ac764c1b39304b2b4418cd4e42668b173"),
5: common.HexToHash("0x779bbf2a738ef09d961c945116197e2ac764c1b39304b2b4418cd4e42668b173"), 5: common.HexToHash("0x779bbf2a738ef09d961c945116197e2ac764c1b39304b2b4418cd4e42668b173"),
}, },
} }
// FrozenStoragePredeploys represents the set of predeploys that
// will not have their storage wiped during the migration process.
// It is very explicitly set in its own mapping to ensure that
// changes elsewhere in the codebase do no alter the predeploys
// that do not have their storage wiped. It is safe for all other
// predeploys to have their storage wiped.
FrozenStoragePredeploys = map[common.Address]bool{
predeploys.GovernanceTokenAddr: true,
predeploys.WETH9Addr: true,
predeploys.LegacyMessagePasserAddr: true,
predeploys.LegacyERC20ETHAddr: true,
predeploys.DeployerWhitelistAddr: true,
}
)
// FundDevAccounts will fund each of the development accounts. // FundDevAccounts will fund each of the development accounts.
func FundDevAccounts(db vm.StateDB) { func FundDevAccounts(db vm.StateDB) {
...@@ -60,6 +78,26 @@ func SetL1Proxies(db vm.StateDB, proxyAdminAddr common.Address) error { ...@@ -60,6 +78,26 @@ func SetL1Proxies(db vm.StateDB, proxyAdminAddr common.Address) error {
return setProxies(db, proxyAdminAddr, bigL1PredeployNamespace, 2048) return setProxies(db, proxyAdminAddr, bigL1PredeployNamespace, 2048)
} }
// WipePredeployStorage will wipe the storage of all L2 predeploys expect
// for predeploys that must not have their storage altered.
func WipePredeployStorage(db vm.StateDB) error {
for name, addr := range predeploys.Predeploys {
if addr == nil {
return fmt.Errorf("nil address in predeploys mapping for %s", name)
}
if FrozenStoragePredeploys[*addr] {
log.Trace("skipping wiping of storage", "name", name, "address", *addr)
continue
}
log.Info("wiping storage", "name", name, "address", *addr)
db.CreateAccount(*addr)
}
return nil
}
func setProxies(db vm.StateDB, proxyAdminAddr common.Address, namespace *big.Int, count uint64) error { func setProxies(db vm.StateDB, proxyAdminAddr common.Address, namespace *big.Int, count uint64) error {
depBytecode, err := bindings.GetDeployedBytecode("Proxy") depBytecode, err := bindings.GetDeployedBytecode("Proxy")
if err != nil { if err != nil {
......
...@@ -29,6 +29,8 @@ require ( ...@@ -29,6 +29,8 @@ require (
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d // indirect github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d // indirect
github.com/holiman/big v0.0.0-20221017200358-a027dc42d04e // indirect github.com/holiman/big v0.0.0-20221017200358-a027dc42d04e // indirect
github.com/holiman/bloomfilter/v2 v2.0.3 // indirect github.com/holiman/bloomfilter/v2 v2.0.3 // indirect
......
This diff is collapsed.
...@@ -24,7 +24,10 @@ contract EchidnaFuzzAddressAliasing { ...@@ -24,7 +24,10 @@ contract EchidnaFuzzAddressAliasing {
} }
/** /**
* @notice Verifies that testRoundTrip(...) did not ever fail. * @custom:invariant Address aliases are always able to be undone.
*
* Asserts that an address that has been aliased with `applyL1ToL2Alias` can always
* be unaliased with `undoL1ToL2Alias`.
*/ */
function echidna_round_trip_aliasing() public view returns (bool) { function echidna_round_trip_aliasing() public view returns (bool) {
// ASSERTION: The round trip aliasing done in testRoundTrip(...) should never fail. // ASSERTION: The round trip aliasing done in testRoundTrip(...) should never fail.
......
...@@ -26,6 +26,12 @@ contract EchidnaFuzzBurnEth is StdUtils { ...@@ -26,6 +26,12 @@ contract EchidnaFuzzBurnEth is StdUtils {
} }
} }
/**
* @custom:invariant `eth(uint256)` always burns the exact amount of eth passed.
*
* Asserts that when `Burn.eth(uint256)` is called, it always burns the exact amount
* of ETH passed to the function.
*/
function echidna_burn_eth() public view returns (bool) { function echidna_burn_eth() public view returns (bool) {
// ASSERTION: The amount burned should always match the amount passed exactly // ASSERTION: The amount burned should always match the amount passed exactly
return !failedEthBurn; return !failedEthBurn;
...@@ -62,6 +68,12 @@ contract EchidnaFuzzBurnGas is StdUtils { ...@@ -62,6 +68,12 @@ contract EchidnaFuzzBurnGas is StdUtils {
} }
} }
/**
* @custom:invariant `gas(uint256)` always burns at least the amount of gas passed.
*
* Asserts that when `Burn.gas(uint256)` is called, it always burns at least the amount
* of gas passed to the function.
*/
function echidna_burn_gas() public view returns (bool) { function echidna_burn_gas() public view returns (bool) {
// ASSERTION: The amount of gas burned should be strictly greater than the // ASSERTION: The amount of gas burned should be strictly greater than the
// the amount passed as _value (minimum _value + whatever minor overhead to // the amount passed as _value (minimum _value + whatever minor overhead to
......
...@@ -49,7 +49,9 @@ contract EchidnaFuzzEncoding { ...@@ -49,7 +49,9 @@ contract EchidnaFuzzEncoding {
} }
/** /**
* @notice Verifies that testRoundTripAToB did not ever fail. * @custom:invariant `testRoundTripAToB` never fails.
*
* Asserts that a raw versioned nonce can be encoded / decoded to reach the same raw value.
*/ */
function echidna_round_trip_encoding_AToB() public view returns (bool) { function echidna_round_trip_encoding_AToB() public view returns (bool) {
// ASSERTION: The round trip encoding done in testRoundTripAToB(...) // ASSERTION: The round trip encoding done in testRoundTripAToB(...)
...@@ -57,7 +59,10 @@ contract EchidnaFuzzEncoding { ...@@ -57,7 +59,10 @@ contract EchidnaFuzzEncoding {
} }
/** /**
* @notice Verifies that testRoundTripBToA did not ever fail. * @custom:invariant `testRoundTripBToA` never fails.
*
* Asserts that an encoded versioned nonce can always be decoded / re-encoded to reach
* the same encoded value.
*/ */
function echidna_round_trip_encoding_BToA() public view returns (bool) { function echidna_round_trip_encoding_BToA() public view returns (bool) {
// ASSERTION: The round trip encoding done in testRoundTripBToA should never // ASSERTION: The round trip encoding done in testRoundTripBToA should never
......
...@@ -112,17 +112,36 @@ contract EchidnaFuzzHashing { ...@@ -112,17 +112,36 @@ contract EchidnaFuzzHashing {
} }
} }
/**
* @custom:invariant `hashCrossDomainMessage` reverts if `version` is > `1`.
*
* The `hashCrossDomainMessage` function should always revert if the `version` passed is > `1`.
*/
function echidna_hash_xdomain_msg_high_version() public view returns (bool) { function echidna_hash_xdomain_msg_high_version() public view returns (bool) {
// ASSERTION: A call to hashCrossDomainMessage will never succeed for a version > 1 // ASSERTION: A call to hashCrossDomainMessage will never succeed for a version > 1
return !failedCrossDomainHashHighVersion; return !failedCrossDomainHashHighVersion;
} }
/**
* @custom:invariant `version` = `0`: `hashCrossDomainMessage` and `hashCrossDomainMessageV0`
* are equivalent.
*
* If the version passed is 0, `hashCrossDomainMessage` and `hashCrossDomainMessageV0` should be
* equivalent.
*/
function echidna_hash_xdomain_msg_0() public view returns (bool) { function echidna_hash_xdomain_msg_0() public view returns (bool) {
// ASSERTION: A call to hashCrossDomainMessage and hashCrossDomainMessageV0 // ASSERTION: A call to hashCrossDomainMessage and hashCrossDomainMessageV0
// should always match when the version passed is 0 // should always match when the version passed is 0
return !failedCrossDomainHashV0; return !failedCrossDomainHashV0;
} }
/**
* @custom:invariant `version` = `1`: `hashCrossDomainMessage` and `hashCrossDomainMessageV1`
* are equivalent.
*
* If the version passed is 1, `hashCrossDomainMessage` and `hashCrossDomainMessageV1` should be
* equivalent.
*/
function echidna_hash_xdomain_msg_1() public view returns (bool) { function echidna_hash_xdomain_msg_1() public view returns (bool) {
// ASSERTION: A call to hashCrossDomainMessage and hashCrossDomainMessageV1 // ASSERTION: A call to hashCrossDomainMessage and hashCrossDomainMessageV1
// should always match when the version passed is 1 // should always match when the version passed is 1
......
...@@ -27,6 +27,13 @@ contract EchidnaFuzzOptimismPortal { ...@@ -27,6 +27,13 @@ contract EchidnaFuzzOptimismPortal {
failedToComplete = false; failedToComplete = false;
} }
/**
* @custom:invariant Deposits of any value should always succeed unless
* `_to` = `address(0)` or `_isCreation` = `true`.
*
* All deposits, barring creation transactions and transactions sent to `address(0)`,
* should always succeed.
*/
function echidna_deposit_completes() public view returns (bool) { function echidna_deposit_completes() public view returns (bool) {
return !failedToComplete; return !failedToComplete;
} }
......
...@@ -78,7 +78,7 @@ contract EchidnaFuzzResourceMetering is ResourceMetering, StdUtils { ...@@ -78,7 +78,7 @@ contract EchidnaFuzzResourceMetering is ResourceMetering, StdUtils {
((uint256(params.prevBaseFee) - cachedPrevBaseFee) < maxBaseFeeChange); ((uint256(params.prevBaseFee) - cachedPrevBaseFee) < maxBaseFeeChange);
} }
// If the last blocked used less than the target amount of gas, (or was empty), // If the last block used less than the target amount of gas, (or was empty),
// ensure that: this block's baseFee was decreased, but not by more than the max amount // ensure that: this block's baseFee was decreased, but not by more than the max amount
if ( if (
(cachedPrevBoughtGas < uint256(TARGET_RESOURCE_LIMIT)) || (cachedPrevBoughtGas < uint256(TARGET_RESOURCE_LIMIT)) ||
...@@ -128,30 +128,78 @@ contract EchidnaFuzzResourceMetering is ResourceMetering, StdUtils { ...@@ -128,30 +128,78 @@ contract EchidnaFuzzResourceMetering is ResourceMetering, StdUtils {
function _burnInternal(uint64 _gasToBurn) private metered(_gasToBurn) {} function _burnInternal(uint64 _gasToBurn) private metered(_gasToBurn) {}
/**
* @custom:invariant The base fee should increase if the last block used more
* than the target amount of gas
*
* If the last block used more than the target amount of gas (and there were no
* empty blocks in between), ensure this block's baseFee increased, but not by
* more than the max amount per block.
*/
function echidna_high_usage_raise_baseFee() public view returns (bool) { function echidna_high_usage_raise_baseFee() public view returns (bool) {
return !failedRaiseBaseFee; return !failedRaiseBaseFee;
} }
/**
* @custom:invariant The base fee should decrease if the last block used less
* than the target amount of gas
*
* If the previous block used less than the target amount of gas, the base fee should decrease,
* but not more than the max amount.
*/
function echidna_low_usage_lower_baseFee() public view returns (bool) { function echidna_low_usage_lower_baseFee() public view returns (bool) {
return !failedLowerBaseFee; return !failedLowerBaseFee;
} }
/**
* @custom:invariant A block's base fee should never be below `MINIMUM_BASE_FEE`
*
* This test asserts that a block's base fee can never drop below the
* `MINIMUM_BASE_FEE` threshold.
*/
function echidna_never_below_min_baseFee() public view returns (bool) { function echidna_never_below_min_baseFee() public view returns (bool) {
return !failedNeverBelowMinBaseFee; return !failedNeverBelowMinBaseFee;
} }
/**
* @custom:invariant A block can never consume more than `MAX_RESOURCE_LIMIT` gas.
*
* This test asserts that a block can never consume more than the `MAX_RESOURCE_LIMIT`
* gas threshold.
*/
function echidna_never_above_max_gas_limit() public view returns (bool) { function echidna_never_above_max_gas_limit() public view returns (bool) {
return !failedMaxGasPerBlock; return !failedMaxGasPerBlock;
} }
/**
* @custom:invariant The base fee can never be raised more than the max base fee change.
*
* After a block consumes more gas than the target gas, the base fee cannot be raised
* more than the maximum amount allowed. The max base fee change (per-block) is derived
* as follows: `prevBaseFee / BASE_FEE_MAX_CHANGE_DENOMINATOR`
*/
function echidna_never_exceed_max_increase() public view returns (bool) { function echidna_never_exceed_max_increase() public view returns (bool) {
return !failedMaxRaiseBaseFeePerBlock; return !failedMaxRaiseBaseFeePerBlock;
} }
/**
* @custom:invariant The base fee can never be lowered more than the max base fee change.
*
* After a block consumes less than the target gas, the base fee cannot be lowered more
* than the maximum amount allowed. The max base fee change (per-block) is derived as
*follows: `prevBaseFee / BASE_FEE_MAX_CHANGE_DENOMINATOR`
*/
function echidna_never_exceed_max_decrease() public view returns (bool) { function echidna_never_exceed_max_decrease() public view returns (bool) {
return !failedMaxLowerBaseFeePerBlock; return !failedMaxLowerBaseFeePerBlock;
} }
/**
* @custom:invariant The `maxBaseFeeChange` calculation over multiple blocks can never
* underflow.
*
* When calculating the `maxBaseFeeChange` after multiple empty blocks, the calculation
* should never be allowed to underflow.
*/
function echidna_underflow() public view returns (bool) { function echidna_underflow() public view returns (bool) {
return !underflow; return !underflow;
} }
......
pragma solidity 0.8.15;
import { InvariantTest } from "forge-std/InvariantTest.sol";
import { StdUtils } from "forge-std/StdUtils.sol";
import { Vm } from "forge-std/Vm.sol";
import { OptimismPortal } from "../../L1/OptimismPortal.sol";
import { L1CrossDomainMessenger } from "../../L1/L1CrossDomainMessenger.sol";
import { Messenger_Initializer } from "../CommonTest.t.sol";
import { Types } from "../../libraries/Types.sol";
import { Predeploys } from "../../libraries/Predeploys.sol";
import { Encoding } from "../../libraries/Encoding.sol";
import { Hashing } from "../../libraries/Hashing.sol";
contract RelayActor is StdUtils {
// Storage slot of the l2Sender
uint256 constant senderSlotIndex = 50;
uint256 public numHashes;
bytes32[] public hashes;
bool public reverted = false;
OptimismPortal op;
L1CrossDomainMessenger xdm;
Vm vm;
constructor(
OptimismPortal _op,
L1CrossDomainMessenger _xdm,
Vm _vm
) {
op = _op;
xdm = _xdm;
vm = _vm;
}
/**
* Relays a message to the `L1CrossDomainMessenger` with a random `version`, `_minGasLimit`
* and `_message`.
*/
function relay(
uint16 _version,
uint32 _minGasLimit,
bytes memory _message
) external {
address target = address(0x04); // ID precompile
address sender = Predeploys.L2_CROSS_DOMAIN_MESSENGER;
// set the value of op.l2Sender() to be the L2 Cross Domain Messenger.
vm.store(address(op), bytes32(senderSlotIndex), bytes32(abi.encode(sender)));
// Restrict `_minGasLimit` to a number in the range of the block gas limit.
_minGasLimit = uint32(bound(_minGasLimit, 0, block.gaslimit));
// Restrict version to the range of [0, 1]
_version = _version % 2;
// Compute the cross domain message hash and store it in `hashes`.
// The `relayMessage` function will always encode the message as a version 1
// message after checking that the V0 hash has not already been relayed.
bytes32 _hash = Hashing.hashCrossDomainMessageV1(
Encoding.encodeVersionedNonce(0, _version),
sender,
target,
0, // value
_minGasLimit,
_message
);
// Act as the optimism portal and call `relayMessage` on the `L1CrossDomainMessenger` with
// the outer min gas limit.
vm.startPrank(address(op));
vm.expectCall(target, _message);
try
xdm.relayMessage{ gas: xdm.baseGas(_message, _minGasLimit) }(
Encoding.encodeVersionedNonce(0, _version),
sender,
target,
0, // value
_minGasLimit,
_message
)
{} catch {
// If any of these calls revert, set `reverted` to true to fail the invariant test.
// NOTE: This is to get around forge's invariant fuzzer ignoring reverted calls
// to this function.
reverted = true;
}
vm.stopPrank();
hashes.push(_hash);
numHashes += 1;
}
}
contract XDM_MinGasLimits is Messenger_Initializer, InvariantTest {
RelayActor actor;
function setUp() public override {
// Set up the `L1CrossDomainMessenger` and `OptimismPortal` contracts.
super.setUp();
// Deploy a relay actor
actor = new RelayActor(op, L1Messenger, vm);
// Target the `RelayActor` contract
targetContract(address(actor));
// Target the actor's `relay` function
bytes4[] memory selectors = new bytes4[](1);
selectors[0] = actor.relay.selector;
targetSelector(FuzzSelector({ addr: address(actor), selectors: selectors }));
}
/**
* @custom:invariant A call to `relayMessage` should never revert if at least the proper minimum
* gas limits are supplied.
*
* There are two minimum gas limits here:
*
* - The outer min gas limit is for the call from the `OptimismPortal` to the
* `L1CrossDomainMessenger`, and it can be retrieved by calling the xdm's `baseGas` function
* with the `message` and inner limit.
*
* - The inner min gas limit is for the call from the `L1CrossDomainMessenger` to the target
* contract.
*/
function invariant_minGasLimits() public {
uint256 length = actor.numHashes();
for (uint256 i = 0; i < length; ++i) {
bytes32 hash = actor.hashes(i);
// the message hash is in the successfulMessages mapping
assertTrue(L1Messenger.successfulMessages(hash));
// it is not in the received messages mapping
assertFalse(L1Messenger.failedMessages(hash));
}
assertFalse(actor.reverted());
}
}
# `AddressAliasing` Invariants
## Address aliases are always able to be undone.
**Test:** [`FuzzAddressAliasing.sol#L32`](../contracts/echidna/FuzzAddressAliasing.sol#L32)
Asserts that an address that has been aliased with `applyL1ToL2Alias` can always be unaliased with `undoL1ToL2Alias`.
# `Burn` Invariants
## `eth(uint256)` always burns the exact amount of eth passed.
**Test:** [`FuzzBurn.sol#L35`](../contracts/echidna/FuzzBurn.sol#L35)
Asserts that when `Burn.eth(uint256)` is called, it always burns the exact amount of ETH passed to the function.
## `gas(uint256)` always burns at least the amount of gas passed.
**Test:** [`FuzzBurn.sol#L77`](../contracts/echidna/FuzzBurn.sol#L77)
Asserts that when `Burn.gas(uint256)` is called, it always burns at least the amount of gas passed to the function.
# `CrossDomainMessenger` Invariants
## A call to `relayMessage` should never revert if at least the proper minimum gas limits are supplied.
**Test:** [`CrossDomainMessenger.t.sol#L127`](../contracts/test/invariants/CrossDomainMessenger.t.sol#L127)
There are two minimum gas limits here:
- The outer min gas limit is for the call from the `OptimismPortal` to the `L1CrossDomainMessenger`, and it can be retrieved by calling the xdm's `baseGas` function with the `message` and inner limit.
- The inner min gas limit is for the call from the `L1CrossDomainMessenger` to the target contract.
# `Encoding` Invariants
## `testRoundTripAToB` never fails.
**Test:** [`FuzzEncoding.sol#L56`](../contracts/echidna/FuzzEncoding.sol#L56)
Asserts that a raw versioned nonce can be encoded / decoded to reach the same raw value.
## `testRoundTripBToA` never fails.
**Test:** [`FuzzEncoding.sol#L67`](../contracts/echidna/FuzzEncoding.sol#L67)
Asserts that an encoded versioned nonce can always be decoded / re-encoded to reach the same encoded value.
# `Hashing` Invariants
## `hashCrossDomainMessage` reverts if `version` is > `1`.
**Test:** [`FuzzHashing.sol#L120`](../contracts/echidna/FuzzHashing.sol#L120)
The `hashCrossDomainMessage` function should always revert if the `version` passed is > `1`.
## `version` = `0`: `hashCrossDomainMessage` and `hashCrossDomainMessageV0` are equivalent.
**Test:** [`FuzzHashing.sol#L132`](../contracts/echidna/FuzzHashing.sol#L132)
If the version passed is 0, `hashCrossDomainMessage` and `hashCrossDomainMessageV0` should be equivalent.
## `version` = `1`: `hashCrossDomainMessage` and `hashCrossDomainMessageV1` are equivalent.
**Test:** [`FuzzHashing.sol#L145`](../contracts/echidna/FuzzHashing.sol#L145)
If the version passed is 1, `hashCrossDomainMessage` and `hashCrossDomainMessageV1` should be equivalent.
# `L2OutputOracle` Invariants
## The block number of the output root proposals should monotonically increase.
**Test:** [`L2OutputOracle.t.sol#L36`](../contracts/test/invariants/L2OutputOracle.t.sol#L36)
When a new output is submitted, it should never be allowed to correspond to a block number that is less than the current output.
# `OptimismPortal` Invariants
## `finalizeWithdrawalTransaction` should revert if the finalization period has not elapsed.
**Test:** [`OptimismPortal.t.sol#L86`](../contracts/test/invariants/OptimismPortal.t.sol#L86)
A withdrawal that has been proven should not be able to be finalized until after the finalization period has elapsed.
## `finalizeWithdrawalTransaction` should revert if the withdrawal has already been finalized.
**Test:** [`OptimismPortal.t.sol#L123`](../contracts/test/invariants/OptimismPortal.t.sol#L123)
Ensures that there is no chain of calls that can be made that allows a withdrawal to be finalized twice.
## A withdrawal should **always** be able to be finalized `FINALIZATION_PERIOD_SECONDS` after it was successfully proven.
**Test:** [`OptimismPortal.t.sol#L158`](../contracts/test/invariants/OptimismPortal.t.sol#L158)
This invariant asserts that there is no chain of calls that can be made that will prevent a withdrawal from being finalized exactly `FINALIZATION_PERIOD_SECONDS` after it was successfully proven.
## Deposits of any value should always succeed unless `_to` = `address(0)` or `_isCreation` = `true`.
**Test:** [`FuzzOptimismPortal.sol#L37`](../contracts/echidna/FuzzOptimismPortal.sol#L37)
All deposits, barring creation transactions and transactions sent to `address(0)`, should always succeed.
# Invariant Docs
This directory contains documentation for all defined invariant tests within `contracts-bedrock`.
<!-- Do not modify the following section manually. It will be automatically generated on running `yarn autogen:invariant-docs` -->
<!-- START autoTOC -->
## Table of Contents
- [AddressAliasing](./AddressAliasing.md)
- [Burn](./Burn.md)
- [CrossDomainMessenger](./CrossDomainMessenger.md)
- [Encoding](./Encoding.md)
- [Hashing](./Hashing.md)
- [L2OutputOracle](./L2OutputOracle.md)
- [OptimismPortal](./OptimismPortal.md)
- [ResourceMetering](./ResourceMetering.md)
- [SystemConfig](./SystemConfig.md)
<!-- END autoTOC -->
## Usage
To auto-generate documentation for invariant tests, run `yarn autogen:invariant-docs`.
## Documentation Standard
In order for an invariant test file to be picked up by the [docgen script](../scripts/invariant-doc-gen.ts), it must
adhere to the following conventions:
### Forge Invariants
All `forge` invariant tests must exist within the `contracts/test/invariants` folder, and the file name should be
`<ContractName>.t.sol`, where `<ContractName>` is the name of the contract that is being tested.
All tests within `forge` invariant files should follow the convention:
```solidity
/**
* @custom:invariant <title>
*
* <longDescription>
*/
function invariant_<shortDescription>() external {
// ...
}
```
### Echidna Invariants
All `echidna` invariant tests must exist within the `contracts/echidna` folder, and the file name should be
`Fuzz<ContractName>.sol`, where `<ContractName>` is the name of the contract that is being tested.
All property tests within `echidna` invariant files should follow the convention:
```solidity
/**
* @custom:invariant <title>
*
* <longDescription>
*/
function echidna_<shortDescription>() external view returns (bool) {
// ...
}
```
# `ResourceMetering` Invariants
## The base fee should increase if the last block used more than the target amount of gas
**Test:** [`FuzzResourceMetering.sol#L139`](../contracts/echidna/FuzzResourceMetering.sol#L139)
If the last block used more than the target amount of gas (and there were no empty blocks in between), ensure this block's baseFee increased, but not by more than the max amount per block.
## The base fee should decrease if the last block used less than the target amount of gas
**Test:** [`FuzzResourceMetering.sol#L150`](../contracts/echidna/FuzzResourceMetering.sol#L150)
If the previous block used less than the target amount of gas, the base fee should decrease, but not more than the max amount.
## A block's base fee should never be below `MINIMUM_BASE_FEE`
**Test:** [`FuzzResourceMetering.sol#L160`](../contracts/echidna/FuzzResourceMetering.sol#L160)
This test asserts that a block's base fee can never drop below the `MINIMUM_BASE_FEE` threshold.
## A block can never consume more than `MAX_RESOURCE_LIMIT` gas.
**Test:** [`FuzzResourceMetering.sol#L170`](../contracts/echidna/FuzzResourceMetering.sol#L170)
This test asserts that a block can never consume more than the `MAX_RESOURCE_LIMIT` gas threshold.
## The base fee can never be raised more than the max base fee change.
**Test:** [`FuzzResourceMetering.sol#L181`](../contracts/echidna/FuzzResourceMetering.sol#L181)
After a block consumes more gas than the target gas, the base fee cannot be raised more than the maximum amount allowed. The max base fee change (per-block) is derived as follows: `prevBaseFee / BASE_FEE_MAX_CHANGE_DENOMINATOR`
## The base fee can never be lowered more than the max base fee change.
**Test:** [`FuzzResourceMetering.sol#L192`](../contracts/echidna/FuzzResourceMetering.sol#L192)
After a block consumes less than the target gas, the base fee cannot be lowered more than the maximum amount allowed. The max base fee change (per-block) is derived as follows: `prevBaseFee / BASE_FEE_MAX_CHANGE_DENOMINATOR`
## The `maxBaseFeeChange` calculation over multiple blocks can never underflow.
**Test:** [`FuzzResourceMetering.sol#L203`](../contracts/echidna/FuzzResourceMetering.sol#L203)
When calculating the `maxBaseFeeChange` after multiple empty blocks, the calculation should never be allowed to underflow.
# `SystemConfig` Invariants
## The gas limit of the `SystemConfig` contract can never be lower than the hard-coded lower bound.
**Test:** [`SystemConfig.t.sol#L40`](../contracts/test/invariants/SystemConfig.t.sol#L40)
...@@ -22,6 +22,7 @@ ...@@ -22,6 +22,7 @@
"build": "hardhat compile && yarn autogen:artifacts && yarn build:ts && yarn typechain", "build": "hardhat compile && yarn autogen:artifacts && yarn build:ts && yarn typechain",
"build:ts": "tsc -p tsconfig.build.json", "build:ts": "tsc -p tsconfig.build.json",
"autogen:artifacts": "ts-node scripts/generate-artifacts.ts", "autogen:artifacts": "ts-node scripts/generate-artifacts.ts",
"autogen:invariant-docs": "ts-node scripts/invariant-doc-gen.ts",
"deploy": "hardhat deploy", "deploy": "hardhat deploy",
"test": "yarn build:differential && yarn build:fuzz && forge test", "test": "yarn build:differential && yarn build:fuzz && forge test",
"coverage": "yarn build:differential && yarn build:fuzz && forge coverage", "coverage": "yarn build:differential && yarn build:fuzz && forge coverage",
......
import fs from 'fs'
import path from 'path'
const BASE_INVARIANTS_DIR = path.join(
__dirname,
'..',
'contracts',
'test',
'invariants'
)
const BASE_ECHIDNA_DIR = path.join(__dirname, '..', 'contracts', 'echidna')
const BASE_DOCS_DIR = path.join(__dirname, '..', 'invariant-docs')
const BASE_ECHIDNA_GH_URL = '../contracts/echidna/'
const BASE_INVARIANT_GH_URL = '../contracts/test/invariants/'
const NATSPEC_INV = '@custom:invariant'
const BLOCK_COMMENT_PREFIX_REGEX = /\*(\/)?/
const BLOCK_COMMENT_HEADER_REGEX = /\*\s(.)+/
// Represents an invariant test contract
type Contract = {
name: string
fileName: string
isEchidna: boolean
docs: InvariantDoc[]
}
// Represents the documentation of an invariant
type InvariantDoc = {
header?: string
desc?: string
lineNo?: number
}
const writtenFiles = []
/**
* Lazy-parses all test files in the `contracts/test/invariants` directory to generate documentation
* on all invariant tests.
*/
const docGen = (dir: string): void => {
// Grab all files within the invariants test dir
const files = fs.readdirSync(dir)
// Array to store all found invariant documentation comments.
const docs: Contract[] = []
for (const fileName of files) {
// Read the contents of the invariant test file.
const fileContents = fs.readFileSync(path.join(dir, fileName)).toString()
// Split the file into individual lines and trim whitespace.
const lines = fileContents.split('\n').map((line: string) => line.trim())
// Create an object to store all invariant test docs for the current contract
const isEchidna = fileName.startsWith('Fuzz')
const name = isEchidna
? fileName.replace('Fuzz', '').replace('.sol', '')
: fileName.replace('.t.sol', '')
const contract: Contract = { name, fileName, isEchidna, docs: [] }
let currentDoc: InvariantDoc
// Loop through all lines to find comments.
for (let i = 0; i < lines.length; i++) {
let line = lines[i]
if (line.startsWith('/**')) {
// We are at the beginning of a new doc comment. Reset the `currentDoc`.
currentDoc = {}
// Move on to the next line
line = lines[++i]
// We have an invariant doc
if (line.startsWith(`* ${NATSPEC_INV}`)) {
// Assign the header of the invariant doc.
// TODO: Handle ambiguous case for `INVARIANT: ` prefix.
// TODO: Handle multi-line headers.
currentDoc = {
header: line.replace(`* ${NATSPEC_INV}`, '').trim(),
desc: '',
}
// If the header is multi-line, continue appending to the `currentDoc`'s header.
while (BLOCK_COMMENT_HEADER_REGEX.test((line = lines[++i]))) {
currentDoc.header += ` ${line
.replace(BLOCK_COMMENT_PREFIX_REGEX, '')
.trim()}`
}
// Process the description
while ((line = lines[++i]).startsWith('*')) {
line = line.replace(BLOCK_COMMENT_PREFIX_REGEX, '').trim()
// If the line has any contents, insert it into the desc.
// Otherwise, consider it a linebreak.
currentDoc.desc += line.length > 0 ? `${line} ` : '\n'
}
// Set the line number of the test
currentDoc.lineNo = i + 1
// Add the doc to the contract
contract.docs.push(currentDoc)
}
}
}
// Add the contract to the array of docs
docs.push(contract)
}
for (const contract of docs) {
const fileName = path.join(BASE_DOCS_DIR, `${contract.name}.md`)
const alreadyWritten = writtenFiles.includes(fileName)
// If the file has already been written, append the extra docs to the end.
// Otherwise, write the file from scratch.
fs.writeFileSync(
fileName,
alreadyWritten
? `${fs.readFileSync(fileName)}\n${renderContractDoc(contract, false)}`
: renderContractDoc(contract, true)
)
// If the file was just written for the first time, add it to the list of written files.
if (!alreadyWritten) {
writtenFiles.push(fileName)
}
}
console.log(
`Generated invariant test documentation for:\n - ${
docs.length
} contracts\n - ${docs.reduce(
(acc: number, contract: Contract) => acc + contract.docs.length,
0
)} invariant tests\nsuccessfully!`
)
}
/**
* Generate a table of contents for all invariant docs and place it in the README.
*/
const tocGen = (): void => {
const autoTOCPrefix = '<!-- START autoTOC -->\n'
const autoTOCPostfix = '<!-- END autoTOC -->\n'
// Grab the name of all markdown files in `BASE_DOCS_DIR` except for `README.md`.
const files = fs
.readdirSync(BASE_DOCS_DIR)
.filter((fileName: string) => fileName !== 'README.md')
// Generate a table of contents section.
const tocList = files
.map(
(fileName: string) => `- [${fileName.replace('.md', '')}](./${fileName})`
)
.join('\n')
const toc = `${autoTOCPrefix}\n## Table of Contents\n${tocList}\n${autoTOCPostfix}`
// Write the table of contents to the README.
const readmeContents = fs
.readFileSync(path.join(BASE_DOCS_DIR, 'README.md'))
.toString()
const above = readmeContents.split(autoTOCPrefix)[0]
const below = readmeContents.split(autoTOCPostfix)[1]
fs.writeFileSync(
path.join(BASE_DOCS_DIR, 'README.md'),
`${above}${toc}${below}`
)
}
/**
* Render a `Contract` object into valid markdown.
*/
const renderContractDoc = (contract: Contract, header: boolean): string => {
const _header = header ? `# \`${contract.name}\` Invariants\n` : ''
const docs = contract.docs
.map((doc: InvariantDoc) => {
const line = `${contract.fileName}#L${doc.lineNo}`
return `## ${doc.header}\n**Test:** [\`${line}\`](${getGithubBase(
contract
)}${line})\n\n${doc.desc}`
})
.join('\n\n')
return `${_header}\n${docs}`
}
/**
* Get the base URL for the test contract
*/
const getGithubBase = ({ isEchidna }: Contract): string =>
isEchidna ? BASE_ECHIDNA_GH_URL : BASE_INVARIANT_GH_URL
// Generate the docs
// Forge
console.log('Generating docs for forge invariants...')
docGen(BASE_INVARIANTS_DIR)
// New line
console.log()
// Echidna
console.log('Generating docs for echidna invariants...')
docGen(BASE_ECHIDNA_DIR)
// Generate an updated table of contents
tocGen()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment