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

Merge branch 'develop' into dependabot/go_modules/github.com/docker/docker-20.10.24incompatible

parents 4761fede d0ee681f
[submodule "tests"] [submodule "tests"]
path = l2geth/tests/testdata path = l2geth/tests/testdata
url = https://github.com/ethereum/tests url = https://github.com/ethereum/tests
[submodule "packages/contracts-periphery/lib/multicall"]
path = packages/contracts-periphery/lib/multicall
url = https://github.com/mds1/multicall
[submodule "lib/multicall"]
branch = v3.1.0
...@@ -447,7 +447,7 @@ func TestBigL2Txs(gt *testing.T) { ...@@ -447,7 +447,7 @@ func TestBigL2Txs(gt *testing.T) {
require.NoError(t, err) require.NoError(t, err)
gas, err := core.IntrinsicGas(data, nil, false, true, true, false) gas, err := core.IntrinsicGas(data, nil, false, true, true, false)
require.NoError(t, err) require.NoError(t, err)
if gas > engine.l2GasPool.Gas() { if gas > engine.engineApi.RemainingBlockGas() {
break break
} }
tx := types.MustSignNewTx(dp.Secrets.Alice, signer, &types.DynamicFeeTx{ tx := types.MustSignNewTx(dp.Secrets.Alice, signer, &types.DynamicFeeTx{
......
...@@ -3,12 +3,12 @@ package actions ...@@ -3,12 +3,12 @@ package actions
import ( import (
"errors" "errors"
"github.com/ethereum-optimism/optimism/op-e2e/e2eutils"
"github.com/ethereum-optimism/optimism/op-program/l2/engineapi"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/ethereum/go-ethereum/beacon/engine"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
geth "github.com/ethereum/go-ethereum/eth" geth "github.com/ethereum/go-ethereum/eth"
"github.com/ethereum/go-ethereum/eth/ethconfig" "github.com/ethereum/go-ethereum/eth/ethconfig"
...@@ -39,21 +39,9 @@ type L2Engine struct { ...@@ -39,21 +39,9 @@ type L2Engine struct {
// L2 evm / chain // L2 evm / chain
l2Chain *core.BlockChain l2Chain *core.BlockChain
l2Database ethdb.Database
l2Cfg *core.Genesis
l2Signer types.Signer l2Signer types.Signer
// L2 block building data engineApi *engineapi.L2EngineAPI
l2BuildingHeader *types.Header // block header that we add txs to for block building
l2BuildingState *state.StateDB // state used for block building
l2GasPool *core.GasPool // track gas used of ongoing building
pendingIndices map[common.Address]uint64 // per account, how many txs from the pool were already included in the block, since the pool is lagging behind block mining.
l2Transactions []*types.Transaction // collects txs that were successfully included into current block build
l2Receipts []*types.Receipt // collect receipts of ongoing building
l2ForceEmpty bool // when no additional txs may be processed (i.e. when sequencer drift runs out)
l2TxFailed []*types.Transaction // log of failed transactions which could not be included
payloadID engine.PayloadID // ID of payload that is currently being built
failL2RPC error // mock error failL2RPC error // mock error
} }
...@@ -61,6 +49,38 @@ type L2Engine struct { ...@@ -61,6 +49,38 @@ type L2Engine struct {
type EngineOption func(ethCfg *ethconfig.Config, nodeCfg *node.Config) error type EngineOption func(ethCfg *ethconfig.Config, nodeCfg *node.Config) error
func NewL2Engine(t Testing, log log.Logger, genesis *core.Genesis, rollupGenesisL1 eth.BlockID, jwtPath string, options ...EngineOption) *L2Engine { func NewL2Engine(t Testing, log log.Logger, genesis *core.Genesis, rollupGenesisL1 eth.BlockID, jwtPath string, options ...EngineOption) *L2Engine {
n, ethBackend, apiBackend := newBackend(t, genesis, jwtPath, options)
engineApi := engineapi.NewL2EngineAPI(log, apiBackend)
chain := ethBackend.BlockChain()
genesisBlock := chain.Genesis()
eng := &L2Engine{
log: log,
node: n,
eth: ethBackend,
rollupGenesis: &rollup.Genesis{
L1: rollupGenesisL1,
L2: eth.BlockID{Hash: genesisBlock.Hash(), Number: genesisBlock.NumberU64()},
L2Time: genesis.Timestamp,
},
l2Chain: chain,
l2Signer: types.LatestSigner(genesis.Config),
engineApi: engineApi,
}
// register the custom engine API, so we can serve engine requests while having more control
// over sequencing of individual txs.
n.RegisterAPIs([]rpc.API{
{
Namespace: "engine",
Service: eng.engineApi,
Authenticated: true,
},
})
require.NoError(t, n.Start(), "failed to start L2 op-geth node")
return eng
}
func newBackend(t e2eutils.TestingBase, genesis *core.Genesis, jwtPath string, options []EngineOption) (*node.Node, *geth.Ethereum, *engineApiBackend) {
ethCfg := &ethconfig.Config{ ethCfg := &ethconfig.Config{
NetworkId: genesis.Config.ChainID.Uint64(), NetworkId: genesis.Config.ChainID.Uint64(),
Genesis: genesis, Genesis: genesis,
...@@ -89,33 +109,26 @@ func NewL2Engine(t Testing, log log.Logger, genesis *core.Genesis, rollupGenesis ...@@ -89,33 +109,26 @@ func NewL2Engine(t Testing, log log.Logger, genesis *core.Genesis, rollupGenesis
chain := backend.BlockChain() chain := backend.BlockChain()
db := backend.ChainDb() db := backend.ChainDb()
genesisBlock := chain.Genesis() apiBackend := &engineApiBackend{
eng := &L2Engine{ BlockChain: chain,
log: log, db: db,
node: n, genesis: genesis,
eth: backend,
rollupGenesis: &rollup.Genesis{
L1: rollupGenesisL1,
L2: eth.BlockID{Hash: genesisBlock.Hash(), Number: genesisBlock.NumberU64()},
L2Time: genesis.Timestamp,
},
l2Chain: chain,
l2Database: db,
l2Cfg: genesis,
l2Signer: types.LatestSigner(genesis.Config),
} }
// register the custom engine API, so we can serve engine requests while having more control return n, backend, apiBackend
// over sequencing of individual txs. }
n.RegisterAPIs([]rpc.API{
{
Namespace: "engine",
Service: (*L2EngineAPI)(eng),
Authenticated: true,
},
})
require.NoError(t, n.Start(), "failed to start L2 op-geth node")
return eng type engineApiBackend struct {
*core.BlockChain
db ethdb.Database
genesis *core.Genesis
}
func (e *engineApiBackend) Database() ethdb.Database {
return e.db
}
func (e *engineApiBackend) Genesis() *core.Genesis {
return e.genesis
} }
func (s *L2Engine) EthClient() *ethclient.Client { func (s *L2Engine) EthClient() *ethclient.Client {
...@@ -158,39 +171,25 @@ func (e *L2Engine) ActL2RPCFail(t Testing) { ...@@ -158,39 +171,25 @@ func (e *L2Engine) ActL2RPCFail(t Testing) {
// ActL2IncludeTx includes the next transaction from the given address in the block that is being built // ActL2IncludeTx includes the next transaction from the given address in the block that is being built
func (e *L2Engine) ActL2IncludeTx(from common.Address) Action { func (e *L2Engine) ActL2IncludeTx(from common.Address) Action {
return func(t Testing) { return func(t Testing) {
if e.l2BuildingHeader == nil { if e.engineApi.ForcedEmpty() {
t.InvalidAction("not currently building a block, cannot include tx from queue")
return
}
if e.l2ForceEmpty {
e.log.Info("Skipping including a transaction because e.L2ForceEmpty is true") e.log.Info("Skipping including a transaction because e.L2ForceEmpty is true")
// t.InvalidAction("cannot include any sequencer txs")
return return
} }
i := e.pendingIndices[from] i := e.engineApi.PendingIndices(from)
txs, q := e.eth.TxPool().ContentFrom(from) txs, q := e.eth.TxPool().ContentFrom(from)
if uint64(len(txs)) <= i { if uint64(len(txs)) <= i {
t.Fatalf("no pending txs from %s, and have %d unprocessable queued txs from this account", from, len(q)) t.Fatalf("no pending txs from %s, and have %d unprocessable queued txs from this account", from, len(q))
} }
tx := txs[i] tx := txs[i]
if tx.Gas() > e.l2BuildingHeader.GasLimit { err := e.engineApi.IncludeTx(tx, from)
t.Fatalf("tx consumes %d gas, more than available in L2 block %d", tx.Gas(), e.l2BuildingHeader.GasLimit) if errors.Is(err, engineapi.ErrNotBuildingBlock) {
} t.InvalidAction(err.Error())
if tx.Gas() > uint64(*e.l2GasPool) { } else if errors.Is(err, engineapi.ErrUsesTooMuchGas) {
t.InvalidAction("action takes too much gas: %d, only have %d", tx.Gas(), uint64(*e.l2GasPool)) t.InvalidAction("included tx uses too much gas: %v", err)
return } else if err != nil {
} t.Fatalf("include tx: %v", err)
e.pendingIndices[from] = i + 1 // won't retry the tx
e.l2BuildingState.SetTxContext(tx.Hash(), len(e.l2Transactions))
receipt, err := core.ApplyTransaction(e.l2Cfg.Config, e.l2Chain, &e.l2BuildingHeader.Coinbase,
e.l2GasPool, e.l2BuildingState, e.l2BuildingHeader, tx, &e.l2BuildingHeader.GasUsed, *e.l2Chain.GetVMConfig())
if err != nil {
e.l2TxFailed = append(e.l2TxFailed, tx)
t.Fatalf("failed to apply transaction to L2 block (tx %d): %v", len(e.l2Transactions), err)
} }
e.l2Receipts = append(e.l2Receipts, receipt)
e.l2Transactions = append(e.l2Transactions, tx)
} }
} }
......
...@@ -4,6 +4,8 @@ import ( ...@@ -4,6 +4,8 @@ import (
"math/big" "math/big"
"testing" "testing"
"github.com/ethereum-optimism/optimism/op-program/l2/engineapi"
"github.com/ethereum-optimism/optimism/op-program/l2/engineapi/test"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/consensus/beacon" "github.com/ethereum/go-ethereum/consensus/beacon"
"github.com/ethereum/go-ethereum/consensus/ethash" "github.com/ethereum/go-ethereum/consensus/ethash"
...@@ -187,3 +189,15 @@ func TestL2EngineAPIFail(gt *testing.T) { ...@@ -187,3 +189,15 @@ func TestL2EngineAPIFail(gt *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(gt, sd.L2Cfg.ToBlock().Hash(), head.Hash(), "expecting engine to start at genesis") require.Equal(gt, sd.L2Cfg.ToBlock().Hash(), head.Hash(), "expecting engine to start at genesis")
} }
func TestEngineAPITests(t *testing.T) {
test.RunEngineAPITests(t, func() engineapi.EngineBackend {
jwtPath := e2eutils.WriteDefaultJWT(t)
dp := e2eutils.MakeDeployParams(t, defaultRollupTestParams)
sd := e2eutils.Setup(t, dp, defaultAlloc)
n, _, apiBackend := newBackend(t, sd.L2Cfg, jwtPath, nil)
err := n.Start()
require.NoError(t, err)
return apiBackend
})
}
...@@ -98,7 +98,7 @@ func TestL2Sequencer_SequencerDrift(gt *testing.T) { ...@@ -98,7 +98,7 @@ func TestL2Sequencer_SequencerDrift(gt *testing.T) {
// We passed the sequencer drift: we can still keep the old origin, but can't include any txs // We passed the sequencer drift: we can still keep the old origin, but can't include any txs
sequencer.ActL2KeepL1Origin(t) sequencer.ActL2KeepL1Origin(t)
sequencer.ActL2StartBlock(t) sequencer.ActL2StartBlock(t)
require.True(t, engine.l2ForceEmpty, "engine should not be allowed to include anything after sequencer drift is surpassed") require.True(t, engine.engineApi.ForcedEmpty(), "engine should not be allowed to include anything after sequencer drift is surpassed")
} }
// TestL2Sequencer_SequencerOnlyReorg regression-tests a Goerli halt where the sequencer // TestL2Sequencer_SequencerOnlyReorg regression-tests a Goerli halt where the sequencer
......
package actions package engineapi
import ( import (
"context" "context"
...@@ -11,26 +11,74 @@ import ( ...@@ -11,26 +11,74 @@ import (
"github.com/ethereum/go-ethereum/beacon/engine" "github.com/ethereum/go-ethereum/beacon/engine"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/consensus"
"github.com/ethereum/go-ethereum/consensus/misc" "github.com/ethereum/go-ethereum/consensus/misc"
"github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core"
"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/core/types"
"github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/params"
"github.com/ethereum/go-ethereum/trie" "github.com/ethereum/go-ethereum/trie"
"github.com/ethereum-optimism/optimism/op-node/eth" "github.com/ethereum-optimism/optimism/op-node/eth"
) )
type EngineBackend interface {
CurrentBlock() *types.Header
CurrentSafeBlock() *types.Header
CurrentFinalBlock() *types.Header
GetHeaderByHash(hash common.Hash) *types.Header
GetBlockByHash(hash common.Hash) *types.Block
GetBlock(hash common.Hash, number uint64) *types.Block
// GetHeader returns the header corresponding to the hash/number argument pair.
GetHeader(common.Hash, uint64) *types.Header
HasBlockAndState(hash common.Hash, number uint64) bool
GetCanonicalHash(n uint64) common.Hash
GetVMConfig() *vm.Config
Config() *params.ChainConfig
// Engine retrieves the chain's consensus engine.
Engine() consensus.Engine
StateAt(root common.Hash) (*state.StateDB, error)
InsertBlockWithoutSetHead(block *types.Block) error
SetCanonical(head *types.Block) (common.Hash, error)
SetFinalized(header *types.Header)
SetSafe(header *types.Header)
}
// L2EngineAPI wraps an engine actor, and implements the RPC backend required to serve the engine API. // L2EngineAPI wraps an engine actor, and implements the RPC backend required to serve the engine API.
// This re-implements some of the Geth API work, but changes the API backend so we can deterministically // This re-implements some of the Geth API work, but changes the API backend so we can deterministically
// build and control the L2 block contents to reach very specific edge cases as desired for testing. // build and control the L2 block contents to reach very specific edge cases as desired for testing.
type L2EngineAPI L2Engine type L2EngineAPI struct {
log log.Logger
backend EngineBackend
// L2 block building data
l2BuildingHeader *types.Header // block header that we add txs to for block building
l2BuildingState *state.StateDB // state used for block building
l2GasPool *core.GasPool // track gas used of ongoing building
pendingIndices map[common.Address]uint64 // per account, how many txs from the pool were already included in the block, since the pool is lagging behind block mining.
l2Transactions []*types.Transaction // collects txs that were successfully included into current block build
l2Receipts []*types.Receipt // collect receipts of ongoing building
l2ForceEmpty bool // when no additional txs may be processed (i.e. when sequencer drift runs out)
l2TxFailed []*types.Transaction // log of failed transactions which could not be included
payloadID engine.PayloadID // ID of payload that is currently being built
}
func NewL2EngineAPI(log log.Logger, backend EngineBackend) *L2EngineAPI {
return &L2EngineAPI{
log: log,
backend: backend,
}
}
var ( var (
STATUS_INVALID = &eth.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionInvalid}, PayloadID: nil} STATUS_INVALID = &eth.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionInvalid}, PayloadID: nil}
STATUS_SYNCING = &eth.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionSyncing}, PayloadID: nil} STATUS_SYNCING = &eth.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionSyncing}, PayloadID: nil}
INVALID_TERMINAL_BLOCK = eth.PayloadStatusV1{Status: eth.ExecutionInvalid, LatestValidHash: &common.Hash{}}
) )
// computePayloadId computes a pseudo-random payloadid, based on the parameters. // computePayloadId computes a pseudo-random payloadid, based on the parameters.
...@@ -53,16 +101,64 @@ func computePayloadId(headBlockHash common.Hash, params *eth.PayloadAttributes) ...@@ -53,16 +101,64 @@ func computePayloadId(headBlockHash common.Hash, params *eth.PayloadAttributes)
return out return out
} }
func (ea *L2EngineAPI) RemainingBlockGas() uint64 {
return ea.l2GasPool.Gas()
}
func (ea *L2EngineAPI) ForcedEmpty() bool {
return ea.l2ForceEmpty
}
func (ea *L2EngineAPI) PendingIndices(from common.Address) uint64 {
return ea.pendingIndices[from]
}
var (
ErrNotBuildingBlock = errors.New("not currently building a block, cannot include tx from queue")
ErrExceedsGasLimit = errors.New("tx gas exceeds block gas limit")
ErrUsesTooMuchGas = errors.New("action takes too much gas")
)
func (ea *L2EngineAPI) IncludeTx(tx *types.Transaction, from common.Address) error {
if ea.l2BuildingHeader == nil {
return ErrNotBuildingBlock
}
if ea.l2ForceEmpty {
ea.log.Info("Skipping including a transaction because e.L2ForceEmpty is true")
// t.InvalidAction("cannot include any sequencer txs")
return nil
}
if tx.Gas() > ea.l2BuildingHeader.GasLimit {
return fmt.Errorf("%w tx gas: %d, block gas limit: %d", ErrExceedsGasLimit, tx.Gas(), ea.l2BuildingHeader.GasLimit)
}
if tx.Gas() > uint64(*ea.l2GasPool) {
return fmt.Errorf("%w: %d, only have %d", ErrUsesTooMuchGas, tx.Gas(), uint64(*ea.l2GasPool))
}
ea.pendingIndices[from] = ea.pendingIndices[from] + 1 // won't retry the tx
ea.l2BuildingState.SetTxContext(tx.Hash(), len(ea.l2Transactions))
receipt, err := core.ApplyTransaction(ea.backend.Config(), ea.backend, &ea.l2BuildingHeader.Coinbase,
ea.l2GasPool, ea.l2BuildingState, ea.l2BuildingHeader, tx, &ea.l2BuildingHeader.GasUsed, *ea.backend.GetVMConfig())
if err != nil {
ea.l2TxFailed = append(ea.l2TxFailed, tx)
return fmt.Errorf("invalid L2 block (tx %d): %w", len(ea.l2Transactions), err)
}
ea.l2Receipts = append(ea.l2Receipts, receipt)
ea.l2Transactions = append(ea.l2Transactions, tx)
return nil
}
func (ea *L2EngineAPI) startBlock(parent common.Hash, params *eth.PayloadAttributes) error { func (ea *L2EngineAPI) startBlock(parent common.Hash, params *eth.PayloadAttributes) error {
if ea.l2BuildingHeader != nil { if ea.l2BuildingHeader != nil {
ea.log.Warn("started building new block without ending previous block", "previous", ea.l2BuildingHeader, "prev_payload_id", ea.payloadID) ea.log.Warn("started building new block without ending previous block", "previous", ea.l2BuildingHeader, "prev_payload_id", ea.payloadID)
} }
parentHeader := ea.l2Chain.GetHeaderByHash(parent) parentHeader := ea.backend.GetHeaderByHash(parent)
if parentHeader == nil { if parentHeader == nil {
return fmt.Errorf("uknown parent block: %s", parent) return fmt.Errorf("uknown parent block: %s", parent)
} }
statedb, err := state.New(parentHeader.Root, state.NewDatabase(ea.l2Database), nil) statedb, err := ea.backend.StateAt(parentHeader.Root)
if err != nil { if err != nil {
return fmt.Errorf("failed to init state db around block %s (state %s): %w", parent, parentHeader.Root, err) return fmt.Errorf("failed to init state db around block %s (state %s): %w", parent, parentHeader.Root, err)
} }
...@@ -78,7 +174,7 @@ func (ea *L2EngineAPI) startBlock(parent common.Hash, params *eth.PayloadAttribu ...@@ -78,7 +174,7 @@ func (ea *L2EngineAPI) startBlock(parent common.Hash, params *eth.PayloadAttribu
MixDigest: common.Hash(params.PrevRandao), MixDigest: common.Hash(params.PrevRandao),
} }
header.BaseFee = misc.CalcBaseFee(ea.l2Cfg.Config, parentHeader) header.BaseFee = misc.CalcBaseFee(ea.backend.Config(), parentHeader)
ea.l2BuildingHeader = header ea.l2BuildingHeader = header
ea.l2BuildingState = statedb ea.l2BuildingState = statedb
...@@ -96,8 +192,8 @@ func (ea *L2EngineAPI) startBlock(parent common.Hash, params *eth.PayloadAttribu ...@@ -96,8 +192,8 @@ func (ea *L2EngineAPI) startBlock(parent common.Hash, params *eth.PayloadAttribu
return fmt.Errorf("transaction %d is not valid: %w", i, err) return fmt.Errorf("transaction %d is not valid: %w", i, err)
} }
ea.l2BuildingState.SetTxContext(tx.Hash(), i) ea.l2BuildingState.SetTxContext(tx.Hash(), i)
receipt, err := core.ApplyTransaction(ea.l2Cfg.Config, ea.l2Chain, &ea.l2BuildingHeader.Coinbase, receipt, err := core.ApplyTransaction(ea.backend.Config(), ea.backend, &ea.l2BuildingHeader.Coinbase,
ea.l2GasPool, ea.l2BuildingState, ea.l2BuildingHeader, &tx, &ea.l2BuildingHeader.GasUsed, *ea.l2Chain.GetVMConfig()) ea.l2GasPool, ea.l2BuildingState, ea.l2BuildingHeader, &tx, &ea.l2BuildingHeader.GasUsed, *ea.backend.GetVMConfig())
if err != nil { if err != nil {
ea.l2TxFailed = append(ea.l2TxFailed, &tx) ea.l2TxFailed = append(ea.l2TxFailed, &tx)
return fmt.Errorf("failed to apply deposit transaction to L2 block (tx %d): %w", i, err) return fmt.Errorf("failed to apply deposit transaction to L2 block (tx %d): %w", i, err)
...@@ -116,17 +212,8 @@ func (ea *L2EngineAPI) endBlock() (*types.Block, error) { ...@@ -116,17 +212,8 @@ func (ea *L2EngineAPI) endBlock() (*types.Block, error) {
ea.l2BuildingHeader = nil ea.l2BuildingHeader = nil
header.GasUsed = header.GasLimit - uint64(*ea.l2GasPool) header.GasUsed = header.GasLimit - uint64(*ea.l2GasPool)
header.Root = ea.l2BuildingState.IntermediateRoot(ea.l2Cfg.Config.IsEIP158(header.Number)) header.Root = ea.l2BuildingState.IntermediateRoot(ea.backend.Config().IsEIP158(header.Number))
block := types.NewBlock(header, ea.l2Transactions, nil, ea.l2Receipts, trie.NewStackTrie(nil)) block := types.NewBlock(header, ea.l2Transactions, nil, ea.l2Receipts, trie.NewStackTrie(nil))
// Write state changes to db
root, err := ea.l2BuildingState.Commit(ea.l2Cfg.Config.IsEIP158(header.Number))
if err != nil {
return nil, fmt.Errorf("l2 state write error: %w", err)
}
if err := ea.l2BuildingState.Database().TrieDB().Commit(root, false); err != nil {
return nil, fmt.Errorf("l2 trie write error: %w", err)
}
return block, nil return block, nil
} }
...@@ -153,31 +240,16 @@ func (ea *L2EngineAPI) ForkchoiceUpdatedV1(ctx context.Context, state *eth.Forkc ...@@ -153,31 +240,16 @@ func (ea *L2EngineAPI) ForkchoiceUpdatedV1(ctx context.Context, state *eth.Forkc
// Check whether we have the block yet in our database or not. If not, we'll // Check whether we have the block yet in our database or not. If not, we'll
// need to either trigger a sync, or to reject this forkchoice update for a // need to either trigger a sync, or to reject this forkchoice update for a
// reason. // reason.
block := ea.l2Chain.GetBlockByHash(state.HeadBlockHash) block := ea.backend.GetBlockByHash(state.HeadBlockHash)
if block == nil { if block == nil {
// TODO: syncing not supported yet // TODO: syncing not supported yet
return STATUS_SYNCING, nil return STATUS_SYNCING, nil
} }
// Block is known locally, just sanity check that the beacon client does not // Block is known locally, just sanity check that the beacon client does not
// attempt to push us back to before the merge. // attempt to push us back to before the merge.
if block.Difficulty().BitLen() > 0 || block.NumberU64() == 0 { // Note: Differs from op-geth implementation as pre-merge blocks are never supported here
var ( if block.Difficulty().BitLen() > 0 {
td = ea.l2Chain.GetTd(state.HeadBlockHash, block.NumberU64()) return STATUS_INVALID, errors.New("pre-merge blocks not supported")
ptd = ea.l2Chain.GetTd(block.ParentHash(), block.NumberU64()-1)
ttd = ea.l2Chain.Config().TerminalTotalDifficulty
)
if td == nil || (block.NumberU64() > 0 && ptd == nil) {
ea.log.Error("TDs unavailable for TTD check", "number", block.NumberU64(), "hash", state.HeadBlockHash, "td", td, "parent", block.ParentHash(), "ptd", ptd)
return STATUS_INVALID, errors.New("TDs unavailable for TDD check")
}
if td.Cmp(ttd) < 0 {
ea.log.Error("Refusing beacon update to pre-merge", "number", block.NumberU64(), "hash", state.HeadBlockHash, "diff", block.Difficulty(), "age", common.PrettyAge(time.Unix(int64(block.Time()), 0)))
return &eth.ForkchoiceUpdatedResult{PayloadStatus: INVALID_TERMINAL_BLOCK, PayloadID: nil}, nil
}
if block.NumberU64() > 0 && ptd.Cmp(ttd) >= 0 {
ea.log.Error("Parent block is already post-ttd", "number", block.NumberU64(), "hash", state.HeadBlockHash, "diff", block.Difficulty(), "age", common.PrettyAge(time.Unix(int64(block.Time()), 0)))
return &eth.ForkchoiceUpdatedResult{PayloadStatus: INVALID_TERMINAL_BLOCK, PayloadID: nil}, nil
}
} }
valid := func(id *engine.PayloadID) *eth.ForkchoiceUpdatedResult { valid := func(id *engine.PayloadID) *eth.ForkchoiceUpdatedResult {
return &eth.ForkchoiceUpdatedResult{ return &eth.ForkchoiceUpdatedResult{
...@@ -185,16 +257,16 @@ func (ea *L2EngineAPI) ForkchoiceUpdatedV1(ctx context.Context, state *eth.Forkc ...@@ -185,16 +257,16 @@ func (ea *L2EngineAPI) ForkchoiceUpdatedV1(ctx context.Context, state *eth.Forkc
PayloadID: id, PayloadID: id,
} }
} }
if rawdb.ReadCanonicalHash(ea.l2Database, block.NumberU64()) != state.HeadBlockHash { if ea.backend.GetCanonicalHash(block.NumberU64()) != state.HeadBlockHash {
// Block is not canonical, set head. // Block is not canonical, set head.
if latestValid, err := ea.l2Chain.SetCanonical(block); err != nil { if latestValid, err := ea.backend.SetCanonical(block); err != nil {
return &eth.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionInvalid, LatestValidHash: &latestValid}}, err return &eth.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionInvalid, LatestValidHash: &latestValid}}, err
} }
} else if ea.l2Chain.CurrentBlock().Hash() == state.HeadBlockHash { } else if ea.backend.CurrentBlock().Hash() == state.HeadBlockHash {
// If the specified head matches with our local head, do nothing and keep // If the specified head matches with our local head, do nothing and keep
// generating the payload. It's a special corner case that a few slots are // generating the payload. It's a special corner case that a few slots are
// missing and we are requested to generate the payload in slot. // missing and we are requested to generate the payload in slot.
} else if ea.l2Chain.Config().Optimism == nil { // minor L2Engine API divergence: allow proposers to reorg their own chain } else if ea.backend.Config().Optimism == nil { // minor L2Engine API divergence: allow proposers to reorg their own chain
panic("engine not configured as optimism engine") panic("engine not configured as optimism engine")
} }
...@@ -202,30 +274,30 @@ func (ea *L2EngineAPI) ForkchoiceUpdatedV1(ctx context.Context, state *eth.Forkc ...@@ -202,30 +274,30 @@ func (ea *L2EngineAPI) ForkchoiceUpdatedV1(ctx context.Context, state *eth.Forkc
// chain final and completely in PoS mode. // chain final and completely in PoS mode.
if state.FinalizedBlockHash != (common.Hash{}) { if state.FinalizedBlockHash != (common.Hash{}) {
// If the finalized block is not in our canonical tree, somethings wrong // If the finalized block is not in our canonical tree, somethings wrong
finalHeader := ea.l2Chain.GetHeaderByHash(state.FinalizedBlockHash) finalHeader := ea.backend.GetHeaderByHash(state.FinalizedBlockHash)
if finalHeader == nil { if finalHeader == nil {
ea.log.Warn("Final block not available in database", "hash", state.FinalizedBlockHash) ea.log.Warn("Final block not available in database", "hash", state.FinalizedBlockHash)
return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("final block not available in database")) return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("final block not available in database"))
} else if rawdb.ReadCanonicalHash(ea.l2Database, finalHeader.Number.Uint64()) != state.FinalizedBlockHash { } else if ea.backend.GetCanonicalHash(finalHeader.Number.Uint64()) != state.FinalizedBlockHash {
ea.log.Warn("Final block not in canonical chain", "number", block.NumberU64(), "hash", state.HeadBlockHash) ea.log.Warn("Final block not in canonical chain", "number", block.NumberU64(), "hash", state.HeadBlockHash)
return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("final block not in canonical chain")) return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("final block not in canonical chain"))
} }
// Set the finalized block // Set the finalized block
ea.l2Chain.SetFinalized(finalHeader) ea.backend.SetFinalized(finalHeader)
} }
// Check if the safe block hash is in our canonical tree, if not somethings wrong // Check if the safe block hash is in our canonical tree, if not somethings wrong
if state.SafeBlockHash != (common.Hash{}) { if state.SafeBlockHash != (common.Hash{}) {
safeHeader := ea.l2Chain.GetHeaderByHash(state.SafeBlockHash) safeHeader := ea.backend.GetHeaderByHash(state.SafeBlockHash)
if safeHeader == nil { if safeHeader == nil {
ea.log.Warn("Safe block not available in database") ea.log.Warn("Safe block not available in database")
return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("safe block not available in database")) return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("safe block not available in database"))
} }
if rawdb.ReadCanonicalHash(ea.l2Database, safeHeader.Number.Uint64()) != state.SafeBlockHash { if ea.backend.GetCanonicalHash(safeHeader.Number.Uint64()) != state.SafeBlockHash {
ea.log.Warn("Safe block not in canonical chain") ea.log.Warn("Safe block not in canonical chain")
return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("safe block not in canonical chain")) return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("safe block not in canonical chain"))
} }
// Set the safe block // Set the safe block
ea.l2Chain.SetSafe(safeHeader) ea.backend.SetSafe(safeHeader)
} }
// If payload generation was requested, create a new block to be potentially // If payload generation was requested, create a new block to be potentially
// sealed by the beacon client. The payload will be requested later, and we // sealed by the beacon client. The payload will be requested later, and we
...@@ -270,7 +342,7 @@ func (ea *L2EngineAPI) NewPayloadV1(ctx context.Context, payload *eth.ExecutionP ...@@ -270,7 +342,7 @@ func (ea *L2EngineAPI) NewPayloadV1(ctx context.Context, payload *eth.ExecutionP
} }
// If we already have the block locally, ignore the entire execution and just // If we already have the block locally, ignore the entire execution and just
// return a fake success. // return a fake success.
if block := ea.l2Chain.GetBlockByHash(payload.BlockHash); block != nil { if block := ea.backend.GetBlockByHash(payload.BlockHash); block != nil {
ea.log.Warn("Ignoring already known beacon payload", "number", payload.BlockNumber, "hash", payload.BlockHash, "age", common.PrettyAge(time.Unix(int64(block.Time()), 0))) ea.log.Warn("Ignoring already known beacon payload", "number", payload.BlockNumber, "hash", payload.BlockHash, "age", common.PrettyAge(time.Unix(int64(block.Time()), 0)))
hash := block.Hash() hash := block.Hash()
return &eth.PayloadStatusV1{Status: eth.ExecutionValid, LatestValidHash: &hash}, nil return &eth.PayloadStatusV1{Status: eth.ExecutionValid, LatestValidHash: &hash}, nil
...@@ -278,16 +350,23 @@ func (ea *L2EngineAPI) NewPayloadV1(ctx context.Context, payload *eth.ExecutionP ...@@ -278,16 +350,23 @@ func (ea *L2EngineAPI) NewPayloadV1(ctx context.Context, payload *eth.ExecutionP
// TODO: skipping invalid ancestor check (i.e. not remembering previously failed blocks) // TODO: skipping invalid ancestor check (i.e. not remembering previously failed blocks)
parent := ea.l2Chain.GetBlock(block.ParentHash(), block.NumberU64()-1) parent := ea.backend.GetBlock(block.ParentHash(), block.NumberU64()-1)
if parent == nil { if parent == nil {
// TODO: hack, saying we accepted if we don't know the parent block. Might want to return critical error if we can't actually sync. // TODO: hack, saying we accepted if we don't know the parent block. Might want to return critical error if we can't actually sync.
return &eth.PayloadStatusV1{Status: eth.ExecutionAccepted, LatestValidHash: nil}, nil return &eth.PayloadStatusV1{Status: eth.ExecutionAccepted, LatestValidHash: nil}, nil
} }
if !ea.l2Chain.HasBlockAndState(block.ParentHash(), block.NumberU64()-1) {
if block.Time() <= parent.Time() {
log.Warn("Invalid timestamp", "parent", block.Time(), "block", block.Time())
return ea.invalid(errors.New("invalid timestamp"), parent.Header()), nil
}
if !ea.backend.HasBlockAndState(block.ParentHash(), block.NumberU64()-1) {
ea.log.Warn("State not available, ignoring new payload") ea.log.Warn("State not available, ignoring new payload")
return &eth.PayloadStatusV1{Status: eth.ExecutionAccepted}, nil return &eth.PayloadStatusV1{Status: eth.ExecutionAccepted}, nil
} }
if err := ea.l2Chain.InsertBlockWithoutSetHead(block); err != nil { log.Trace("Inserting block without sethead", "hash", block.Hash(), "number", block.Number)
if err := ea.backend.InsertBlockWithoutSetHead(block); err != nil {
ea.log.Warn("NewPayloadV1: inserting block failed", "error", err) ea.log.Warn("NewPayloadV1: inserting block failed", "error", err)
// TODO not remembering the payload as invalid // TODO not remembering the payload as invalid
return ea.invalid(err, parent.Header()), nil return ea.invalid(err, parent.Header()), nil
...@@ -297,7 +376,7 @@ func (ea *L2EngineAPI) NewPayloadV1(ctx context.Context, payload *eth.ExecutionP ...@@ -297,7 +376,7 @@ func (ea *L2EngineAPI) NewPayloadV1(ctx context.Context, payload *eth.ExecutionP
} }
func (ea *L2EngineAPI) invalid(err error, latestValid *types.Header) *eth.PayloadStatusV1 { func (ea *L2EngineAPI) invalid(err error, latestValid *types.Header) *eth.PayloadStatusV1 {
currentHash := ea.l2Chain.CurrentBlock().Hash() currentHash := ea.backend.CurrentBlock().Hash()
if latestValid != nil { if latestValid != nil {
// Set latest valid hash to 0x0 if parent is PoW block // Set latest valid hash to 0x0 if parent is PoW block
currentHash = common.Hash{} currentHash = common.Hash{}
......
package test
import (
"context"
"testing"
"github.com/ethereum-optimism/optimism/op-node/eth"
"github.com/ethereum-optimism/optimism/op-node/rollup/derive"
"github.com/ethereum-optimism/optimism/op-node/testlog"
"github.com/ethereum-optimism/optimism/op-program/l2/engineapi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log"
"github.com/stretchr/testify/require"
)
var gasLimit = eth.Uint64Quantity(30_000_000)
var feeRecipient = common.Address{}
func RunEngineAPITests(t *testing.T, createBackend func() engineapi.EngineBackend) {
t.Run("CreateBlock", func(t *testing.T) {
api := newTestHelper(t, createBackend)
block := api.addBlock()
api.assert.Equal(block.BlockHash, api.headHash(), "should create and import new block")
})
t.Run("IncludeRequiredTransactions", func(t *testing.T) {
api := newTestHelper(t, createBackend)
genesis := api.backend.CurrentBlock()
txData, err := derive.L1InfoDeposit(1, eth.HeaderBlockInfo(genesis), eth.SystemConfig{}, true)
api.assert.NoError(err)
tx := types.NewTx(txData)
block := api.addBlock(tx)
api.assert.Equal(block.BlockHash, api.headHash(), "should create and import new block")
imported := api.backend.GetBlockByHash(block.BlockHash)
api.assert.Len(imported.Transactions(), 1, "should include transaction")
})
t.Run("IgnoreUpdateHeadToOlderBlock", func(t *testing.T) {
api := newTestHelper(t, createBackend)
genesisHash := api.headHash()
api.addBlock()
block := api.addBlock()
api.assert.Equal(block.BlockHash, api.headHash(), "should have extended chain")
api.forkChoiceUpdated(genesisHash, genesisHash, genesisHash)
api.assert.Equal(block.BlockHash, api.headHash(), "should not have reset chain head")
})
t.Run("AllowBuildingOnOlderBlock", func(t *testing.T) {
api := newTestHelper(t, createBackend)
genesis := api.backend.CurrentBlock()
api.addBlock()
block := api.addBlock()
api.assert.Equal(block.BlockHash, api.headHash(), "should have extended chain")
payloadID := api.startBlockBuilding(genesis, eth.Uint64Quantity(genesis.Time+3))
api.assert.Equal(block.BlockHash, api.headHash(), "should not reset chain head when building starts")
payload := api.getPayload(payloadID)
api.assert.Equal(genesis.Hash(), payload.ParentHash, "should have old block as parent")
api.newPayload(payload)
api.forkChoiceUpdated(payload.BlockHash, genesis.Hash(), genesis.Hash())
api.assert.Equal(payload.BlockHash, api.headHash(), "should reorg to block built on old parent")
})
t.Run("RejectInvalidBlockHash", func(t *testing.T) {
api := newTestHelper(t, createBackend)
// Invalid because BlockHash won't be correct (among many other reasons)
block := &eth.ExecutionPayload{}
r, err := api.engine.NewPayloadV1(api.ctx, block)
api.assert.NoError(err)
api.assert.Equal(eth.ExecutionInvalidBlockHash, r.Status)
})
t.Run("RejectBlockWithInvalidStateTransition", func(t *testing.T) {
api := newTestHelper(t, createBackend)
genesis := api.backend.CurrentBlock()
// Build a valid block
payloadID := api.startBlockBuilding(genesis, eth.Uint64Quantity(genesis.Time+2))
newBlock := api.getPayload(payloadID)
// But then make it invalid by changing the state root
newBlock.StateRoot = eth.Bytes32(genesis.TxHash)
updateBlockHash(newBlock)
r, err := api.engine.NewPayloadV1(api.ctx, newBlock)
api.assert.NoError(err)
api.assert.Equal(eth.ExecutionInvalid, r.Status)
})
t.Run("RejectBlockWithSameTimeAsParent", func(t *testing.T) {
api := newTestHelper(t, createBackend)
genesis := api.backend.CurrentBlock()
payloadID := api.startBlockBuilding(genesis, eth.Uint64Quantity(genesis.Time))
newBlock := api.getPayload(payloadID)
r, err := api.engine.NewPayloadV1(api.ctx, newBlock)
api.assert.NoError(err)
api.assert.Equal(eth.ExecutionInvalid, r.Status)
})
t.Run("RejectBlockWithTimeBeforeParent", func(t *testing.T) {
api := newTestHelper(t, createBackend)
genesis := api.backend.CurrentBlock()
payloadID := api.startBlockBuilding(genesis, eth.Uint64Quantity(genesis.Time-1))
newBlock := api.getPayload(payloadID)
r, err := api.engine.NewPayloadV1(api.ctx, newBlock)
api.assert.NoError(err)
api.assert.Equal(eth.ExecutionInvalid, r.Status)
})
t.Run("UpdateSafeAndFinalizedHead", func(t *testing.T) {
api := newTestHelper(t, createBackend)
finalized := api.addBlock()
safe := api.addBlock()
head := api.addBlock()
api.forkChoiceUpdated(head.BlockHash, safe.BlockHash, finalized.BlockHash)
api.assert.Equal(head.BlockHash, api.headHash(), "should update head block")
api.assert.Equal(safe.BlockHash, api.safeHash(), "should update safe block")
api.assert.Equal(finalized.BlockHash, api.finalHash(), "should update finalized block")
})
t.Run("RejectSafeHeadWhenNotAncestor", func(t *testing.T) {
api := newTestHelper(t, createBackend)
genesis := api.backend.CurrentBlock()
api.addBlock()
chainA2 := api.addBlock()
chainA3 := api.addBlock()
chainB1 := api.addBlockWithParent(genesis, eth.Uint64Quantity(genesis.Time+3))
result, err := api.engine.ForkchoiceUpdatedV1(api.ctx, &eth.ForkchoiceState{
HeadBlockHash: chainA3.BlockHash,
SafeBlockHash: chainB1.BlockHash,
FinalizedBlockHash: chainA2.BlockHash,
}, nil)
api.assert.ErrorContains(err, "Invalid forkchoice state", "should return error from forkChoiceUpdated")
api.assert.Equal(eth.ExecutionInvalid, result.PayloadStatus.Status, "forkChoiceUpdated should return invalid")
api.assert.Nil(result.PayloadID, "should not provide payload ID when invalid")
})
t.Run("RejectFinalizedHeadWhenNotAncestor", func(t *testing.T) {
api := newTestHelper(t, createBackend)
genesis := api.backend.CurrentBlock()
api.addBlock()
chainA2 := api.addBlock()
chainA3 := api.addBlock()
chainB1 := api.addBlockWithParent(genesis, eth.Uint64Quantity(genesis.Time+3))
result, err := api.engine.ForkchoiceUpdatedV1(api.ctx, &eth.ForkchoiceState{
HeadBlockHash: chainA3.BlockHash,
SafeBlockHash: chainA2.BlockHash,
FinalizedBlockHash: chainB1.BlockHash,
}, nil)
api.assert.ErrorContains(err, "Invalid forkchoice state", "should return error from forkChoiceUpdated")
api.assert.Equal(eth.ExecutionInvalid, result.PayloadStatus.Status, "forkChoiceUpdated should return invalid")
api.assert.Nil(result.PayloadID, "should not provide payload ID when invalid")
})
}
// Updates the block hash to the expected value based on the other fields in the payload
func updateBlockHash(newBlock *eth.ExecutionPayload) {
// And fix up the block hash
newHash, _ := newBlock.CheckBlockHash()
newBlock.BlockHash = newHash
}
type testHelper struct {
t *testing.T
ctx context.Context
engine *engineapi.L2EngineAPI
backend engineapi.EngineBackend
assert *require.Assertions
}
func newTestHelper(t *testing.T, createBackend func() engineapi.EngineBackend) *testHelper {
logger := testlog.Logger(t, log.LvlDebug)
ctx := context.Background()
backend := createBackend()
api := engineapi.NewL2EngineAPI(logger, backend)
test := &testHelper{
t: t,
ctx: ctx,
engine: api,
backend: backend,
assert: require.New(t),
}
return test
}
func (h *testHelper) headHash() common.Hash {
return h.backend.CurrentBlock().Hash()
}
func (h *testHelper) safeHash() common.Hash {
return h.backend.CurrentSafeBlock().Hash()
}
func (h *testHelper) finalHash() common.Hash {
return h.backend.CurrentFinalBlock().Hash()
}
func (h *testHelper) Log(args ...any) {
h.t.Log(args...)
}
func (h *testHelper) addBlock(txs ...*types.Transaction) *eth.ExecutionPayload {
head := h.backend.CurrentBlock()
return h.addBlockWithParent(head, eth.Uint64Quantity(head.Time+2), txs...)
}
func (h *testHelper) addBlockWithParent(head *types.Header, timestamp eth.Uint64Quantity, txs ...*types.Transaction) *eth.ExecutionPayload {
prevHead := h.backend.CurrentBlock()
id := h.startBlockBuilding(head, timestamp, txs...)
block := h.getPayload(id)
h.assert.Equal(timestamp, block.Timestamp, "should create block with correct timestamp")
h.assert.Equal(head.Hash(), block.ParentHash, "should have correct parent")
h.assert.Len(block.Transactions, len(txs))
h.newPayload(block)
// Should not have changed the chain head yet
h.assert.Equal(prevHead, h.backend.CurrentBlock())
h.forkChoiceUpdated(block.BlockHash, head.Hash(), head.Hash())
h.assert.Equal(block.BlockHash, h.backend.CurrentBlock().Hash())
return block
}
func (h *testHelper) forkChoiceUpdated(head common.Hash, safe common.Hash, finalized common.Hash) {
h.Log("forkChoiceUpdated", "head", head, "safe", safe, "finalized", finalized)
result, err := h.engine.ForkchoiceUpdatedV1(h.ctx, &eth.ForkchoiceState{
HeadBlockHash: head,
SafeBlockHash: safe,
FinalizedBlockHash: finalized,
}, nil)
h.assert.NoError(err)
h.assert.Equal(eth.ExecutionValid, result.PayloadStatus.Status, "forkChoiceUpdated should return valid")
h.assert.Nil(result.PayloadStatus.ValidationError, "should not have validation error when valid")
h.assert.Nil(result.PayloadID, "should not provide payload ID when block building not requested")
}
func (h *testHelper) startBlockBuilding(head *types.Header, newBlockTimestamp eth.Uint64Quantity, txs ...*types.Transaction) *eth.PayloadID {
h.Log("Start block building", "head", head.Hash(), "timestamp", newBlockTimestamp)
var txData []eth.Data
for _, tx := range txs {
rlp, err := tx.MarshalBinary()
h.assert.NoError(err, "Failed to marshall tx %v", tx)
txData = append(txData, rlp)
}
result, err := h.engine.ForkchoiceUpdatedV1(h.ctx, &eth.ForkchoiceState{
HeadBlockHash: head.Hash(),
SafeBlockHash: head.Hash(),
FinalizedBlockHash: head.Hash(),
}, &eth.PayloadAttributes{
Timestamp: newBlockTimestamp,
PrevRandao: eth.Bytes32(head.MixDigest),
SuggestedFeeRecipient: feeRecipient,
Transactions: txData,
NoTxPool: true,
GasLimit: &gasLimit,
})
h.assert.NoError(err)
h.assert.Equal(eth.ExecutionValid, result.PayloadStatus.Status)
id := result.PayloadID
h.assert.NotNil(id)
return id
}
func (h *testHelper) getPayload(id *eth.PayloadID) *eth.ExecutionPayload {
h.Log("getPayload", "id", id)
block, err := h.engine.GetPayloadV1(h.ctx, *id)
h.assert.NoError(err)
h.assert.NotNil(block)
return block
}
func (h *testHelper) newPayload(block *eth.ExecutionPayload) {
h.Log("newPayload", "hash", block.BlockHash)
r, err := h.engine.NewPayloadV1(h.ctx, block)
h.assert.NoError(err)
h.assert.Equal(eth.ExecutionValid, r.Status)
h.assert.Nil(r.ValidationError)
}
...@@ -5,3 +5,5 @@ import "github.com/ethereum/go-ethereum/core/types" ...@@ -5,3 +5,5 @@ import "github.com/ethereum/go-ethereum/core/types"
type NoopTxMetrics struct{} type NoopTxMetrics struct{}
func (*NoopTxMetrics) RecordL1GasFee(*types.Receipt) {} func (*NoopTxMetrics) RecordL1GasFee(*types.Receipt) {}
func (*NoopTxMetrics) RecordGasBumpCount(int) {}
func (*NoopTxMetrics) RecordTxConfirmationLatency(int64) {}
...@@ -10,10 +10,14 @@ import ( ...@@ -10,10 +10,14 @@ import (
type TxMetricer interface { type TxMetricer interface {
RecordL1GasFee(receipt *types.Receipt) RecordL1GasFee(receipt *types.Receipt)
RecordGasBumpCount(times int)
RecordTxConfirmationLatency(latency int64)
} }
type TxMetrics struct { type TxMetrics struct {
TxL1GasFee prometheus.Gauge TxL1GasFee prometheus.Gauge
TxGasBump prometheus.Gauge
LatencyConfirmedTx prometheus.Gauge
} }
var _ TxMetricer = (*TxMetrics)(nil) var _ TxMetricer = (*TxMetrics)(nil)
...@@ -26,9 +30,29 @@ func MakeTxMetrics(ns string, factory metrics.Factory) TxMetrics { ...@@ -26,9 +30,29 @@ func MakeTxMetrics(ns string, factory metrics.Factory) TxMetrics {
Help: "L1 gas fee for transactions in GWEI", Help: "L1 gas fee for transactions in GWEI",
Subsystem: "txmgr", Subsystem: "txmgr",
}), }),
TxGasBump: factory.NewGauge(prometheus.GaugeOpts{
Namespace: ns,
Name: "tx_gas_bump",
Help: "Number of times a transaction gas needed to be bumped before it got included",
Subsystem: "txmgr",
}),
LatencyConfirmedTx: factory.NewGauge(prometheus.GaugeOpts{
Namespace: ns,
Name: "tx_confirmed_latency_ms",
Help: "Latency of a confirmed transaction in milliseconds",
Subsystem: "txmgr",
}),
} }
} }
func (t *TxMetrics) RecordL1GasFee(receipt *types.Receipt) { func (t *TxMetrics) RecordL1GasFee(receipt *types.Receipt) {
t.TxL1GasFee.Set(float64(receipt.EffectiveGasPrice.Uint64() * receipt.GasUsed / params.GWei)) t.TxL1GasFee.Set(float64(receipt.EffectiveGasPrice.Uint64() * receipt.GasUsed / params.GWei))
} }
func (t *TxMetrics) RecordGasBumpCount(times int) {
t.TxGasBump.Set(float64(times))
}
func (t *TxMetrics) RecordTxConfirmationLatency(latency int64) {
t.LatencyConfirmedTx.Set(float64(latency))
}
...@@ -216,6 +216,7 @@ func (m *SimpleTxManager) send(ctx context.Context, tx *types.Transaction) (*typ ...@@ -216,6 +216,7 @@ func (m *SimpleTxManager) send(ctx context.Context, tx *types.Transaction) (*typ
ticker := time.NewTicker(m.cfg.ResubmissionTimeout) ticker := time.NewTicker(m.cfg.ResubmissionTimeout)
defer ticker.Stop() defer ticker.Stop()
bumpCounter := 0
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
...@@ -231,12 +232,14 @@ func (m *SimpleTxManager) send(ctx context.Context, tx *types.Transaction) (*typ ...@@ -231,12 +232,14 @@ func (m *SimpleTxManager) send(ctx context.Context, tx *types.Transaction) (*typ
// Increase the gas price & submit the new transaction // Increase the gas price & submit the new transaction
tx = m.increaseGasPrice(ctx, tx) tx = m.increaseGasPrice(ctx, tx)
wg.Add(1) wg.Add(1)
bumpCounter += 1
go sendTxAsync(tx) go sendTxAsync(tx)
case <-ctx.Done(): case <-ctx.Done():
return nil, ctx.Err() return nil, ctx.Err()
case receipt := <-receiptChan: case receipt := <-receiptChan:
m.metr.RecordGasBumpCount(bumpCounter)
return receipt, nil return receipt, nil
} }
} }
...@@ -251,6 +254,7 @@ func (m *SimpleTxManager) publishAndWaitForTx(ctx context.Context, tx *types.Tra ...@@ -251,6 +254,7 @@ func (m *SimpleTxManager) publishAndWaitForTx(ctx context.Context, tx *types.Tra
cCtx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout) cCtx, cancel := context.WithTimeout(ctx, m.cfg.NetworkTimeout)
defer cancel() defer cancel()
t := time.Now()
err := m.backend.SendTransaction(cCtx, tx) err := m.backend.SendTransaction(cCtx, tx)
sendState.ProcessSendError(err) sendState.ProcessSendError(err)
...@@ -282,6 +286,7 @@ func (m *SimpleTxManager) publishAndWaitForTx(ctx context.Context, tx *types.Tra ...@@ -282,6 +286,7 @@ func (m *SimpleTxManager) publishAndWaitForTx(ctx context.Context, tx *types.Tra
} }
select { select {
case receiptChan <- receipt: case receiptChan <- receipt:
m.metr.RecordTxConfirmationLatency(time.Since(t).Milliseconds())
m.metr.RecordL1GasFee(receipt) m.metr.RecordL1GasFee(receipt)
default: default:
} }
......
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import { Multicall3 } from "multicall/src/Multicall3.sol";
/**
* Just exists so we can compile this contract.
*/
contract MulticallContractCompiler {
}
//SPDX-License-Identifier: MIT //SPDX-License-Identifier: MIT
pragma solidity 0.8.15; pragma solidity >=0.6.2 <0.9.0;
/* Testing utilities */ /* Testing utilities */
import { Test } from "forge-std/Test.sol"; import { Test } from "forge-std/Test.sol";
import { AttestationStation } from "../universal/op-nft/AttestationStation.sol"; import { AttestationStation } from "../universal/op-nft/AttestationStation.sol";
import { Optimist } from "../universal/op-nft/Optimist.sol"; import { Optimist } from "../universal/op-nft/Optimist.sol";
import { OptimistAllowlist } from "../universal/op-nft/OptimistAllowlist.sol";
import { OptimistInviter } from "../universal/op-nft/OptimistInviter.sol";
import { OptimistInviterHelper } from "../testing/helpers/OptimistInviterHelper.sol";
import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol";
import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
interface IMulticall3 {
struct Call3 {
address target;
bool allowFailure;
bytes callData;
}
struct Result {
bool success;
bytes returnData;
}
function aggregate3(Call3[] calldata calls)
external
payable
returns (Result[] memory returnData);
}
contract Optimist_Initializer is Test { contract Optimist_Initializer is Test {
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Initialized(uint8); event Initialized(uint8);
event AttestationCreated(
address indexed creator,
address indexed about,
bytes32 indexed key,
bytes val
);
address constant alice_admin = address(128);
address constant bob = address(256);
address constant sally = address(512);
string constant name = "Optimist name"; string constant name = "Optimist name";
string constant symbol = "OPTIMISTSYMBOL"; string constant symbol = "OPTIMISTSYMBOL";
string constant base_uri = string constant base_uri =
"https://storageapi.fleek.co/6442819a1b05-bucket/optimist-nft/attributes"; "https://storageapi.fleek.co/6442819a1b05-bucket/optimist-nft/attributes";
AttestationStation attestationStation; AttestationStation attestationStation;
Optimist optimist; Optimist optimist;
OptimistAllowlist optimistAllowlist;
OptimistInviter optimistInviter;
// Helps with EIP-712 signature generation
OptimistInviterHelper optimistInviterHelper;
// To test multicall for claiming and minting in one call
IMulticall3 multicall3;
function attestBaseuri(string memory _baseUri) internal { address internal carol_baseURIAttestor;
address internal alice_allowlistAttestor;
address internal eve_inviteGranter;
address internal ted_coinbaseAttestor;
address internal bob;
address internal sally;
/**
* @notice BaseURI attestor sets the baseURI of the Optimist NFT.
*/
function _attestBaseURI(string memory _baseUri) internal {
bytes32 baseURIAttestationKey = optimist.BASE_URI_ATTESTATION_KEY();
AttestationStation.AttestationData[] AttestationStation.AttestationData[]
memory attestationData = new AttestationStation.AttestationData[](1); memory attestationData = new AttestationStation.AttestationData[](1);
attestationData[0] = AttestationStation.AttestationData( attestationData[0] = AttestationStation.AttestationData(
address(optimist), address(optimist),
bytes32("optimist.base-uri"), baseURIAttestationKey,
bytes(_baseUri) bytes(_baseUri)
); );
vm.prank(alice_admin);
vm.expectEmit(true, true, true, true, address(attestationStation));
emit AttestationCreated(
carol_baseURIAttestor,
address(optimist),
baseURIAttestationKey,
bytes(_baseUri)
);
vm.prank(carol_baseURIAttestor);
attestationStation.attest(attestationData);
}
/**
* @notice Allowlist attestor creates an attestation for an address.
*/
function _attestAllowlist(address _about) internal {
bytes32 attestationKey = optimistAllowlist.OPTIMIST_CAN_MINT_ATTESTATION_KEY();
AttestationStation.AttestationData[]
memory attestationData = new AttestationStation.AttestationData[](1);
// we are using true but it can be any non empty value
attestationData[0] = AttestationStation.AttestationData({
about: _about,
key: attestationKey,
val: bytes("true")
});
vm.expectEmit(true, true, true, true, address(attestationStation));
emit AttestationCreated(alice_allowlistAttestor, _about, attestationKey, bytes("true"));
vm.prank(alice_allowlistAttestor);
attestationStation.attest(attestationData); attestationStation.attest(attestationData);
assertTrue(optimist.isOnAllowList(_about));
} }
function attestAllowlist(address _about) internal { /**
* @notice Coinbase Quest attestor creates an attestation for an address.
*/
function _attestCoinbaseQuest(address _about) internal {
bytes32 attestationKey = optimistAllowlist.COINBASE_QUEST_ELIGIBLE_ATTESTATION_KEY();
AttestationStation.AttestationData[] AttestationStation.AttestationData[]
memory attestationData = new AttestationStation.AttestationData[](1); memory attestationData = new AttestationStation.AttestationData[](1);
// we are using true but it can be any non empty value // we are using true but it can be any non empty value
attestationData[0] = AttestationStation.AttestationData({ attestationData[0] = AttestationStation.AttestationData({
about: _about, about: _about,
key: bytes32("optimist.can-mint"), key: attestationKey,
val: bytes("true") val: bytes("true")
}); });
vm.prank(alice_admin);
vm.expectEmit(true, true, true, true, address(attestationStation));
emit AttestationCreated(ted_coinbaseAttestor, _about, attestationKey, bytes("true"));
vm.prank(ted_coinbaseAttestor);
attestationStation.attest(attestationData); attestationStation.attest(attestationData);
assertTrue(optimist.isOnAllowList(_about));
}
/**
* @notice Issues invite, then claims it using the claimer's address.
*/
function _inviteAndClaim(address _about) internal {
uint256 inviterPrivateKey = 0xbeefbeef;
address inviter = vm.addr(inviterPrivateKey);
address[] memory addresses = new address[](1);
addresses[0] = inviter;
vm.prank(eve_inviteGranter);
// grant invites to Inviter;
optimistInviter.setInviteCounts(addresses, 3);
// issue a new invite
OptimistInviter.ClaimableInvite memory claimableInvite = optimistInviterHelper
.getClaimableInviteWithNewNonce(inviter);
// EIP-712 sign with Inviter's private key
(uint8 v, bytes32 r, bytes32 s) = vm.sign(
inviterPrivateKey,
optimistInviterHelper.getDigest(claimableInvite)
);
bytes memory signature = abi.encodePacked(r, s, v);
bytes32 hashedCommit = keccak256(abi.encode(_about, signature));
// commit the invite
vm.prank(_about);
optimistInviter.commitInvite(hashedCommit);
// wait minimum commitment period
vm.warp(optimistInviter.MIN_COMMITMENT_PERIOD() + block.timestamp);
// reveal and claim the invite
optimistInviter.claimInvite(_about, claimableInvite, signature);
assertTrue(optimist.isOnAllowList(_about));
}
/**
* @notice Mocks the allowlistAttestor to always return true for a given address.
*/
function _mockAllowlistTrueFor(address _claimer) internal {
vm.mockCall(
address(optimistAllowlist),
abi.encodeWithSelector(OptimistAllowlist.isAllowedToMint.selector, _claimer),
abi.encode(true)
);
assertTrue(optimist.isOnAllowList(_claimer));
}
/**
* @notice Returns address as uint256.
*/
function _getTokenId(address _owner) internal pure returns (uint256) {
return uint256(uint160(address(_owner)));
} }
function setUp() public { function setUp() public {
// Give alice and bob and sally some ETH carol_baseURIAttestor = makeAddr("carol_baseURIAttestor");
vm.deal(alice_admin, 1 ether); alice_allowlistAttestor = makeAddr("alice_allowlistAttestor");
vm.deal(bob, 1 ether); eve_inviteGranter = makeAddr("eve_inviteGranter");
vm.deal(sally, 1 ether); ted_coinbaseAttestor = makeAddr("ted_coinbaseAttestor");
bob = makeAddr("bob");
vm.label(alice_admin, "alice_admin"); sally = makeAddr("sally");
vm.label(bob, "bob");
vm.label(sally, "sally");
_initializeContracts(); _initializeContracts();
} }
...@@ -63,99 +207,207 @@ contract Optimist_Initializer is Test { ...@@ -63,99 +207,207 @@ contract Optimist_Initializer is Test {
attestationStation = new AttestationStation(); attestationStation = new AttestationStation();
vm.expectEmit(true, true, false, false); vm.expectEmit(true, true, false, false);
emit Initialized(1); emit Initialized(1);
optimist = new Optimist(name, symbol, alice_admin, attestationStation);
optimistInviter = new OptimistInviter({
_inviteGranter: eve_inviteGranter,
_attestationStation: attestationStation
});
optimistInviter.initialize("OptimistInviter");
// Initialize the helper which helps sign EIP-712 signatures
optimistInviterHelper = new OptimistInviterHelper(optimistInviter, "OptimistInviter");
optimistAllowlist = new OptimistAllowlist({
_attestationStation: attestationStation,
_allowlistAttestor: alice_allowlistAttestor,
_coinbaseQuestAttestor: ted_coinbaseAttestor,
_optimistInviter: address(optimistInviter)
});
optimist = new Optimist({
_name: name,
_symbol: symbol,
_baseURIAttestor: carol_baseURIAttestor,
_attestationStation: attestationStation,
_optimistAllowlist: optimistAllowlist
});
// address test = deployCode("Multicall3.sol");
multicall3 = IMulticall3(deployCode("Multicall3.sol"));
} }
} }
contract OptimistTest is Optimist_Initializer { contract OptimistTest is Optimist_Initializer {
function test_optimist_initialize() external { /**
* @notice Check that constructor and initializer parameters are correctly set.
*/
function test_initialize_success() external {
// expect name to be set // expect name to be set
assertEq(optimist.name(), name); assertEq(optimist.name(), name);
// expect symbol to be set // expect symbol to be set
assertEq(optimist.symbol(), symbol); assertEq(optimist.symbol(), symbol);
// expect attestationStation to be set // expect attestationStation to be set
assertEq(address(optimist.ATTESTATION_STATION()), address(attestationStation)); assertEq(address(optimist.ATTESTATION_STATION()), address(attestationStation));
assertEq(optimist.ATTESTOR(), alice_admin); assertEq(optimist.BASE_URI_ATTESTOR(), carol_baseURIAttestor);
assertEq(optimist.version(), "1.0.0"); assertEq(optimist.version(), "2.0.0");
} }
/** /**
* @dev Bob should be able to mint an NFT if he is allowlisted * @notice Bob should be able to mint an NFT if he is allowlisted
* by the attestation station and has a balance of 0 * by the allowlistAttestor and has a balance of 0.
*/ */
function test_optimist_mint_happy_path() external { function test_mint_afterAllowlistAttestation_succeeds() external {
// bob should start with 0 balance // bob should start with 0 balance
assertEq(optimist.balanceOf(bob), 0); assertEq(optimist.balanceOf(bob), 0);
// whitelist bob // allowlist bob
attestAllowlist(bob); _attestAllowlist(bob);
assertTrue(optimistAllowlist.isAllowedToMint(bob));
// Check that the OptimistAllowlist is checked
bytes memory data = abi.encodeWithSelector(optimistAllowlist.isAllowedToMint.selector, bob);
vm.expectCall(address(optimistAllowlist), data);
uint256 tokenId = uint256(uint160(bob)); // mint an NFT and expect mint transfer event to be emitted
vm.expectEmit(true, true, true, true); vm.expectEmit(true, true, true, true);
emit Transfer(address(0), bob, tokenId); emit Transfer(address(0), bob, _getTokenId(bob));
vm.prank(bob);
optimist.mint(bob);
bytes memory data = abi.encodeWithSelector( // expect the NFT to be owned by bob
attestationStation.attestations.selector, assertEq(optimist.ownerOf(_getTokenId(bob)), bob);
alice_admin, assertEq(optimist.balanceOf(bob), 1);
bob, }
bytes32("optimist.can-mint")
); /**
vm.expectCall(address(attestationStation), data); * @notice Bob should be able to mint an NFT if he claimed an invite through OptimistInviter
// mint an NFT * and has a balance of 0.
*/
function test_mint_afterInviteClaimed_succeeds() external {
// bob should start with 0 balance
assertEq(optimist.balanceOf(bob), 0);
// bob claims an invite
_inviteAndClaim(bob);
assertTrue(optimistAllowlist.isAllowedToMint(bob));
// Check that the OptimistAllowlist is checked
bytes memory data = abi.encodeWithSelector(optimistAllowlist.isAllowedToMint.selector, bob);
vm.expectCall(address(optimistAllowlist), data);
// mint an NFT and expect mint transfer event to be emitted
vm.expectEmit(true, true, true, true);
emit Transfer(address(0), bob, _getTokenId(bob));
vm.prank(bob); vm.prank(bob);
optimist.mint(bob); optimist.mint(bob);
// expect the NFT to be owned by bob // expect the NFT to be owned by bob
assertEq(optimist.ownerOf(256), bob); assertEq(optimist.ownerOf(_getTokenId(bob)), bob);
assertEq(optimist.balanceOf(bob), 1); assertEq(optimist.balanceOf(bob), 1);
} }
/** /**
* @dev Sally should be able to mint a token on behalf of bob * @notice Bob should be able to mint an NFT if he has an attestation from Coinbase Quest
* attestor and has a balance of 0.
*/ */
function test_optimist_mint_secondary_minter() external { function test_mint_afterCoinbaseQuestAttestation_succeeds() external {
attestAllowlist(bob); // bob should start with 0 balance
assertEq(optimist.balanceOf(bob), 0);
bytes memory data = abi.encodeWithSelector( // bob receives attestation from Coinbase Quest attestor
attestationStation.attestations.selector, _attestCoinbaseQuest(bob);
alice_admin,
bob, assertTrue(optimistAllowlist.isAllowedToMint(bob));
bytes32("optimist.can-mint")
); // Check that the OptimistAllowlist is checked
vm.expectCall(address(attestationStation), data); bytes memory data = abi.encodeWithSelector(optimistAllowlist.isAllowedToMint.selector, bob);
vm.expectCall(address(optimistAllowlist), data);
// mint an NFT and expect mint transfer event to be emitted
vm.expectEmit(true, true, true, true);
emit Transfer(address(0), bob, _getTokenId(bob));
vm.prank(bob);
optimist.mint(bob);
// expect the NFT to be owned by bob
assertEq(optimist.ownerOf(_getTokenId(bob)), bob);
assertEq(optimist.balanceOf(bob), 1);
}
/**
* @notice Multiple valid attestations should allow Bob to mint.
*/
function test_mint_afterMultipleAttestations_succeeds() external {
// bob should start with 0 balance
assertEq(optimist.balanceOf(bob), 0);
// bob receives attestation from Coinbase Quest attestor
_attestCoinbaseQuest(bob);
// allowlist bob
_attestAllowlist(bob);
// bob claims an invite
_inviteAndClaim(bob);
assertTrue(optimistAllowlist.isAllowedToMint(bob));
// Check that the OptimistAllowlist is checked
bytes memory data = abi.encodeWithSelector(optimistAllowlist.isAllowedToMint.selector, bob);
vm.expectCall(address(optimistAllowlist), data);
// mint an NFT and expect mint transfer event to be emitted
vm.expectEmit(true, true, true, true);
emit Transfer(address(0), bob, _getTokenId(bob));
vm.prank(bob);
optimist.mint(bob);
// expect the NFT to be owned by bob
assertEq(optimist.ownerOf(_getTokenId(bob)), bob);
assertEq(optimist.balanceOf(bob), 1);
}
/**
* @notice Sally should be able to mint a token on behalf of bob.
*/
function test_mint_secondaryMinter_succeeds() external {
_mockAllowlistTrueFor(bob);
uint256 tokenId = uint256(uint160(bob));
vm.expectEmit(true, true, true, true); vm.expectEmit(true, true, true, true);
emit Transfer(address(0), bob, tokenId); emit Transfer(address(0), bob, _getTokenId(bob));
// mint as sally instead of bob // mint as sally instead of bob
vm.prank(sally); vm.prank(sally);
optimist.mint(bob); optimist.mint(bob);
// expect the NFT to be owned by bob // expect the NFT to be owned by bob
assertEq(optimist.ownerOf(256), bob); assertEq(optimist.ownerOf(_getTokenId(bob)), bob);
assertEq(optimist.balanceOf(bob), 1); assertEq(optimist.balanceOf(bob), 1);
} }
/** /**
* @dev Bob should not be able to mint an NFT if he is not whitelisted * @notice Bob should not be able to mint an NFT if he is not allowlisted.
*/ */
function test_optimist_mint_no_attestation() external { function test_mint_forNonAllowlistedClaimer_reverts() external {
vm.prank(bob); vm.prank(bob);
vm.expectRevert("Optimist: address is not on allowList"); vm.expectRevert("Optimist: address is not on allowList");
optimist.mint(bob); optimist.mint(bob);
} }
/** /**
* @dev Bob's tx should revert if he already minted * @notice Bob's tx should revert if he already minted.
*/ */
function test_optimist_mint_already_minted() external { function test_mint_forAlreadyMintedClaimer_reverts() external {
attestAllowlist(bob); _attestAllowlist(bob);
// mint initial nft with bob // mint initial nft with bob
vm.prank(bob); vm.prank(bob);
optimist.mint(bob); optimist.mint(bob);
// expect the NFT to be owned by bob // expect the NFT to be owned by bob
assertEq(optimist.ownerOf(256), bob); assertEq(optimist.ownerOf(_getTokenId(bob)), bob);
assertEq(optimist.balanceOf(bob), 1); assertEq(optimist.balanceOf(bob), 1);
// attempt to mint again // attempt to mint again
...@@ -164,82 +416,52 @@ contract OptimistTest is Optimist_Initializer { ...@@ -164,82 +416,52 @@ contract OptimistTest is Optimist_Initializer {
} }
/** /**
* @dev The baseURI should be set by attestation station * @notice The baseURI should be set by attestation station by the baseURIAttestor.
* by the owner of contract alice_admin
*/ */
function test_optimist_baseURI() external { function test_baseURI_returnsCorrectBaseURI_succeeds() external {
attestBaseuri(base_uri); _attestBaseURI(base_uri);
bytes memory data = abi.encodeWithSelector( bytes memory data = abi.encodeWithSelector(
attestationStation.attestations.selector, attestationStation.attestations.selector,
alice_admin, carol_baseURIAttestor,
address(optimist), address(optimist),
bytes32("optimist.base-uri") optimist.BASE_URI_ATTESTATION_KEY()
); );
vm.expectCall(address(attestationStation), data); vm.expectCall(address(attestationStation), data);
vm.prank(alice_admin); vm.prank(carol_baseURIAttestor);
// assert baseURI is set // assert baseURI is set
assertEq(optimist.baseURI(), base_uri); assertEq(optimist.baseURI(), base_uri);
} }
/** /**
* @dev The tokenURI should return the token uri * @notice tokenURI should return the token uri for a minted token.
* for a minted token
*/ */
function test_optimist_token_uri() external { function test_tokenURI_returnsCorrectTokenURI_succeeds() external {
attestAllowlist(bob);
// we are using true but it can be any non empty value // we are using true but it can be any non empty value
attestBaseuri(base_uri); _attestBaseURI(base_uri);
// mint an NFT // mint an NFT
_mockAllowlistTrueFor(bob);
vm.prank(bob); vm.prank(bob);
optimist.mint(bob); optimist.mint(bob);
// assert tokenURI is set // assert tokenURI is set
assertEq(optimist.baseURI(), base_uri); assertEq(optimist.baseURI(), base_uri);
assertEq( assertEq(
optimist.tokenURI(256), optimist.tokenURI(_getTokenId(bob)),
// solhint-disable-next-line max-line-length "https://storageapi.fleek.co/6442819a1b05-bucket/optimist-nft/attributes/0x1d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e.json"
"https://storageapi.fleek.co/6442819a1b05-bucket/optimist-nft/attributes/0x0000000000000000000000000000000000000100.json"
);
}
/**
* @dev Should return a boolean of if the address is whitelisted
*/
function test_optimist_is_on_allow_list() external {
attestAllowlist(bob);
bytes memory data = abi.encodeWithSelector(
attestationStation.attestations.selector,
alice_admin,
bob,
bytes32("optimist.can-mint")
); );
vm.expectCall(address(attestationStation), data);
// assert bob is whitelisted
assertEq(optimist.isOnAllowList(bob), true);
data = abi.encodeWithSelector(
attestationStation.attestations.selector,
alice_admin,
sally,
bytes32("optimist.can-mint")
);
vm.expectCall(address(attestationStation), data);
// assert sally is not whitelisted
assertEq(optimist.isOnAllowList(sally), false);
} }
/** /**
* @dev Should return the token id of the owner * @notice Should return the token id of the owner.
*/ */
function test_optimist_token_id_of_owner() external { function test_tokenIdOfAddress_returnsOwnerID_succeeds() external {
// whitelist bob
uint256 willTokenId = 1024; uint256 willTokenId = 1024;
address will = address(1024); address will = address(1024);
attestAllowlist(will); _mockAllowlistTrueFor(will);
optimist.mint(will); optimist.mint(will);
...@@ -247,10 +469,10 @@ contract OptimistTest is Optimist_Initializer { ...@@ -247,10 +469,10 @@ contract OptimistTest is Optimist_Initializer {
} }
/** /**
* @dev It should revert if anybody attemps token transfer * @notice transferFrom should revert since Optimist is a SBT.
*/ */
function test_optimist_sbt_transfer() external { function test_transferFrom_reverts() external {
attestAllowlist(bob); _mockAllowlistTrueFor(bob);
// mint as bob // mint as bob
vm.prank(bob); vm.prank(bob);
...@@ -259,23 +481,23 @@ contract OptimistTest is Optimist_Initializer { ...@@ -259,23 +481,23 @@ contract OptimistTest is Optimist_Initializer {
// attempt to transfer to sally // attempt to transfer to sally
vm.expectRevert(bytes("Optimist: soul bound token")); vm.expectRevert(bytes("Optimist: soul bound token"));
vm.prank(bob); vm.prank(bob);
optimist.transferFrom(bob, sally, 256); optimist.transferFrom(bob, sally, _getTokenId(bob));
// attempt to transfer to sally // attempt to transfer to sally
vm.expectRevert(bytes("Optimist: soul bound token")); vm.expectRevert(bytes("Optimist: soul bound token"));
vm.prank(bob); vm.prank(bob);
optimist.safeTransferFrom(bob, sally, 256); optimist.safeTransferFrom(bob, sally, _getTokenId(bob));
// attempt to transfer to sally // attempt to transfer to sally
vm.expectRevert(bytes("Optimist: soul bound token")); vm.expectRevert(bytes("Optimist: soul bound token"));
vm.prank(bob); vm.prank(bob);
optimist.safeTransferFrom(bob, sally, 256, bytes("0x")); optimist.safeTransferFrom(bob, sally, _getTokenId(bob), bytes("0x"));
} }
/** /**
* @dev It should revert if anybody attemps approve * @notice approve should revert since Optimist is a SBT.
*/ */
function test_optimist_sbt_approve() external { function test_approve_reverts() external {
attestAllowlist(bob); _mockAllowlistTrueFor(bob);
// mint as bob // mint as bob
vm.prank(bob); vm.prank(bob);
...@@ -284,16 +506,38 @@ contract OptimistTest is Optimist_Initializer { ...@@ -284,16 +506,38 @@ contract OptimistTest is Optimist_Initializer {
// attempt to approve sally // attempt to approve sally
vm.prank(bob); vm.prank(bob);
vm.expectRevert("Optimist: soul bound token"); vm.expectRevert("Optimist: soul bound token");
optimist.approve(address(attestationStation), 256); optimist.approve(address(attestationStation), _getTokenId(bob));
assertEq(optimist.getApproved(_getTokenId(bob)), address(0));
}
/**
* @notice setApprovalForAll should revert since Optimist is a SBT.
*/
function test_setApprovalForAll_reverts() external {
_mockAllowlistTrueFor(bob);
// mint as bob
vm.prank(bob);
optimist.mint(bob);
vm.prank(alice_allowlistAttestor);
vm.expectRevert(bytes("Optimist: soul bound token"));
optimist.setApprovalForAll(alice_allowlistAttestor, true);
assertEq(optimist.getApproved(256), address(0)); // expect approval amount to stil be 0
assertEq(optimist.getApproved(_getTokenId(bob)), address(0));
// isApprovedForAll should return false
assertEq(
optimist.isApprovedForAll(alice_allowlistAttestor, alice_allowlistAttestor),
false
);
} }
/** /**
* @dev It should be able to burn token * @notice Only owner should be able to burn token.
*/ */
function test_optimist_burn() external { function test_burn_byOwner_succeeds() external {
attestAllowlist(bob); _mockAllowlistTrueFor(bob);
// mint as bob // mint as bob
vm.prank(bob); vm.prank(bob);
...@@ -301,37 +545,103 @@ contract OptimistTest is Optimist_Initializer { ...@@ -301,37 +545,103 @@ contract OptimistTest is Optimist_Initializer {
// burn as bob // burn as bob
vm.prank(bob); vm.prank(bob);
optimist.burn(256); optimist.burn(_getTokenId(bob));
// expect bob to have no balance now // expect bob to have no balance now
assertEq(optimist.balanceOf(bob), 0); assertEq(optimist.balanceOf(bob), 0);
} }
/** /**
* @dev setApprovalForAll should revert as sbt * @notice Non-owner attempting to burn token should revert.
*/ */
function test_optimist_set_approval_for_all() external { function test_burn_byNonOwner_reverts() external {
attestAllowlist(bob); _mockAllowlistTrueFor(bob);
// mint as bob // mint as bob
vm.prank(bob); vm.prank(bob);
optimist.mint(bob); optimist.mint(bob);
vm.prank(alice_admin);
vm.expectRevert(bytes("Optimist: soul bound token"));
optimist.setApprovalForAll(alice_admin, true);
// expect approval amount to stil be 0 vm.expectRevert("ERC721: caller is not token owner nor approved");
assertEq(optimist.getApproved(256), address(0)); // burn as Sally
// isApprovedForAll should return false vm.prank(sally);
assertEq(optimist.isApprovedForAll(alice_admin, alice_admin), false); optimist.burn(_getTokenId(bob));
// expect bob to have still have the token
assertEq(optimist.balanceOf(bob), 1);
} }
/** /**
* @dev should support erc721 interface * @notice Should support ERC-721 interface.
*/ */
function test_optimist_supports_interface() external { function test_supportsInterface_returnsCorrectInterfaceForERC721_succeeds() external {
bytes4 iface721 = type(IERC721).interfaceId; bytes4 iface721 = type(IERC721).interfaceId;
// check that it supports erc721 interface // check that it supports ERC-721 interface
assertEq(optimist.supportsInterface(iface721), true); assertEq(optimist.supportsInterface(iface721), true);
} }
/**
* @notice Checking that multi-call using the invite & claim flow works correctly, since the
* frontend will be making multicalls to improve UX. The OptimistInviter.claimInvite
* and Optimist.mint will be batched
*/
function test_multicall_batchingClaimAndMint_succeeds() external {
uint256 inviterPrivateKey = 0xbeefbeef;
address inviter = vm.addr(inviterPrivateKey);
address[] memory addresses = new address[](1);
addresses[0] = inviter;
vm.prank(eve_inviteGranter);
// grant invites to Inviter;
optimistInviter.setInviteCounts(addresses, 3);
// issue a new invite
OptimistInviter.ClaimableInvite memory claimableInvite = optimistInviterHelper
.getClaimableInviteWithNewNonce(inviter);
// EIP-712 sign with Inviter's private key
(uint8 v, bytes32 r, bytes32 s) = vm.sign(
inviterPrivateKey,
optimistInviterHelper.getDigest(claimableInvite)
);
bytes memory signature = abi.encodePacked(r, s, v);
bytes32 hashedCommit = keccak256(abi.encode(bob, signature));
// commit the invite
vm.prank(bob);
optimistInviter.commitInvite(hashedCommit);
// wait minimum commitment period
vm.warp(optimistInviter.MIN_COMMITMENT_PERIOD() + block.timestamp);
IMulticall3.Call3[] memory calls = new IMulticall3.Call3[](2);
// First call is to claim the invite, receiving the attestation
calls[0] = IMulticall3.Call3({
target: address(optimistInviter),
callData: abi.encodeWithSelector(
optimistInviter.claimInvite.selector,
bob,
claimableInvite,
signature
),
allowFailure: false
});
// Second call is to mint the Optimist NFT
calls[1] = IMulticall3.Call3({
target: address(optimist),
callData: abi.encodeWithSelector(optimist.mint.selector, bob),
allowFailure: false
});
multicall3.aggregate3(calls);
assertTrue(optimist.isOnAllowList(bob));
assertEq(optimist.ownerOf(_getTokenId(bob)), bob);
assertEq(optimist.balanceOf(bob), 1);
}
} }
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
ERC721BurnableUpgradeable ERC721BurnableUpgradeable
} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721BurnableUpgradeable.sol"; } from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721BurnableUpgradeable.sol";
import { AttestationStation } from "./AttestationStation.sol"; import { AttestationStation } from "./AttestationStation.sol";
import { OptimistAllowlist } from "./OptimistAllowlist.sol";
import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol";
/** /**
...@@ -15,31 +16,44 @@ import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; ...@@ -15,31 +16,44 @@ import { Strings } from "@openzeppelin/contracts/utils/Strings.sol";
* @notice A Soul Bound Token for real humans only(tm). * @notice A Soul Bound Token for real humans only(tm).
*/ */
contract Optimist is ERC721BurnableUpgradeable, Semver { contract Optimist is ERC721BurnableUpgradeable, Semver {
/**
* @notice Attestation key used by the attestor to attest the baseURI.
*/
bytes32 public constant BASE_URI_ATTESTATION_KEY = bytes32("optimist.base-uri");
/**
* @notice Attestor who attests to baseURI.
*/
address public immutable BASE_URI_ATTESTOR;
/** /**
* @notice Address of the AttestationStation contract. * @notice Address of the AttestationStation contract.
*/ */
AttestationStation public immutable ATTESTATION_STATION; AttestationStation public immutable ATTESTATION_STATION;
/** /**
* @notice Attestor who attests to baseURI and allowlist. * @notice Address of the OptimistAllowlist contract.
*/ */
address public immutable ATTESTOR; OptimistAllowlist public immutable OPTIMIST_ALLOWLIST;
/** /**
* @custom:semver 1.0.0 * @custom:semver 2.0.0
* @param _name Token name. * @param _name Token name.
* @param _symbol Token symbol. * @param _symbol Token symbol.
* @param _attestor Address of the attestor. * @param _baseURIAttestor Address of the baseURI attestor.
* @param _attestationStation Address of the AttestationStation contract. * @param _attestationStation Address of the AttestationStation contract.
* @param _optimistAllowlist Address of the OptimistAllowlist contract
*/ */
constructor( constructor(
string memory _name, string memory _name,
string memory _symbol, string memory _symbol,
address _attestor, address _baseURIAttestor,
AttestationStation _attestationStation AttestationStation _attestationStation,
) Semver(1, 0, 0) { OptimistAllowlist _optimistAllowlist
ATTESTOR = _attestor; ) Semver(2, 0, 0) {
BASE_URI_ATTESTOR = _baseURIAttestor;
ATTESTATION_STATION = _attestationStation; ATTESTATION_STATION = _attestationStation;
OPTIMIST_ALLOWLIST = _optimistAllowlist;
initialize(_name, _symbol); initialize(_name, _symbol);
} }
...@@ -76,7 +90,7 @@ contract Optimist is ERC721BurnableUpgradeable, Semver { ...@@ -76,7 +90,7 @@ contract Optimist is ERC721BurnableUpgradeable, Semver {
string( string(
abi.encodePacked( abi.encodePacked(
ATTESTATION_STATION.attestations( ATTESTATION_STATION.attestations(
ATTESTOR, BASE_URI_ATTESTOR,
address(this), address(this),
bytes32("optimist.base-uri") bytes32("optimist.base-uri")
) )
...@@ -105,17 +119,15 @@ contract Optimist is ERC721BurnableUpgradeable, Semver { ...@@ -105,17 +119,15 @@ contract Optimist is ERC721BurnableUpgradeable, Semver {
} }
/** /**
* @notice Checks whether a given address is allowed to mint the Optimist NFT yet. Since the * @notice Checks OptimistAllowlist to determine whether a given address is allowed to mint
* Optimist NFT will also be used as part of the Citizens House, mints are currently * the Optimist NFT. Since the Optimist NFT will also be used as part of the
* restricted. Eventually anyone will be able to mint. * Citizens House, mints are currently restricted. Eventually anyone will be able
* to mint.
* *
* @return Whether or not the address is allowed to mint yet. * @return Whether or not the address is allowed to mint yet.
*/ */
function isOnAllowList(address _recipient) public view returns (bool) { function isOnAllowList(address _recipient) public view returns (bool) {
return return OPTIMIST_ALLOWLIST.isAllowedToMint(_recipient);
ATTESTATION_STATION
.attestations(ATTESTOR, _recipient, bytes32("optimist.can-mint"))
.length > 0;
} }
/** /**
......
...@@ -16,9 +16,13 @@ remappings = [ ...@@ -16,9 +16,13 @@ remappings = [
'@rari-capital/solmate/=node_modules/@rari-capital/solmate', '@rari-capital/solmate/=node_modules/@rari-capital/solmate',
'forge-std/=node_modules/forge-std/src', 'forge-std/=node_modules/forge-std/src',
'ds-test/=node_modules/ds-test/src', 'ds-test/=node_modules/ds-test/src',
'multicall/=lib/multicall',
'@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/', '@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/',
'@openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/', '@openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/',
'@eth-optimism/contracts-bedrock/=../../node_modules/@eth-optimism/contracts-bedrock', '@eth-optimism/contracts-bedrock/=../../node_modules/@eth-optimism/contracts-bedrock',
] ]
# The metadata hash can be removed from the bytecode by setting "none" # The metadata hash can be removed from the bytecode by setting "none"
bytecode_hash = "none" bytecode_hash = "none"
libs = ["node_modules", "lib"]
# Required to use `deployCode` to deploy the multicall contract which has incompatible version
fs_permissions = [{ access = "read", path = "./forge-artifacts/Multicall3.sol/Multicall3.json"}]
Subproject commit a1fa0644fa412cd3237ef7081458ecb2ffad7dbe
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