diff --git a/devnet-sdk/book/src/shell.md b/devnet-sdk/book/src/shell.md index e5a4e91e31491..f0ad9cad1dc88 100644 --- a/devnet-sdk/book/src/shell.md +++ b/devnet-sdk/book/src/shell.md @@ -53,7 +53,7 @@ export ETH_JWT_SECRET=... ```bash # Enter devnet shell -go run devnet-sdk/shll/cmd/enter/main.go --descriptor devnet.json --chain ... +go run devnet-sdk/shell/cmd/enter/main.go --descriptor devnet.json --chain ... # Now you can use tools directly cast block latest @@ -72,6 +72,7 @@ exit ## Implementation Details The shell integration: + 1. Reads the descriptor file 2. Sets up environment variables based on the descriptor content 3. Creates a new shell session with the configured environment diff --git a/devnet-sdk/descriptors/deployment.go b/devnet-sdk/descriptors/deployment.go index 70e38d1bc501b..f484c3af6201d 100644 --- a/devnet-sdk/descriptors/deployment.go +++ b/devnet-sdk/descriptors/deployment.go @@ -1,6 +1,9 @@ package descriptors -import "github.com/ethereum-optimism/optimism/devnet-sdk/types" +import ( + "github.com/ethereum-optimism/optimism/devnet-sdk/types" + "github.com/ethereum/go-ethereum/params" +) type PortInfo struct { Host string `json:"host"` @@ -30,13 +33,14 @@ type AddressMap map[string]types.Address // Chain represents a chain (L1 or L2) in a devnet. type Chain struct { - Name string `json:"name"` - ID string `json:"id,omitempty"` - Services ServiceMap `json:"services,omitempty"` - Nodes []Node `json:"nodes"` - Addresses AddressMap `json:"addresses,omitempty"` - Wallets WalletMap `json:"wallets,omitempty"` - JWT string `json:"jwt,omitempty"` + Name string `json:"name"` + ID string `json:"id,omitempty"` + Services ServiceMap `json:"services,omitempty"` + Nodes []Node `json:"nodes"` + Addresses AddressMap `json:"addresses,omitempty"` + Wallets WalletMap `json:"wallets,omitempty"` + JWT string `json:"jwt,omitempty"` + ChainConfig *params.ChainConfig `json:"chain_config,omitempty"` } // Wallet represents a wallet with an address and optional private key. diff --git a/devnet-sdk/kt/fs/fs.go b/devnet-sdk/kt/fs/fs.go index cd4841c1f71bf..a309c3c96ddb2 100644 --- a/devnet-sdk/kt/fs/fs.go +++ b/devnet-sdk/kt/fs/fs.go @@ -43,22 +43,30 @@ func NewEnclaveFSWithContext(ctx EnclaveContextIface) *EnclaveFS { } type Artifact struct { - reader *tar.Reader + reader *tar.Reader + archiveData []byte + gzipReader *gzip.Reader } func (fs *EnclaveFS) GetArtifact(ctx context.Context, name string) (*Artifact, error) { - artifact, err := fs.enclaveCtx.DownloadFilesArtifact(ctx, name) + archiveData, err := fs.enclaveCtx.DownloadFilesArtifact(ctx, name) if err != nil { return nil, err } - buffer := bytes.NewBuffer(artifact) - zipReader, err := gzip.NewReader(buffer) + // Create a new reader for the archive data + buffer := bytes.NewBuffer(archiveData) + gzipReader, err := gzip.NewReader(buffer) if err != nil { return nil, err } - tarReader := tar.NewReader(zipReader) - return &Artifact{reader: tarReader}, nil + tarReader := tar.NewReader(gzipReader) + + return &Artifact{ + reader: tarReader, + archiveData: archiveData, + gzipReader: gzipReader, + }, nil } type ArtifactFileWriter struct { @@ -73,7 +81,33 @@ func NewArtifactFileWriter(path string, writer io.Writer) *ArtifactFileWriter { } } +// resetReader recreates the tar reader from the stored archive data +func (a *Artifact) resetReader() error { + // Close the existing gzip reader if it exists + if a.gzipReader != nil { + a.gzipReader.Close() + } + + // Create a new reader from the stored archive data + buffer := bytes.NewBuffer(a.archiveData) + gzipReader, err := gzip.NewReader(buffer) + if err != nil { + return err + } + + a.gzipReader = gzipReader + a.reader = tar.NewReader(gzipReader) + return nil +} + +// ExtractFiles extracts specific files from the artifact to the provided writers. +// This function can be called multiple times on the same Artifact instance. func (a *Artifact) ExtractFiles(writers ...*ArtifactFileWriter) error { + // Reset the reader to the beginning of the archive + if err := a.resetReader(); err != nil { + return err + } + paths := make(map[string]io.Writer) for _, writer := range writers { canonicalPath := filepath.Clean(writer.path) @@ -149,3 +183,11 @@ func NewArtifactFileReader(path string, reader io.Reader) *ArtifactFileReader { reader: reader, } } + +// Close closes the gzip reader and releases resources. +func (a *Artifact) Close() error { + if a.gzipReader != nil { + return a.gzipReader.Close() + } + return nil +} diff --git a/devnet-sdk/kt/fs/fs_test.go b/devnet-sdk/kt/fs/fs_test.go index 94c2401653ca2..2f486373f8e78 100644 --- a/devnet-sdk/kt/fs/fs_test.go +++ b/devnet-sdk/kt/fs/fs_test.go @@ -153,6 +153,112 @@ func TestArtifactExtraction(t *testing.T) { } } +func TestMultipleExtractCalls(t *testing.T) { + // Create a test artifact with multiple files + files := map[string]string{ + "file1.txt": "content1", + "file2.txt": "content2", + "dir/file3.txt": "content3", + } + + // Create mock context with artifact + mockCtx := &mockEnclaveContext{ + artifacts: map[string][]byte{ + "test-artifact": createTarGzArtifact(t, files), + }, + } + + fs := NewEnclaveFSWithContext(mockCtx) + artifact, err := fs.GetArtifact(context.Background(), "test-artifact") + require.NoError(t, err) + defer artifact.Close() + + // First extraction - extract file1.txt + buf1 := &bytes.Buffer{} + writer1 := NewArtifactFileWriter("file1.txt", buf1) + err = artifact.ExtractFiles(writer1) + require.NoError(t, err) + require.Equal(t, "content1", buf1.String(), "content mismatch for file1.txt on first extraction") + + // Second extraction - extract file2.txt + buf2 := &bytes.Buffer{} + writer2 := NewArtifactFileWriter("file2.txt", buf2) + err = artifact.ExtractFiles(writer2) + require.NoError(t, err) + require.Equal(t, "content2", buf2.String(), "content mismatch for file2.txt on second extraction") + + // Third extraction - extract multiple files at once + buf3 := &bytes.Buffer{} + buf4 := &bytes.Buffer{} + writer3 := NewArtifactFileWriter("file1.txt", buf3) + writer4 := NewArtifactFileWriter("dir/file3.txt", buf4) + err = artifact.ExtractFiles(writer3, writer4) + require.NoError(t, err) + require.Equal(t, "content1", buf3.String(), "content mismatch for file1.txt on third extraction") + require.Equal(t, "content3", buf4.String(), "content mismatch for dir/file3.txt on third extraction") +} + +func TestComplexExtractionScenarios(t *testing.T) { + // Create a test artifact with files containing longer content + longContent1 := "This is a longer content that will be extracted in parts\nIt has multiple lines\nAnd should be extractable multiple times" + longContent2 := "Another file with content\nThat spans multiple lines\nAnd should be extractable" + + files := map[string]string{ + "config.json": `{"key1":"value1","key2":"value2","nested":{"inner":"value"}}`, + "data.txt": longContent1, + "log.txt": longContent2, + } + + // Create mock context with artifact + mockCtx := &mockEnclaveContext{ + artifacts: map[string][]byte{ + "test-artifact": createTarGzArtifact(t, files), + }, + } + + fs := NewEnclaveFSWithContext(mockCtx) + artifact, err := fs.GetArtifact(context.Background(), "test-artifact") + require.NoError(t, err) + defer artifact.Close() + + // Test case 1: Extract all files first + bufAll := make(map[string]*bytes.Buffer) + var writersAll []*ArtifactFileWriter + + for path := range files { + bufAll[path] = &bytes.Buffer{} + writersAll = append(writersAll, NewArtifactFileWriter(path, bufAll[path])) + } + + err = artifact.ExtractFiles(writersAll...) + require.NoError(t, err) + + // Verify all contents + for path, content := range files { + require.Equal(t, content, bufAll[path].String(), "content mismatch for %s", path) + } + + // Test case 2: Now extract each file individually and verify + for path, content := range files { + buf := &bytes.Buffer{} + writer := NewArtifactFileWriter(path, buf) + + err = artifact.ExtractFiles(writer) + require.NoError(t, err) + require.Equal(t, content, buf.String(), "individual extraction failed for %s", path) + } + + // Test case 3: Extract the same file multiple times + for i := 0; i < 3; i++ { + buf := &bytes.Buffer{} + writer := NewArtifactFileWriter("data.txt", buf) + + err = artifact.ExtractFiles(writer) + require.NoError(t, err) + require.Equal(t, longContent1, buf.String(), "repeated extraction %d failed for data.txt", i) + } +} + func TestPutArtifact(t *testing.T) { tests := []struct { name string diff --git a/devnet-sdk/system/chain.go b/devnet-sdk/system/chain.go index 6a3e4700a5509..18862814a732b 100644 --- a/devnet-sdk/system/chain.go +++ b/devnet-sdk/system/chain.go @@ -14,6 +14,7 @@ import ( "github.com/ethereum/go-ethereum/common" coreTypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/params" ) var ( @@ -61,22 +62,26 @@ type chain struct { id string rpcUrl string - users map[string]Wallet - clients *clientManager - registry interfaces.ContractsRegistry - mu sync.Mutex + users map[string]Wallet + clients *clientManager + registry interfaces.ContractsRegistry + mu sync.Mutex + chainConfig *params.ChainConfig + addresses map[string]types.Address } func (c *chain) Client() (*ethclient.Client, error) { return c.clients.Client(c.rpcUrl) } -func newChain(chainID string, rpcUrl string, users map[string]Wallet) *chain { +func newChain(chainID string, rpcUrl string, users map[string]Wallet, chainConfig *params.ChainConfig, addresses map[string]types.Address) *chain { return &chain{ - id: chainID, - rpcUrl: rpcUrl, - users: users, - clients: newClientManager(), + id: chainID, + rpcUrl: rpcUrl, + users: users, + clients: newClientManager(), + chainConfig: chainConfig, + addresses: addresses, } } @@ -189,15 +194,53 @@ func (c *chain) SupportsEIP(ctx context.Context, eip uint64) bool { return false } +func (c *chain) ChainConfig() (*params.ChainConfig, error) { + if c.chainConfig == nil { + return nil, fmt.Errorf("chain config not configured on L1 chains yet") + } + return c.chainConfig, nil +} + +func (c *chain) BlockByHash(ctx context.Context, hash common.Hash) (*coreTypes.Block, error) { + client, err := c.Client() + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + return client.BlockByHash(ctx, hash) +} + +func (c *chain) BlockByNumber(ctx context.Context, number *big.Int) (*coreTypes.Block, error) { + client, err := c.Client() + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + return client.BlockByNumber(ctx, number) +} + +func (c *chain) LatestBlock(ctx context.Context) (*coreTypes.Block, error) { + client, err := c.Client() + if err != nil { + return nil, fmt.Errorf("failed to get client: %w", err) + } + return client.BlockByNumber(ctx, nil) // nil means latest block in geth +} + +func (c *chain) Addresses() map[string]common.Address { + return c.addresses +} + func chainFromDescriptor(d *descriptors.Chain) (Chain, error) { // TODO: handle incorrect descriptors better. We could panic here. firstNodeRPC := d.Nodes[0].Services["el"].Endpoints["rpc"] rpcURL := fmt.Sprintf("http://%s:%d", firstNodeRPC.Host, firstNodeRPC.Port) - c := newChain(d.ID, rpcURL, nil) // Create chain first + c := newChain(d.ID, rpcURL, nil, d.ChainConfig, d.Addresses) // Create chain first users := make(map[string]Wallet) for key, w := range d.Wallets { + // TODO: The assumption that the wallet will necessarily be used on chain `d` may + // be problematic if the L2 admin wallets are to be used to sign L1 transactions. + // TBD on whether they belong somewhere other than `d.Wallets`. k, err := newWallet(w.PrivateKey, w.Address, c) if err != nil { return nil, fmt.Errorf("failed to create wallet: %w", err) diff --git a/devnet-sdk/system/chain_test.go b/devnet-sdk/system/chain_test.go index 9730c8384cba4..775b331ba1a10 100644 --- a/devnet-sdk/system/chain_test.go +++ b/devnet-sdk/system/chain_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/ethereum-optimism/optimism/devnet-sdk/descriptors" + "github.com/ethereum-optimism/optimism/devnet-sdk/types" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -90,8 +91,7 @@ func TestChainWallet(t *testing.T) { assert.Nil(t, err) chain := newChain("1", "http://localhost:8545", map[string]Wallet{ - "user1": wallet, - }) + "user1": wallet}, nil, map[string]common.Address{}) t.Run("finds wallet meeting constraints", func(t *testing.T) { constraint := &addressConstraint{addr: testAddr} @@ -156,7 +156,7 @@ func TestChainID(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - chain := newChain(tt.idString, "", nil) + chain := newChain(tt.idString, "", nil, nil, map[string]types.Address{}) got := chain.ID() // Compare the underlying big.Int values assert.Equal(t, 0, tt.want.Cmp(got)) @@ -166,7 +166,7 @@ func TestChainID(t *testing.T) { func TestSupportsEIP(t *testing.T) { ctx := context.Background() - chain := newChain("1", "http://localhost:8545", nil) + chain := newChain("1", "http://localhost:8545", nil, nil, map[string]types.Address{}) // Since we can't reliably test against a live node, we're just testing the error case t.Run("returns false for connection error", func(t *testing.T) { @@ -176,7 +176,7 @@ func TestSupportsEIP(t *testing.T) { } func TestContractsRegistry(t *testing.T) { - chain := newChain("1", "http://localhost:8545", nil) + chain := newChain("1", "http://localhost:8545", nil, nil, map[string]types.Address{}) t.Run("returns empty registry on error", func(t *testing.T) { registry := chain.ContractsRegistry() diff --git a/devnet-sdk/system/interfaces.go b/devnet-sdk/system/interfaces.go index 01ededc3c8e41..d92c99d96f058 100644 --- a/devnet-sdk/system/interfaces.go +++ b/devnet-sdk/system/interfaces.go @@ -9,6 +9,7 @@ import ( "github.com/ethereum/go-ethereum/common" coreTypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/params" ) type genSystem[T Chain] interface { @@ -25,6 +26,19 @@ type LowLevelSystem = genSystem[LowLevelChain] // Chain represents an Ethereum chain (L1 or L2) type Chain interface { ID() types.ChainID + // If an instance of an implementation this interface represents an L1 chain, + // then then the wallets returned should be either validator wallets or test wallets, + // both useful in the context of sending transactions on the L1. + // + // If an instance of an implementation of this interface represents an L2 chain, + // then the wallets returned should be a combination of: + // 1. L2 admin wallets: wallets with admin priviledges for administrating an + // L2's bridge contracts, etc on L1. Despite inclusion on the L2 wallet list, these wallets + // are not useful for sending transactions on the L2 and do not control any L2 balance. + // 2. L2 test wallets: wallets controlling balance on the L2 for purposes of + // testing. The balance on these wallets will originate unbacked L2 ETH from + // the L2 genesis definition which cannot be withdrawn without maybe "stealing" + // the backing from other deposits. Wallets(ctx context.Context) ([]Wallet, error) ContractsRegistry() interfaces.ContractsRegistry SupportsEIP(ctx context.Context, eip uint64) bool @@ -32,6 +46,18 @@ type Chain interface { GasPrice(ctx context.Context) (*big.Int, error) GasLimit(ctx context.Context, tx TransactionData) (uint64, error) PendingNonceAt(ctx context.Context, address common.Address) (uint64, error) + ChainConfig() (*params.ChainConfig, error) + + // BlockByONumber retrieves a block from the chain by its number. + // If the number is nil, the latest block is returned. + BlockByNumber(ctx context.Context, number *big.Int) (*coreTypes.Block, error) + + // LatestBlock retrieves the latest block from the chain. + LatestBlock(ctx context.Context) (*coreTypes.Block, error) + + // BlockByHash retrieves a block from the chain by its hash. + BlockByHash(ctx context.Context, hash common.Hash) (*coreTypes.Block, error) + Addresses() map[string]common.Address } // LowLevelChain is a Chain that gives direct access to the low level RPC client. diff --git a/devnet-sdk/system/system.go b/devnet-sdk/system/system.go index 51a1c0252b93b..288e5686f61e5 100644 --- a/devnet-sdk/system/system.go +++ b/devnet-sdk/system/system.go @@ -44,11 +44,12 @@ func (s *system) Identifier() string { func (s *system) addChains(chains ...*descriptors.Chain) error { for _, chainDesc := range chains { - if chainDesc.ID == "" { + if chainDesc.Name == "Ethereum" { l1, err := chainFromDescriptor(chainDesc) if err != nil { return fmt.Errorf("failed to add L1 chain: %w", err) } + fmt.Println("L1 chain ID: ", l1.ID()) s.l1 = l1 } else { l2, err := chainFromDescriptor(chainDesc) diff --git a/devnet-sdk/system/system_test.go b/devnet-sdk/system/system_test.go index b05d7e10bc547..29fe3931b5582 100644 --- a/devnet-sdk/system/system_test.go +++ b/devnet-sdk/system/system_test.go @@ -164,7 +164,7 @@ func TestSystemFromDevnet(t *testing.T) { } func TestWallet(t *testing.T) { - chain := newChain("1", "http://localhost:8545", nil) + chain := newChain("1", "http://localhost:8545", nil, nil, map[string]types.Address{}) tests := []struct { name string @@ -204,7 +204,7 @@ func TestWallet(t *testing.T) { } func TestChainUser(t *testing.T) { - chain := newChain("1", "http://localhost:8545", nil) + chain := newChain("1", "http://localhost:8545", nil, nil, map[string]types.Address{}) testWallet, err := newWallet("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", common.HexToAddress("0x123"), chain) assert.Nil(t, err) diff --git a/devnet-sdk/system/txbuilder_test.go b/devnet-sdk/system/txbuilder_test.go index 2443ea7757237..e59ae462095c2 100644 --- a/devnet-sdk/system/txbuilder_test.go +++ b/devnet-sdk/system/txbuilder_test.go @@ -2,6 +2,7 @@ package system import ( "context" + "fmt" "math/big" "testing" @@ -12,6 +13,7 @@ import ( ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/params" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -75,6 +77,8 @@ func newMockChain() *mockChain { } } +var _ Chain = (*mockChain)(nil) + func (m *mockChain) ID() types.ChainID { args := m.Called() return args.Get(0).(types.ChainID) @@ -119,6 +123,29 @@ func (m *mockChain) Wallets(ctx context.Context) ([]Wallet, error) { return nil, nil } +func (m *mockChain) ChainConfig() (*params.ChainConfig, error) { + return nil, fmt.Errorf("not implemented for mock chain") +} + +func (m *mockChain) BlockByHash(ctx context.Context, hash common.Hash) (*ethtypes.Block, error) { + args := m.Called(ctx, hash) + return args.Get(0).(*ethtypes.Block), args.Error(1) +} + +func (m *mockChain) BlockByNumber(ctx context.Context, number *big.Int) (*ethtypes.Block, error) { + args := m.Called(ctx, number) + return args.Get(0).(*ethtypes.Block), args.Error(1) +} + +func (m *mockChain) LatestBlock(ctx context.Context) (*ethtypes.Block, error) { + args := m.Called(ctx) + return args.Get(0).(*ethtypes.Block), args.Error(1) +} +func (m *mockChain) Addresses() map[string]common.Address { + args := m.Called() + return args.Get(0).(map[string]common.Address) +} + func TestNewTxBuilder(t *testing.T) { ctx := context.Background() chain := newMockChain() diff --git a/devnet-sdk/system/txprocessor_test.go b/devnet-sdk/system/txprocessor_test.go index e6824b08a9948..e0b74441e3f02 100644 --- a/devnet-sdk/system/txprocessor_test.go +++ b/devnet-sdk/system/txprocessor_test.go @@ -6,6 +6,7 @@ import ( "math/big" "testing" + devnetsdktypes "github.com/ethereum-optimism/optimism/devnet-sdk/types" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/stretchr/testify/assert" @@ -26,7 +27,7 @@ func TestTransactionProcessor_Sign(t *testing.T) { client := new(mockEthClient) // Create a wallet with the test key - chain := newChain(chainID.String(), "http://localhost:8545", nil) + chain := newChain(chainID.String(), "http://localhost:8545", nil, nil, map[string]devnetsdktypes.Address{}) wallet, err := newWallet(testKey, testAddr, chain) assert.NoError(t, err) diff --git a/devnet-sdk/testing/systest/testing_test.go b/devnet-sdk/testing/systest/testing_test.go index 62f2b5f1645f5..6ac6c2844d560 100644 --- a/devnet-sdk/testing/systest/testing_test.go +++ b/devnet-sdk/testing/systest/testing_test.go @@ -12,7 +12,9 @@ import ( "github.com/ethereum-optimism/optimism/devnet-sdk/system" "github.com/ethereum-optimism/optimism/devnet-sdk/types" "github.com/ethereum/go-ethereum/common" + coreTypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/params" "github.com/stretchr/testify/require" ) @@ -78,6 +80,8 @@ func (m *mockTBRecorder) Skipped() bool { return m.skipped } // mockChain implements a minimal system.Chain for testing type mockChain struct{} +var _ system.Chain = (*mockChain)(nil) + func (m *mockChain) RPCURL() string { return "http://localhost:8545" } func (m *mockChain) Client() (*ethclient.Client, error) { return ethclient.Dial(m.RPCURL()) } func (m *mockChain) ID() types.ChainID { return types.ChainID(big.NewInt(1)) } @@ -97,6 +101,21 @@ func (m *mockChain) PendingNonceAt(ctx context.Context, address common.Address) func (m *mockChain) SupportsEIP(ctx context.Context, eip uint64) bool { return true } +func (m *mockChain) ChainConfig() (*params.ChainConfig, error) { + return nil, fmt.Errorf("not implemented on mockChain") +} +func (m *mockChain) BlockByHash(ctx context.Context, hash common.Hash) (*coreTypes.Block, error) { + return nil, fmt.Errorf("not implemented on mockChain") +} +func (m *mockChain) BlockByNumber(ctx context.Context, number *big.Int) (*coreTypes.Block, error) { + return nil, fmt.Errorf("not implemented on mockChain") +} +func (m *mockChain) LatestBlock(ctx context.Context) (*coreTypes.Block, error) { + return nil, fmt.Errorf("not implemented on mockChain") +} +func (m *mockChain) Addresses() map[string]common.Address { + return map[string]common.Address{} +} // mockSystem implements a minimal system.System for testing type mockSystem struct{} diff --git a/devnet-sdk/testing/testlib/validators/forks.go b/devnet-sdk/testing/testlib/validators/forks.go new file mode 100644 index 0000000000000..f265dadad529f --- /dev/null +++ b/devnet-sdk/testing/testlib/validators/forks.go @@ -0,0 +1,203 @@ +package validators + +import ( + "context" + "fmt" + + "github.com/ethereum-optimism/optimism/devnet-sdk/system" + "github.com/ethereum-optimism/optimism/devnet-sdk/testing/systest" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum/go-ethereum/params" +) + +// ForkConfig holds the chain configuration and latest block timestamp +// for checking if various forks are activated. +type ForkConfig struct { + config *params.ChainConfig + timestamp uint64 +} + +// IsForkActivated checks if a specific fork is activated at the given timestamp +// based on the chain configuration. +func (fc *ForkConfig) IsForkActivated(forkName rollup.ForkName) bool { + switch forkName { + case "bedrock": + // Bedrock is activated based on block number, not timestamp + return true // Assuming bedrock is always active in the context of this validator + case "regolith": + return fc.config.IsOptimismRegolith(fc.timestamp) + case "canyon": + return fc.config.IsOptimismCanyon(fc.timestamp) + case "ecotone": + return fc.config.IsOptimismEcotone(fc.timestamp) + case "fjord": + return fc.config.IsOptimismFjord(fc.timestamp) + case "granite": + return fc.config.IsOptimismGranite(fc.timestamp) + case "holocene": + return fc.config.IsOptimismHolocene(fc.timestamp) + case "isthmus": + return fc.config.IsOptimismIsthmus(fc.timestamp) + case "jovian": + return fc.config.IsOptimismJovian(fc.timestamp) + default: + return false + } +} + +// CurrentFork returns the most recent fork that is currently activated. +func (fc *ForkConfig) CurrentFork() rollup.ForkName { + if fc.config.IsOptimismJovian(fc.timestamp) { + return rollup.Jovian + } else if fc.config.IsOptimismIsthmus(fc.timestamp) { + return rollup.Isthmus + } else if fc.config.IsOptimismHolocene(fc.timestamp) { + return rollup.Holocene + } else if fc.config.IsOptimismGranite(fc.timestamp) { + return rollup.Granite + } else if fc.config.IsOptimismFjord(fc.timestamp) { + return rollup.Fjord + } else if fc.config.IsOptimismEcotone(fc.timestamp) { + return rollup.Ecotone + } else if fc.config.IsOptimismCanyon(fc.timestamp) { + return rollup.Canyon + } else if fc.config.IsOptimismRegolith(fc.timestamp) { + return rollup.Regolith + } else { + return rollup.Bedrock + } +} + +func (fc *ForkConfig) IsInteropEnabled() bool { + return fc.config.IsInterop(fc.timestamp) +} + +// getForkConfig is a helper function that retrieves the ForkConfig for a specific L2 chain. +func getForkConfig(t systest.T, sys system.System, chainIdx uint64) (*ForkConfig, error) { + if len(sys.L2s()) <= int(chainIdx) { + return nil, fmt.Errorf("chain index %d out of range, only %d L2 chains available", chainIdx, len(sys.L2s())) + } + + chain := sys.L2s()[chainIdx] + + chainConfig, err := chain.ChainConfig() + if err != nil { + return nil, fmt.Errorf("failed to get chain config for L2 chain %d: %w", chainIdx, err) + } + + block, err := chain.LatestBlock(t.Context()) + if err != nil { + return nil, fmt.Errorf("failed to get latest block for L2 chain %d: %w", chainIdx, err) + } + + return &ForkConfig{ + config: chainConfig, + timestamp: block.Time(), + }, nil +} + +// forkConfigValidator is a helper function that checks if a specific L2 chain meets a fork condition. +func forkConfigValidator(chainIdx uint64, forkName rollup.ForkName, shouldBeActive bool, forkConfigMarker interface{}) systest.PreconditionValidator { + return func(t systest.T, sys system.System) (context.Context, error) { + forkConfig, err := getForkConfig(t, sys, chainIdx) + if err != nil { + return nil, err + } + + isActive := forkConfig.IsForkActivated(forkName) + if isActive != shouldBeActive { + if shouldBeActive { + return nil, fmt.Errorf("L2 chain %d does not have fork %s activated, which it should be for this validator to pass", chainIdx, forkName) + } else { + return nil, fmt.Errorf("L2 chain %d has fork %s activated, but it should not be for this validator to pass", chainIdx, forkName) + } + } + + return context.WithValue(t.Context(), forkConfigMarker, forkConfig), nil + } +} + +// ForkConfigGetter is a function type that retrieves a ForkConfig from a context. +type ForkConfigGetter = func(context.Context) *ForkConfig + +type forkConfigMarker struct{} + +// AcquireForkConfig returns a ForkConfigGetter and a PreconditionValidator +// that ensures a ForkConfig is available for the specified L2 chain. +// The ForkConfig can be used to check if various forks are activated. +func acquireForkConfig(chainIdx uint64, forkName rollup.ForkName, shouldBeActive bool) (ForkConfigGetter, systest.PreconditionValidator) { + forkConfigMarker := &forkConfigMarker{} + validator := forkConfigValidator(chainIdx, forkName, shouldBeActive, forkConfigMarker) + return func(ctx context.Context) *ForkConfig { + return ctx.Value(forkConfigMarker).(*ForkConfig) + }, validator +} + +// RequiresFork returns a validator that ensures a specific L2 chain has a specific fork activated. +func AcquireRequiresL2Fork(chainIdx uint64, forkName rollup.ForkName) (ForkConfigGetter, systest.PreconditionValidator) { + return acquireForkConfig(chainIdx, forkName, true) +} + +// RequiresNotFork returns a validator that ensures a specific L2 chain does not have a specific fork activated. +func AcquireRequiresNotL2Fork(chainIdx uint64, forkName rollup.ForkName) (ForkConfigGetter, systest.PreconditionValidator) { + return acquireForkConfig(chainIdx, forkName, false) +} + +// exactForkValidator is a helper function that checks if a specific L2 chain is exactly at a given fork. +func exactForkValidator(chainIdx uint64, forkName rollup.ForkName, forkConfigMarker interface{}) systest.PreconditionValidator { + return func(t systest.T, sys system.System) (context.Context, error) { + forkConfig, err := getForkConfig(t, sys, chainIdx) + if err != nil { + return nil, err + } + + currentFork := forkConfig.CurrentFork() + if currentFork != forkName { + return nil, fmt.Errorf("L2 chain %d is at fork %s, but should be exactly at fork %s for this validator to pass", chainIdx, currentFork, forkName) + } + + return context.WithValue(t.Context(), forkConfigMarker, forkConfig), nil + } +} + +// AcquireRequiresExactL2Fork returns a validator that ensures a specific L2 chain is exactly at a specific fork, +// meaning the specified fork must be active but no later forks may be activated. +func AcquireRequiresExactL2Fork(chainIdx uint64, forkName rollup.ForkName) (ForkConfigGetter, systest.PreconditionValidator) { + forkConfigMarker := &forkConfigMarker{} + validator := exactForkValidator(chainIdx, forkName, forkConfigMarker) + return func(ctx context.Context) *ForkConfig { + return ctx.Value(forkConfigMarker).(*ForkConfig) + }, validator +} + +// interopValidator is a helper function that checks if a specific L2 chain has interop enabled or disabled. +func interopValidator(chainIdx uint64, shouldBeEnabled bool, forkConfigMarker interface{}) systest.PreconditionValidator { + return func(t systest.T, sys system.System) (context.Context, error) { + forkConfig, err := getForkConfig(t, sys, chainIdx) + if err != nil { + return nil, err + } + + isEnabled := forkConfig.IsInteropEnabled() + if isEnabled != shouldBeEnabled { + if shouldBeEnabled { + return nil, fmt.Errorf("L2 chain %d does not have interop enabled, which it should be for this validator to pass", chainIdx) + } else { + return nil, fmt.Errorf("L2 chain %d has interop enabled, but it should not be for this validator to pass", chainIdx) + } + } + + return context.WithValue(t.Context(), forkConfigMarker, forkConfig), nil + } +} + +// AcquireInteropConfig returns a ForkConfigGetter and a PreconditionValidator +// that ensures a ForkConfig is available for the specified L2 chain and checks +// if interop is enabled or disabled as specified. +func AcquireInteropConfig(chainIdx uint64, shouldBeEnabled bool) (ForkConfigGetter, systest.PreconditionValidator) { + forkConfigMarker := &forkConfigMarker{} + validator := interopValidator(chainIdx, shouldBeEnabled, forkConfigMarker) + return func(ctx context.Context) *ForkConfig { + return ctx.Value(forkConfigMarker).(*ForkConfig) + }, validator +} diff --git a/devnet-sdk/testing/testlib/validators/lowlevel.go b/devnet-sdk/testing/testlib/validators/lowlevel.go index 29e99f0d12758..06b74ce6b28f7 100644 --- a/devnet-sdk/testing/testlib/validators/lowlevel.go +++ b/devnet-sdk/testing/testlib/validators/lowlevel.go @@ -30,6 +30,17 @@ func (l *lowLevelSystemWrapper) L2s() []system.LowLevelChain { return l.l2 } +// lowLevelSystemValidator creates a PreconditionValidator that ensures all chains in the system +// implement the LowLevelChain interface. If successful, it stores a LowLevelSystem wrapper +// in the context using the provided sysMarker as the key. +// +// The validator: +// 1. Checks if the L1 chain implements LowLevelChain +// 2. Checks if all L2 chains implement LowLevelChain +// 3. Creates a lowLevelSystemWrapper containing all chains +// 4. Stores the wrapper in the context with the provided marker +// +// Returns an error if any chain doesn't implement the LowLevelChain interface. func lowLevelSystemValidator(sysMarker interface{}) systest.PreconditionValidator { return func(t systest.T, sys system.System) (context.Context, error) { lowLevelSys := &lowLevelSystemWrapper{} @@ -53,6 +64,8 @@ func lowLevelSystemValidator(sysMarker interface{}) systest.PreconditionValidato } } +type sysMarker struct{} + func AcquireLowLevelSystem() (LowLevelSystemGetter, systest.PreconditionValidator) { sysMarker := new(byte) validator := lowLevelSystemValidator(sysMarker) diff --git a/devnet-sdk/testing/testlib/validators/wallet.go b/devnet-sdk/testing/testlib/validators/wallet.go index 4757ec03046af..ec9b042210c26 100644 --- a/devnet-sdk/testing/testlib/validators/wallet.go +++ b/devnet-sdk/testing/testlib/validators/wallet.go @@ -12,10 +12,24 @@ import ( type WalletGetter = func(context.Context) system.Wallet -func walletFundsValidator(chainIdx uint64, minFunds types.Balance, userMarker interface{}) systest.PreconditionValidator { +// walletFundsValidator creates a PreconditionValidator that ensures a wallet with sufficient funds +// is available on the specified chain. If successful, it stores the wallet in the context +// using the provided userMarker as the key. +// +// Parameters: +// - chain: The chain to check for wallets +// - minFunds: The minimum balance required for a wallet to be selected +// - userMarker: A unique object to use as a key for storing the wallet in the context +// +// The validator: +// 1. Retrieves all wallets from the specified chain +// 2. Checks each wallet to find one with at least the minimum required balance +// 3. If found, stores the wallet in the context with the provided marker +// +// Returns an error if no wallet with sufficient funds is found. +func walletFundsValidator(chain system.Chain, minFunds types.Balance, userMarker interface{}) systest.PreconditionValidator { constraint := constraints.WithBalance(minFunds) return func(t systest.T, sys system.System) (context.Context, error) { - chain := sys.L2s()[chainIdx] wallets, err := chain.Wallets(t.Context()) if err != nil { return nil, err @@ -28,14 +42,28 @@ func walletFundsValidator(chainIdx uint64, minFunds types.Balance, userMarker in } return nil, fmt.Errorf("no available wallet with balance of at least of %s", minFunds) - } } -func AcquireL2WalletWithFunds(chainIdx uint64, minFunds types.Balance) (WalletGetter, systest.PreconditionValidator) { - userMarker := new(byte) - validator := walletFundsValidator(chainIdx, minFunds, userMarker) +func AcquireL2WalletWithFunds(chainIndex uint64, minFunds types.Balance) (WalletGetter, systest.PreconditionValidator) { + walletMarker := new(byte) return func(ctx context.Context) system.Wallet { - return ctx.Value(userMarker).(system.Wallet) - }, validator + return ctx.Value(walletMarker).(system.Wallet) + }, func(t systest.T, sys system.System) (context.Context, error) { + if len(sys.L2s()) <= int(chainIndex) { + return nil, fmt.Errorf("chain index %d out of range, only %d L2 chains available", chainIndex, len(sys.L2s())) + } + chain := sys.L2s()[chainIndex] + return walletFundsValidator(chain, minFunds, walletMarker)(t, sys) + } +} + +func AcquireL1WalletWithFunds(minFunds types.Balance) (WalletGetter, systest.PreconditionValidator) { + walletMarker := &struct{}{} + return func(ctx context.Context) system.Wallet { + return ctx.Value(walletMarker).(system.Wallet) + }, func(t systest.T, sys system.System) (context.Context, error) { + chain := sys.L1() + return walletFundsValidator(chain, minFunds, walletMarker)(t, sys) + } } diff --git a/kurtosis-devnet/book/src/std_output.md b/kurtosis-devnet/book/src/std_output.md index d62c5acf34abe..a77060bdeb663 100644 --- a/kurtosis-devnet/book/src/std_output.md +++ b/kurtosis-devnet/book/src/std_output.md @@ -132,3 +132,113 @@ By leveraging the devnet-sdk integration, your devnets automatically gain access - Automated test setup and teardown These capabilities make kurtosis-devnet an ideal platform for both development and testing environments. + +## Devnet Descriptor Generation + +In the implementation, the devnet descriptor file is of the type DevnetEnvironment, and is generated according to the following flow: + +```mermaid +flowchart TD + %% Main CLI entrypoint + main_go["main.go (CLI entrypoint)"] + cli_config["CLI Config Structure"] + main_go --> |parses flags| cli_config + + %% Template and Data Files + template_file["Template File (YAML)"] + data_file["Optional JSON Data File"] + cli_config --> |references| template_file + cli_config --> |optional| data_file + + %% Deployer Creation + deployer["Deployer"] + cli_config --> |configures| deployer + + %% Template Processing + templater["Templater"] + deployer --> |creates| templater + template_file --> |input to| templater + data_file --> |optional input to| templater + + %% Template Functions + templater --> |provides functions| template_functions["Template Functions"] + template_functions --> |build docker images| local_docker["localDockerImage()"] + template_functions --> |build contracts| local_contracts["localContractArtifacts()"] + template_functions --> |generate prestate| local_prestate["localPrestate()"] + + %% Rendered Template + rendered_buffer["Rendered Template Buffer"] + templater --> |renders to| rendered_buffer + + %% Fileserver Deployment + fileserver["FileServer"] + deployer --> |deploys| fileserver + + %% Kurtosis Deployer + kt_deployer["KurtosisDeployer"] + deployer --> |creates| kt_deployer + rendered_buffer --> |input to| kt_deployer + + %% Enclave Spec Parsing + yaml_spec["YAML Spec"] + enclave_spec["EnclaveSpec"] + kt_deployer --> |parses| yaml_spec + yaml_spec --> |converts to| enclave_spec + + %% Kurtosis Engine & Execution + engine_manager["Engine Manager"] + kt_runner["Kurtosis Runner"] + deployer --> |starts| engine_manager + kt_deployer --> |creates| kt_runner + + %% Environment Information Gathering + inspector["Enclave Inspector"] + observer["Enclave Observer"] + jwt_extractor["JWT Extractor"] + kt_deployer --> |uses| inspector + kt_deployer --> |uses| observer + kt_deployer --> |uses| jwt_extractor + + %% Service Discovery + service_finder["Service Finder"] + inspector --> |results used by| service_finder + + %% Service and Node Information + l1_services["L1 Services/Nodes"] + l2_services["L2 Services/Nodes"] + service_finder --> |finds| l1_services + service_finder --> |finds| l2_services + + %% Contract and Wallet Data + addresses["Contract Addresses"] + wallets["Wallet Data"] + observer --> |extracts| addresses + observer --> |extracts| wallets + + %% JWT Information + jwt_data["JWT Data"] + jwt_extractor --> |extracts| jwt_data + + %% Environment Construction + kurtosis_env["KurtosisEnvironment"] + l1_services --> |populates| kurtosis_env + l2_services --> |populates| kurtosis_env + addresses --> |populates| kurtosis_env + wallets --> |populates| kurtosis_env + jwt_data --> |populates| kurtosis_env + enclave_spec -->|features| kurtosis_env + + %% Final Output + env_output["Environment JSON"] + kurtosis_env --> |serialized to| env_output + + %% Data Types + classDef config fill:#f9f,stroke:#333,stroke-width:2px + classDef process fill:#bbf,stroke:#333,stroke-width:2px + classDef data fill:#ffb,stroke:#333,stroke-width:2px + + class cli_config,template_file,data_file,enclave_spec,yaml_spec config + class main_go,deployer,templater,kt_deployer,kt_runner,engine_manager,fileserver process + class rendered_buffer,l1_services,l2_services,addresses,wallets,jwt_data,kurtosis_env,env_output data + class template_functions,local_docker,local_contracts,local_prestate,inspector,observer,jwt_extractor,service_finder process +``` diff --git a/kurtosis-devnet/pkg/kurtosis/kurtosis.go b/kurtosis-devnet/pkg/kurtosis/kurtosis.go index 9cd3869d6f163..b7ba6a8225f75 100644 --- a/kurtosis-devnet/pkg/kurtosis/kurtosis.go +++ b/kurtosis-devnet/pkg/kurtosis/kurtosis.go @@ -151,7 +151,7 @@ func (d *KurtosisDeployer) GetEnvironmentInfo(ctx context.Context, spec *spec.En } // Get contract addresses - deployerState, err := d.enclaveObserver.EnclaveObserve(ctx, d.enclave) + deployerData, err := d.enclaveObserver.EnclaveObserve(ctx, d.enclave) if err != nil { return nil, fmt.Errorf("failed to parse deployer state: %w", err) } @@ -173,14 +173,15 @@ func (d *KurtosisDeployer) GetEnvironmentInfo(ctx context.Context, spec *spec.En finder := NewServiceFinder(inspectResult.UserServices) if nodes, services := finder.FindL1Services(); len(nodes) > 0 { chain := &descriptors.Chain{ + ID: deployerData.L1ChainID, Name: "Ethereum", Services: services, Nodes: nodes, JWT: jwtData.L1JWT, } - if deployerState.State != nil { - chain.Addresses = descriptors.AddressMap(deployerState.State.Addresses) - chain.Wallets = d.getWallets(deployerState.Wallets) + if deployerData.State != nil { + chain.Addresses = descriptors.AddressMap(deployerData.State.Addresses) + chain.Wallets = d.getWallets(deployerData.L1ValidatorWallets) } env.L1 = chain } @@ -198,12 +199,11 @@ func (d *KurtosisDeployer) GetEnvironmentInfo(ctx context.Context, spec *spec.En } // Add contract addresses if available - if deployerState.State != nil && deployerState.State.Deployments != nil { - if addresses, ok := deployerState.State.Deployments[chainSpec.NetworkID]; ok { - chain.Addresses = descriptors.AddressMap(addresses.Addresses) - } - if wallets, ok := deployerState.State.Deployments[chainSpec.NetworkID]; ok { - chain.Wallets = d.getWallets(wallets.Wallets) + if deployerData.State != nil && deployerData.State.Deployments != nil { + if deployment, ok := deployerData.State.Deployments[chainSpec.NetworkID]; ok { + chain.Addresses = descriptors.AddressMap(deployment.Addresses) + chain.Wallets = d.getWallets(append(deployment.L2Wallets, deployment.L1Wallets...)) + chain.ChainConfig = deployment.ChainConfig } } diff --git a/kurtosis-devnet/pkg/kurtosis/kurtosis_test.go b/kurtosis-devnet/pkg/kurtosis/kurtosis_test.go index 74821607a9fba..8ecbcf063f7c9 100644 --- a/kurtosis-devnet/pkg/kurtosis/kurtosis_test.go +++ b/kurtosis-devnet/pkg/kurtosis/kurtosis_test.go @@ -201,7 +201,7 @@ func TestDeploy(t *testing.T) { }), WithKurtosisEnclaveObserver(&fakeEnclaveObserver{ state: &deployer.DeployerData{ - Wallets: testWallets, + L1ValidatorWallets: testWallets, }, err: tt.deployerErr, }), @@ -272,7 +272,7 @@ func TestGetEnvironmentInfo(t *testing.T) { name: "successful environment info with JWT", spec: testSpec, inspect: &inspect.InspectData{UserServices: testServices}, - deploy: &deployer.DeployerData{Wallets: testWallets}, + deploy: &deployer.DeployerData{L1ValidatorWallets: testWallets}, jwt: testJWTs, want: &KurtosisEnvironment{ DevnetEnvironment: descriptors.DevnetEnvironment{ diff --git a/kurtosis-devnet/pkg/kurtosis/sources/deployer/deployer.go b/kurtosis-devnet/pkg/kurtosis/sources/deployer/deployer.go index 3a99039093f42..fe66d97ebdadc 100644 --- a/kurtosis-devnet/pkg/kurtosis/sources/deployer/deployer.go +++ b/kurtosis-devnet/pkg/kurtosis/sources/deployer/deployer.go @@ -8,10 +8,16 @@ import ( "io" "math/big" "strings" + "text/template" ktfs "github.com/ethereum-optimism/optimism/devnet-sdk/kt/fs" "github.com/ethereum-optimism/optimism/devnet-sdk/types" + "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" ) const ( @@ -20,6 +26,8 @@ const ( defaultStateName = "state.json" defaultGenesisArtifactName = "el_cl_genesis_data" defaultMnemonicsName = "mnemonics.yaml" + defaultGenesisNameTemplate = "genesis-{{.ChainID}}.json" + defaultL1GenesisName = "genesis.json" ) // DeploymentAddresses maps contract names to their addresses @@ -29,8 +37,10 @@ type DeploymentAddresses map[string]types.Address type DeploymentStateAddresses map[string]DeploymentAddresses type DeploymentState struct { - Addresses DeploymentAddresses `json:"addresses"` - Wallets WalletList `json:"wallets"` + Addresses DeploymentAddresses `json:"addresses"` + L1Wallets WalletList `json:"l1_wallets"` + L2Wallets WalletList `json:"l2_wallets"` + ChainConfig *params.ChainConfig `json:"chain_config"` } type DeployerState struct { @@ -56,17 +66,20 @@ type Wallet struct { type WalletList []*Wallet type DeployerData struct { - Wallets WalletList `json:"wallets"` - State *DeployerState `json:"state"` + L1ValidatorWallets WalletList `json:"wallets"` + State *DeployerState `json:"state"` + L1ChainID string `json:"l1_chain_id"` } type Deployer struct { - enclave string - deployerArtifactName string - walletsName string - stateName string - genesisArtifactName string - mnemonicsName string + enclave string + deployerArtifactName string + walletsName string + stateName string + genesisArtifactName string + l1ValidatorMnemonicsName string + l2GenesisNameTemplate string + l1GenesisName string } type DeployerOption func(*Deployer) @@ -97,18 +110,26 @@ func WithGenesisArtifactName(name string) DeployerOption { func WithMnemonicsName(name string) DeployerOption { return func(d *Deployer) { - d.mnemonicsName = name + d.l1ValidatorMnemonicsName = name + } +} + +func WithGenesisNameTemplate(name string) DeployerOption { + return func(d *Deployer) { + d.l2GenesisNameTemplate = name } } func NewDeployer(enclave string, opts ...DeployerOption) *Deployer { d := &Deployer{ - enclave: enclave, - deployerArtifactName: defaultDeployerArtifactName, - walletsName: defaultWalletsName, - stateName: defaultStateName, - genesisArtifactName: defaultGenesisArtifactName, - mnemonicsName: defaultMnemonicsName, + enclave: enclave, + deployerArtifactName: defaultDeployerArtifactName, + walletsName: defaultWalletsName, + stateName: defaultStateName, + genesisArtifactName: defaultGenesisArtifactName, + l1ValidatorMnemonicsName: defaultMnemonicsName, + l2GenesisNameTemplate: defaultGenesisNameTemplate, + l1GenesisName: defaultL1GenesisName, } for _, opt := range opts { @@ -276,25 +297,117 @@ func (d *Deployer) ExtractData(ctx context.Context) (*DeployerData, error) { return nil, err } - wallets, err := parseWalletsFile(walletsBuffer) + l1WalletsForL2Admin, err := parseWalletsFile(walletsBuffer) + if err != nil { + return nil, err + } + + // Generate test wallets from the standard "test test test..." mnemonic + // These are the same wallets funded in L2Genesis.s.sol's devAccounts array + devWallets, err := d.getDevWallets() if err != nil { return nil, err } - for id, wallets := range wallets { + for id, l1Wallets := range l1WalletsForL2Admin { if deployment, exists := state.Deployments[id]; exists { - deployment.Wallets = wallets + deployment.L1Wallets = l1Wallets + deployment.L2Wallets = devWallets state.Deployments[id] = deployment } } - knownWallets, err := d.getKnownWallets(ctx, fs) + // retrieve L2 genesis files + for id, deployment := range state.Deployments { + genesisBuffer := bytes.NewBuffer(nil) + genesisName, err := d.renderGenesisNameTemplate(id) + if err != nil { + return nil, err + } + + if err := a.ExtractFiles( + ktfs.NewArtifactFileWriter(genesisName, genesisBuffer), + ); err != nil { + return nil, err + } + + // Parse the genesis file JSON into a core.Genesis struct + var genesis core.Genesis + if err := json.NewDecoder(genesisBuffer).Decode(&genesis); err != nil { + return nil, fmt.Errorf("failed to parse genesis file %s in artifact %s for chain ID %s: %w", genesisName, d.deployerArtifactName, id, err) + } + + // Store the genesis data in the deployment state + deployment.ChainConfig = genesis.Config + state.Deployments[id] = deployment + } + + // retrieve L1 genesis file + genesisArtifact, err := fs.GetArtifact(ctx, d.genesisArtifactName) + if err != nil { + return nil, err + } + + l1ValidatorWallets, err := d.getL1ValidatorWallets(genesisArtifact) + if err != nil { + return nil, err + } + + l1ChainID, err := d.getL1ChainID(genesisArtifact) if err != nil { return nil, err } return &DeployerData{ - State: state, - Wallets: knownWallets, + State: state, + L1ValidatorWallets: l1ValidatorWallets, + L1ChainID: l1ChainID, }, nil } + +func (d *Deployer) renderGenesisNameTemplate(chainID string) (string, error) { + tmpl, err := template.New("genesis").Parse(d.l2GenesisNameTemplate) + if err != nil { + return "", fmt.Errorf("failed to compile genesis name template %s: %w", d.l2GenesisNameTemplate, err) + } + + var buf bytes.Buffer + err = tmpl.Execute(&buf, map[string]string{"ChainID": chainID}) + if err != nil { + return "", fmt.Errorf("failed to execute name template %s: %w", d.l2GenesisNameTemplate, err) + } + + return buf.String(), nil +} + +// getDevWallets generates the set of test wallets used in L2Genesis.s.sol +// These wallets are derived from the standard test mnemonic +func (d *Deployer) getDevWallets() ([]*Wallet, error) { + m, err := devkeys.NewMnemonicDevKeys(devkeys.TestMnemonic) + if err != nil { + return nil, fmt.Errorf("failed to create mnemonic dev keys: %w", err) + } + + // Generate 30 wallets to match L2Genesis.s.sol's devAccounts array + testWallets := make([]*Wallet, 0, 30) + for i := 0; i < 30; i++ { + key := devkeys.UserKey(uint64(i)) + addr, err := m.Address(key) + if err != nil { + return nil, fmt.Errorf("failed to get address for test wallet %d: %w", i, err) + } + + sec, err := m.Secret(key) + if err != nil { + return nil, fmt.Errorf("failed to get secret key for test wallet %d: %w", i, err) + } + + testWallets = append(testWallets, &Wallet{ + Name: fmt.Sprintf("dev-account-%d", i), + Address: addr, + PrivateKey: hexutil.Bytes(crypto.FromECDSA(sec)).String(), + }) + } + + return testWallets, nil +} diff --git a/kurtosis-devnet/pkg/kurtosis/sources/deployer/wallets.go b/kurtosis-devnet/pkg/kurtosis/sources/deployer/wallets.go index 3dcb5cab6f1b8..bb698c7f4ce83 100644 --- a/kurtosis-devnet/pkg/kurtosis/sources/deployer/wallets.go +++ b/kurtosis-devnet/pkg/kurtosis/sources/deployer/wallets.go @@ -2,13 +2,14 @@ package deployer import ( "bytes" - "context" + "encoding/json" "fmt" "io" ktfs "github.com/ethereum-optimism/optimism/devnet-sdk/kt/fs" "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/crypto" "gopkg.in/yaml.v3" ) @@ -34,25 +35,20 @@ func getMnemonics(r io.Reader) (string, error) { return config[0].Mnemonic, nil } -func (d *Deployer) getKnownWallets(ctx context.Context, fs *ktfs.EnclaveFS) ([]*Wallet, error) { - a, err := fs.GetArtifact(ctx, d.genesisArtifactName) - if err != nil { - return nil, err - } - +func (d *Deployer) getL1ValidatorWallets(genesisArtifact *ktfs.Artifact) ([]*Wallet, error) { mnemonicsBuffer := bytes.NewBuffer(nil) - if err := a.ExtractFiles( - ktfs.NewArtifactFileWriter(d.mnemonicsName, mnemonicsBuffer), + if err := genesisArtifact.ExtractFiles( + ktfs.NewArtifactFileWriter(d.l1ValidatorMnemonicsName, mnemonicsBuffer), ); err != nil { return nil, err } - mnemonics, err := getMnemonics(mnemonicsBuffer) + mnemonic, err := getMnemonics(mnemonicsBuffer) if err != nil { return nil, err } - m, _ := devkeys.NewMnemonicDevKeys(mnemonics) + m, _ := devkeys.NewMnemonicDevKeys(mnemonic) knownWallets := make([]*Wallet, 0) var keys []devkeys.Key @@ -73,3 +69,20 @@ func (d *Deployer) getKnownWallets(ctx context.Context, fs *ktfs.EnclaveFS) ([]* return knownWallets, nil } + +func (d *Deployer) getL1ChainID(genesisArtifact *ktfs.Artifact) (string, error) { + genesisBuffer := bytes.NewBuffer(nil) + if err := genesisArtifact.ExtractFiles( + ktfs.NewArtifactFileWriter(d.l1GenesisName, genesisBuffer), + ); err != nil { + return "", err + } + + // Parse the genesis file JSON into a core.Genesis struct + var genesis core.Genesis + if err := json.NewDecoder(genesisBuffer).Decode(&genesis); err != nil { + return "", fmt.Errorf("failed to parse genesis file %s in artifact %s: %w", d.l1GenesisName, d.genesisArtifactName, err) + } + + return genesis.Config.ChainID.String(), nil +} diff --git a/kurtosis-devnet/pkg/kurtosis/sources/spec/spec.go b/kurtosis-devnet/pkg/kurtosis/sources/spec/spec.go index 369d2c5d37675..0948f309c5345 100644 --- a/kurtosis-devnet/pkg/kurtosis/sources/spec/spec.go +++ b/kurtosis-devnet/pkg/kurtosis/sources/spec/spec.go @@ -21,8 +21,9 @@ type EnclaveSpec struct { // NetworkParams represents the network parameters section in the YAML type NetworkParams struct { - Name string `yaml:"name"` - NetworkID string `yaml:"network_id"` + Name string `yaml:"name"` + NetworkID string `yaml:"network_id"` + IsthmusTimeOffset *int `yaml:"isthmus_time_offset,omitempty"` } // ChainConfig represents a chain configuration in the YAML @@ -62,12 +63,24 @@ type featureExtractor func(YAMLSpec, string) bool var featuresMap = map[string]featureExtractor{ "interop": interopExtractor, + "isthmus": isthmusExtractor, } -func interopExtractor(yamlSpec YAMLSpec, chainName string) bool { +func interopExtractor(yamlSpec YAMLSpec, feature string) bool { return yamlSpec.OptimismPackage.Interop.Enabled } +// Returns true if any configured L2 has Isthmus enabled +func isthmusExtractor(yamlSpec YAMLSpec, feature string) bool { + for _, e := range yamlSpec.OptimismPackage.Chains { + isthmus := e.NetworkParams.IsthmusTimeOffset + if isthmus != nil && *isthmus == 0 { + return true + } + } + return false +} + // ExtractData parses a YAML document and returns the chain specifications func (s *Spec) ExtractData(r io.Reader) (*EnclaveSpec, error) { var yamlSpec YAMLSpec diff --git a/kurtosis-devnet/tests/fees/fees_test.go b/kurtosis-devnet/tests/fees/fees_test.go new file mode 100644 index 0000000000000..ac6b3446f46df --- /dev/null +++ b/kurtosis-devnet/tests/fees/fees_test.go @@ -0,0 +1,341 @@ +package fees + +import ( + "context" + "errors" + "log/slog" + "math/big" + "os" + "testing" + "time" + + "github.com/ethereum-optimism/optimism/devnet-sdk/system" + "github.com/ethereum-optimism/optimism/devnet-sdk/testing/systest" + "github.com/ethereum-optimism/optimism/devnet-sdk/testing/testlib/validators" + "github.com/ethereum-optimism/optimism/devnet-sdk/types" + "github.com/ethereum-optimism/optimism/op-e2e/bindings" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/predeploys" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + gethTypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +func init() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))) +} + +// TestFees verifies that L1/L2 fees are handled properly in different fork configurations +func TestFees(t *testing.T) { + // Define which L2 chain we'll test + chainIdx := uint64(0) + + // Get validators and getters for accessing the system and wallets + lowLevelSystemGetter, lowLevelSystemValidator := validators.AcquireLowLevelSystem() + walletGetter, walletValidator := validators.AcquireL2WalletWithFunds(chainIdx, types.NewBalance(big.NewInt(params.Ether))) + + // Run pre-regolith test + forkGetter, forkValidator := validators.AcquireRequiresNotL2Fork(chainIdx, rollup.Regolith) + systest.SystemTest(t, + feesTestScenario(lowLevelSystemGetter, walletGetter, chainIdx, forkGetter), + lowLevelSystemValidator, + walletValidator, + forkValidator, + ) + + // Run regolith test + forkGetter, forkValidator = validators.AcquireRequiresL2Fork(chainIdx, rollup.Regolith) + _, notForkValidator := validators.AcquireRequiresNotL2Fork(chainIdx, rollup.Ecotone) + systest.SystemTest(t, + feesTestScenario(lowLevelSystemGetter, walletGetter, chainIdx, forkGetter), + lowLevelSystemValidator, + walletValidator, + forkValidator, + notForkValidator, + ) + + // Run ecotone test + forkGetter, forkValidator = validators.AcquireRequiresL2Fork(chainIdx, rollup.Ecotone) + _, notForkValidator = validators.AcquireRequiresNotL2Fork(chainIdx, rollup.Fjord) + systest.SystemTest(t, + feesTestScenario(lowLevelSystemGetter, walletGetter, chainIdx, forkGetter), + lowLevelSystemValidator, + walletValidator, + forkValidator, + notForkValidator, + ) + + // Run fjord test + forkGetter, forkValidator = validators.AcquireRequiresL2Fork(chainIdx, rollup.Fjord) + systest.SystemTest(t, + feesTestScenario(lowLevelSystemGetter, walletGetter, chainIdx, forkGetter), + lowLevelSystemValidator, + walletValidator, + forkValidator, + ) +} + +// stateGetterAdapter adapts the ethclient to implement the StateGetter interface +type stateGetterAdapter struct { + ctx context.Context + t systest.T + client *ethclient.Client +} + +// GetState implements the StateGetter interface +func (sga *stateGetterAdapter) GetState(addr common.Address, key common.Hash) common.Hash { + var result common.Hash + val, err := sga.client.StorageAt(sga.ctx, addr, key, nil) + require.NoError(sga.t, err) + copy(result[:], val) + return result +} + +// waitForTransaction polls for a transaction receipt until it is available or the context is canceled. +// It's a simpler version of the functionality in SimpleTxManager. +func waitForTransaction(ctx context.Context, client *ethclient.Client, hash common.Hash) (*gethTypes.Receipt, error) { + ticker := time.NewTicker(500 * time.Millisecond) // Poll every 500ms + defer ticker.Stop() + + for { + receipt, err := client.TransactionReceipt(ctx, hash) + if receipt != nil && err == nil { + return receipt, nil + } else if err != nil && !errors.Is(err, ethereum.NotFound) { + return nil, err + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + // Continue polling + } + } +} + +// feesTestScenario creates a test scenario for verifying fee calculations +func feesTestScenario( + lowLevelSystemGetter validators.LowLevelSystemGetter, + walletGetter validators.WalletGetter, + chainIdx uint64, + forkGetter validators.ForkConfigGetter, +) systest.SystemTestFunc { + return func(t systest.T, sys system.System) { + ctx := t.Context() + + // Get the low-level system and wallet + llsys := lowLevelSystemGetter(ctx) + wallet := walletGetter(ctx) + fork := forkGetter(ctx) + + // Get the L2 client + l2Chain := llsys.L2s()[chainIdx] + l2Client, err := l2Chain.Client() + require.NoError(t, err) + + // Get the L1 client + l1Chain := llsys.L1() + l1Client, err := l1Chain.Client() + require.NoError(t, err) + + // TODO: Wait for first block after genesis + // The genesis block has zero L1Block values and will throw off the GPO checks + _, err = l2Client.HeaderByNumber(ctx, big.NewInt(1)) + require.NoError(t, err) + + // Get the genesis config + chainConfig, err := l2Chain.ChainConfig() + require.NoError(t, err) + + // Create state getter adapter for L1 cost function + sga := &stateGetterAdapter{ + ctx: ctx, + t: t, + client: l2Client, + } + + // Create L1 cost function + l1CostFn := gethTypes.NewL1CostFunc(chainConfig, sga) + + // Get wallet private key and address + fromAddr := wallet.Address() + privateKey := wallet.PrivateKey() + + // Find gaspriceoracle contract + gpoContract, err := bindings.NewGasPriceOracle(predeploys.GasPriceOracleAddr, l2Client) + require.NoError(t, err) + + // Get wallet balance before test + startBalance, err := l2Client.BalanceAt(ctx, fromAddr, big.NewInt(rpc.EarliestBlockNumber.Int64())) + require.NoError(t, err) + require.Greater(t, startBalance.Uint64(), big.NewInt(0).Uint64()) + + // Get initial balances of fee recipients + baseFeeRecipientStartBalance, err := l2Client.BalanceAt(ctx, predeploys.BaseFeeVaultAddr, big.NewInt(rpc.EarliestBlockNumber.Int64())) + require.NoError(t, err) + + l1FeeRecipientStartBalance, err := l2Client.BalanceAt(ctx, predeploys.L1FeeVaultAddr, big.NewInt(rpc.EarliestBlockNumber.Int64())) + require.NoError(t, err) + + sequencerFeeVaultStartBalance, err := l2Client.BalanceAt(ctx, predeploys.SequencerFeeVaultAddr, big.NewInt(rpc.EarliestBlockNumber.Int64())) + require.NoError(t, err) + + genesisBlock, err := l2Client.BlockByNumber(ctx, big.NewInt(rpc.EarliestBlockNumber.Int64())) + require.NoError(t, err) + + coinbaseStartBalance, err := l2Client.BalanceAt(ctx, genesisBlock.Coinbase(), big.NewInt(rpc.EarliestBlockNumber.Int64())) + require.NoError(t, err) + + // Send a simple transfer from wallet to a test address + transferAmount := big.NewInt(params.Ether / 10) // 0.1 ETH + targetAddr := common.Address{0xff, 0xff} + + // Get suggested gas tip from the client instead of using a hardcoded value + gasTip, err := l2Client.SuggestGasTipCap(ctx) + require.NoError(t, err, "Failed to get suggested gas tip") + + // Estimate gas for the transaction instead of using a hardcoded value + msg := ethereum.CallMsg{ + From: fromAddr, + To: &targetAddr, + Value: transferAmount, + } + gasLimit, err := l2Client.EstimateGas(ctx, msg) + require.NoError(t, err, "Failed to estimate gas") + + // Create and sign transaction with the suggested values + nonce, err := l2Client.PendingNonceAt(ctx, fromAddr) + require.NoError(t, err) + + // Get latest header to get the base fee + header, err := l2Client.HeaderByNumber(ctx, nil) + require.NoError(t, err) + + // Calculate a reasonable gas fee cap based on the base fee + // A common approach is to set fee cap to 2x the base fee + tip + gasFeeCap := new(big.Int).Add( + new(big.Int).Mul(header.BaseFee, big.NewInt(2)), + gasTip, + ) + + txData := &gethTypes.DynamicFeeTx{ + ChainID: l2Chain.ID(), + Nonce: nonce, + GasTipCap: gasTip, + GasFeeCap: gasFeeCap, + Gas: gasLimit, + To: &targetAddr, + Value: transferAmount, + Data: nil, + } + + // Sign transaction + tx := gethTypes.NewTx(txData) + signedTx, err := gethTypes.SignTx(tx, gethTypes.LatestSignerForChainID(l2Chain.ID()), privateKey) + require.NoError(t, err) + + // Send transaction + err = l2Client.SendTransaction(ctx, signedTx) + require.NoError(t, err) + + // Wait for transaction receipt with timeout + ctx, cancel := context.WithTimeout(ctx, time.Second*10) + defer cancel() + receipt, err := waitForTransaction(ctx, l2Client, signedTx.Hash()) + require.NoError(t, err, "Failed to wait for transaction receipt") + require.NotNil(t, receipt) + require.Equal(t, gethTypes.ReceiptStatusSuccessful, receipt.Status) + + // Get block header where transaction was included + header, err = l2Client.HeaderByNumber(ctx, receipt.BlockNumber) + require.NoError(t, err) + + // Get final balances after transaction + coinbaseEndBalance, err := l2Client.BalanceAt(ctx, header.Coinbase, header.Number) + require.NoError(t, err) + + endBalance, err := l2Client.BalanceAt(ctx, fromAddr, header.Number) + require.NoError(t, err) + + baseFeeRecipientEndBalance, err := l2Client.BalanceAt(ctx, predeploys.BaseFeeVaultAddr, header.Number) + require.NoError(t, err) + + l1Header, err := l1Client.HeaderByNumber(ctx, nil) + require.NoError(t, err) + + l1FeeRecipientEndBalance, err := l2Client.BalanceAt(ctx, predeploys.L1FeeVaultAddr, header.Number) + require.NoError(t, err) + + sequencerFeeVaultEndBalance, err := l2Client.BalanceAt(ctx, predeploys.SequencerFeeVaultAddr, header.Number) + require.NoError(t, err) + + // Calculate differences in balances + baseFeeRecipientDiff := new(big.Int).Sub(baseFeeRecipientEndBalance, baseFeeRecipientStartBalance) + l1FeeRecipientDiff := new(big.Int).Sub(l1FeeRecipientEndBalance, l1FeeRecipientStartBalance) + sequencerFeeVaultDiff := new(big.Int).Sub(sequencerFeeVaultEndBalance, sequencerFeeVaultStartBalance) + coinbaseDiff := new(big.Int).Sub(coinbaseEndBalance, coinbaseStartBalance) + + // Verify L2 fee + l2Fee := new(big.Int).Mul(gasTip, new(big.Int).SetUint64(receipt.GasUsed)) + require.Equal(t, sequencerFeeVaultDiff, coinbaseDiff, "coinbase is always sequencer fee vault") + require.Equal(t, l2Fee, coinbaseDiff, "l2 fee mismatch") + require.Equal(t, l2Fee, sequencerFeeVaultDiff) + + // Verify base fee + baseFee := new(big.Int).Mul(header.BaseFee, new(big.Int).SetUint64(receipt.GasUsed)) + require.Equal(t, baseFee, baseFeeRecipientDiff, "base fee mismatch") + + // Verify L1 fee + txBytes, err := signedTx.MarshalBinary() + require.NoError(t, err) + + // Calculate L1 fee based on transaction data and blocktime + l1Fee := l1CostFn(tx.RollupCostData(), header.Time) + require.Equal(t, l1Fee, l1FeeRecipientDiff, "L1 fee mismatch") + + // Verify GPO matches expected state + gpoEcotone, err := gpoContract.IsEcotone(nil) + require.NoError(t, err) + + ecotoneEnabled := fork.IsForkActivated(rollup.Ecotone) + require.Equal(t, ecotoneEnabled, gpoEcotone, "GPO and chain must have same ecotone view") + + gpoFjord, err := gpoContract.IsFjord(nil) + require.NoError(t, err) + fjordEnabled := fork.IsForkActivated(rollup.Fjord) + require.Equal(t, fjordEnabled, gpoFjord, "GPO and chain must have same fjord view") + + // Verify gas price oracle L1 fee calculation + gpoL1Fee, err := gpoContract.GetL1Fee(&bind.CallOpts{}, txBytes) + require.NoError(t, err) + + adjustedGPOFee := gpoL1Fee + if fjordEnabled { + // The fjord calculations are handled differently and may be bounded by the minimum value + // This is a simplification as the full test adapts more precisely + } else if regolithEnabled := fork.IsForkActivated(rollup.Regolith); regolithEnabled && !ecotoneEnabled { + // For post-regolith (but pre-ecotone), adjust the GPO fee to account for signature overhead + artificialGPOOverhead := big.NewInt(68 * 16) // 68 bytes to cover signature and RLP data + l1BaseFee := l1Header.BaseFee + adjustedGPOFee = new(big.Int).Sub(gpoL1Fee, new(big.Int).Mul(artificialGPOOverhead, l1BaseFee)) + } + require.Equal(t, l1Fee, adjustedGPOFee, "GPO reports L1 fee mismatch") + + // Verify receipt L1 fee + require.Equal(t, receipt.L1Fee, l1Fee, "l1 fee in receipt is correct") + + // Calculate total fee and verify wallet balance difference + totalFeeRecipient := new(big.Int).Add(baseFeeRecipientDiff, sequencerFeeVaultDiff) + totalFee := new(big.Int).Add(totalFeeRecipient, l1FeeRecipientDiff) + + balanceDiff := new(big.Int).Sub(startBalance, endBalance) + balanceDiff.Sub(balanceDiff, transferAmount) + require.Equal(t, balanceDiff, totalFee, "balances should add up") + } +} diff --git a/kurtosis-devnet/tests/fjord/check_scripts_test.go b/kurtosis-devnet/tests/fjord/check_scripts_test.go new file mode 100644 index 0000000000000..69e2ef5b301ac --- /dev/null +++ b/kurtosis-devnet/tests/fjord/check_scripts_test.go @@ -0,0 +1,84 @@ +package fjord + +import ( + "context" + "log/slog" + "math/big" + "os" + "testing" + + "github.com/ethereum-optimism/optimism/devnet-sdk/system" + "github.com/ethereum-optimism/optimism/devnet-sdk/testing/systest" + "github.com/ethereum-optimism/optimism/devnet-sdk/testing/testlib/validators" + "github.com/ethereum-optimism/optimism/devnet-sdk/types" + fjordChecks "github.com/ethereum-optimism/optimism/op-chain-ops/cmd/check-fjord/checks" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +// TestCheckFjordScript ensures the op-chain-ops/cmd/check-fjord script runs successfully +// against a test chain with the fjord hardfork activated/unactivated +func TestCheckFjordScript(t *testing.T) { + + l2ChainIndex := uint64(0) + + lowLevelSystemGetter, lowLevelSystemValidator := validators.AcquireLowLevelSystem() + walletGetter, walletValidator := validators.AcquireL2WalletWithFunds(l2ChainIndex, types.NewBalance(big.NewInt(1_000_000))) + forkConfigGetter, forkValidator := validators.AcquireRequiresExactL2Fork(l2ChainIndex, rollup.Fjord) + systest.SystemTest(t, + checkFjordScriptScenario(lowLevelSystemGetter, walletGetter, forkConfigGetter, l2ChainIndex), + lowLevelSystemValidator, + walletValidator, + forkValidator, + ) + + forkConfigGetter, notForkValidator := validators.AcquireRequiresNotL2Fork(l2ChainIndex, rollup.Fjord) + systest.SystemTest(t, + checkFjordScriptScenario(lowLevelSystemGetter, walletGetter, forkConfigGetter, l2ChainIndex), + lowLevelSystemValidator, + walletValidator, + notForkValidator, + ) + +} + +func checkFjordScriptScenario(lowLevelSystemGetter validators.LowLevelSystemGetter, walletGetter validators.WalletGetter, forkGetter validators.ForkConfigGetter, chainIndex uint64) systest.SystemTestFunc { + return func(t systest.T, sys system.System) { + llsys := lowLevelSystemGetter(t.Context()) + wallet := walletGetter(t.Context()) + forkConfig := forkGetter(t.Context()) + + l2Client, err := llsys.L2s()[chainIndex].Client() + require.NoError(t, err) + + // Get the wallet's private key and address + privateKey := wallet.PrivateKey() + walletAddr := wallet.Address() + + checkFjordConfig := &fjordChecks.CheckFjordConfig{ + Log: log.NewLogger(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})), + L2: l2Client, + Key: privateKey, + Addr: walletAddr, + } + + if !forkConfig.IsForkActivated(rollup.Fjord) { + err = fjordChecks.CheckRIP7212(context.Background(), checkFjordConfig) + require.Error(t, err, "expected error for CheckRIP7212") + err = fjordChecks.CheckGasPriceOracle(context.Background(), checkFjordConfig) + require.Error(t, err, "expected error for CheckGasPriceOracle") + err = fjordChecks.CheckTxEmpty(context.Background(), checkFjordConfig) + require.Error(t, err, "expected error for CheckTxEmpty") + err = fjordChecks.CheckTxAllZero(context.Background(), checkFjordConfig) + require.Error(t, err, "expected error for CheckTxAllZero") + err = fjordChecks.CheckTxAll42(context.Background(), checkFjordConfig) + require.Error(t, err, "expected error for CheckTxAll42") + err = fjordChecks.CheckTxRandom(context.Background(), checkFjordConfig) + require.Error(t, err, "expected error for CheckTxRandom") + } else { + err = fjordChecks.CheckAll(context.Background(), checkFjordConfig) + require.NoError(t, err, "should not error on CheckAll") + } + } +} diff --git a/kurtosis-devnet/tests/interop/interop_smoke_test.go b/kurtosis-devnet/tests/interop/interop_smoke_test.go index f2db52a00cf2c..8a3ad13eb16fe 100644 --- a/kurtosis-devnet/tests/interop/interop_smoke_test.go +++ b/kurtosis-devnet/tests/interop/interop_smoke_test.go @@ -52,10 +52,12 @@ func TestSystemWrapETH(t *testing.T) { chainIdx := uint64(0) // We'll use the first L2 chain for this test walletGetter, fundsValidator := validators.AcquireL2WalletWithFunds(chainIdx, sdktypes.NewBalance(big.NewInt(1.0*constants.ETH))) + _, interopValidator := validators.AcquireInteropConfig(chainIdx, true) systest.SystemTest(t, smokeTestScenario(chainIdx, walletGetter), fundsValidator, + interopValidator, ) } diff --git a/kurtosis-devnet/tests/interop/mocks_test.go b/kurtosis-devnet/tests/interop/mocks_test.go index 3bcbea9370f38..805e133bf0840 100644 --- a/kurtosis-devnet/tests/interop/mocks_test.go +++ b/kurtosis-devnet/tests/interop/mocks_test.go @@ -16,7 +16,9 @@ import ( "github.com/ethereum-optimism/optimism/devnet-sdk/types" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + gethTypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/params" ) var ( @@ -126,6 +128,8 @@ type mockFailingChain struct { wallets []system.Wallet } +var _ system.Chain = (*mockFailingChain)(nil) + func newMockFailingChain(id types.ChainID, wallets []system.Wallet) *mockFailingChain { return &mockFailingChain{ id: id, @@ -153,6 +157,21 @@ func (m *mockFailingChain) PendingNonceAt(ctx context.Context, address common.Ad func (m *mockFailingChain) SupportsEIP(ctx context.Context, eip uint64) bool { return true } +func (m *mockFailingChain) ChainConfig() (*params.ChainConfig, error) { + return nil, fmt.Errorf("not implemented") +} +func (m *mockFailingChain) BlockByHash(ctx context.Context, hash common.Hash) (*gethTypes.Block, error) { + return nil, fmt.Errorf("not implemented") +} +func (m *mockFailingChain) BlockByNumber(ctx context.Context, number *big.Int) (*gethTypes.Block, error) { + return nil, fmt.Errorf("not implemented") +} +func (m *mockFailingChain) LatestBlock(ctx context.Context) (*gethTypes.Block, error) { + return nil, fmt.Errorf("not implemented") +} +func (m *mockFailingChain) Addresses() map[string]common.Address { + return map[string]common.Address{} +} // mockFailingSystem implements system.System type mockFailingSystem struct { diff --git a/kurtosis-devnet/tests/isthmus/erc20_bridge_test.go b/kurtosis-devnet/tests/isthmus/erc20_bridge_test.go new file mode 100644 index 0000000000000..da2d4b3e9bb46 --- /dev/null +++ b/kurtosis-devnet/tests/isthmus/erc20_bridge_test.go @@ -0,0 +1,217 @@ +package isthmus + +import ( + "fmt" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" + + "github.com/ethereum-optimism/optimism/devnet-sdk/contracts/constants" + "github.com/ethereum-optimism/optimism/devnet-sdk/system" + "github.com/ethereum-optimism/optimism/devnet-sdk/testing/systest" + "github.com/ethereum-optimism/optimism/devnet-sdk/testing/testlib/validators" + sdktypes "github.com/ethereum-optimism/optimism/devnet-sdk/types" + "github.com/ethereum-optimism/optimism/op-e2e/bindings" + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils/wait" + "github.com/ethereum-optimism/optimism/op-service/predeploys" + "github.com/ethereum-optimism/optimism/op-service/testlog" +) + +func TestERC20Bridge(t *testing.T) { + chainIdx := uint64(0) // We'll use the first L2 chain for this test + + l2WalletGetter, l2WalletFundsValidator := validators.AcquireL2WalletWithFunds( + chainIdx, + sdktypes.NewBalance(big.NewInt(1.0*constants.ETH)), + ) + l1WalletGetter, l1WalletFundsValidator := validators.AcquireL1WalletWithFunds(sdktypes.NewBalance(big.NewInt(1.0 * constants.ETH))) + lowLevelSystemGetter, lowLevelSystemValidator := validators.AcquireLowLevelSystem() + + systest.SystemTest(t, + erc20BridgeTestScenario(lowLevelSystemGetter, chainIdx, l1WalletGetter, l2WalletGetter), + l2WalletFundsValidator, + l1WalletFundsValidator, + lowLevelSystemValidator, + ) +} + +// erc20BridgeTestScenario tests depositing an ERC20 token from L1 to L2 through the bridge +func erc20BridgeTestScenario(lowLevelSystemGetter validators.LowLevelSystemGetter, chainIdx uint64, l1WalletGetter validators.WalletGetter, l2WalletGetter validators.WalletGetter) systest.SystemTestFunc { + return func(t systest.T, sys system.System) { + ctx := t.Context() + + // Get the l2User wallet + llsys := lowLevelSystemGetter(ctx) + l1User := l1WalletGetter(ctx) + l2User := l2WalletGetter(ctx) + + // Get the L1 chain + l1Chain := llsys.L1() + + // Get the L2 chain + l2Chain := llsys.L2s()[chainIdx] + + logger := testlog.Logger(t, log.LevelInfo) + logger.Info("Started ERC20 bridge test") + + // Connect to L1 and L2 + l1Client, err := l1Chain.Client() + require.NoError(t, err) + t.Cleanup(func() { l1Client.Close() }) + + l2Client, err := l2Chain.Client() + require.NoError(t, err) + t.Cleanup(func() { l2Client.Close() }) + + // Print the L1 chain ID as a string + logger.Info("L1 Chain ID", "id", l1Chain.ID()) + + // Create transaction options for L1 + l1Opts, err := bind.NewKeyedTransactorWithChainID(l1User.PrivateKey(), l1Chain.ID()) + require.NoError(t, err) + + // Create transaction options for L2 + l2Opts, err := bind.NewKeyedTransactorWithChainID(l2User.PrivateKey(), l2Chain.ID()) + require.NoError(t, err) + + // Deploy a test ERC20 token on L1 (WETH) + logger.Info("Deploying WETH token on L1") + l1TokenAddress, tx, l1Token, err := bindings.DeployWETH(l1Opts, l1Client) + require.NoError(t, err) + + // Wait for the token deployment transaction to be confirmed + _, err = wait.ForReceiptOK(ctx, l1Client, tx.Hash()) + require.NoError(t, err, "Failed to deploy L1 token") + logger.Info("Deployed L1 token", "address", l1TokenAddress) + + // Mint some tokens to the user (deposit ETH to get WETH) + mintAmount := big.NewInt(params.Ether) // 1 ETH + l1Opts.Value = mintAmount + tx, err = l1Token.Deposit(l1Opts) + require.NoError(t, err) + _, err = wait.ForReceiptOK(ctx, l1Client, tx.Hash()) + require.NoError(t, err, "Failed to mint L1 tokens") + l1Opts.Value = nil + + l1Balance, err := l1Token.BalanceOf(&bind.CallOpts{}, l1User.Address()) + require.NoError(t, err) + require.Equal(t, mintAmount, l1Balance, "User should have the minted tokens on L1") + logger.Info("User has tokens on L1", "balance", l1Balance) + + // Create the corresponding L2 token using the OptimismMintableERC20Factory + logger.Info("Creating L2 token via OptimismMintableERC20Factory") + optimismMintableTokenFactory, err := bindings.NewOptimismMintableERC20Factory(predeploys.OptimismMintableERC20FactoryAddr, l2Client) + require.NoError(t, err) + + // Create the L2 token + l2TokenName := "L2 Test Token" + l2TokenSymbol := "L2TEST" + tx, err = optimismMintableTokenFactory.CreateOptimismMintableERC20(l2Opts, l1TokenAddress, l2TokenName, l2TokenSymbol) + require.NoError(t, err) + l2TokenReceipt, err := wait.ForReceiptOK(ctx, l2Client, tx.Hash()) + require.NoError(t, err, "Failed to create L2 token") + + // Extract the L2 token address from the event logs + var l2TokenAddress common.Address + for _, log := range l2TokenReceipt.Logs { + createdEvent, err := optimismMintableTokenFactory.ParseOptimismMintableERC20Created(*log) + if err == nil && createdEvent != nil { + l2TokenAddress = createdEvent.LocalToken + break + } + } + require.NotEqual(t, common.Address{}, l2TokenAddress, "Failed to find L2 token address from events") + logger.Info("Created L2 token", "address", l2TokenAddress) + + // Get the L2 token contract + l2Token, err := bindings.NewOptimismMintableERC20(l2TokenAddress, l2Client) + require.NoError(t, err) + + // Check initial L2 token balance (should be 0) + initialL2Balance, err := l2Token.BalanceOf(&bind.CallOpts{}, l2User.Address()) + require.NoError(t, err) + require.True(t, big.NewInt(0).Cmp(initialL2Balance) == 0, "Initial L2 token balance should be 0, actual was %s", initialL2Balance.String()) + + // Get the L1 standard bridge contract + l1Chain.ChainConfig() + + l1StandardBridgeAddress, ok := l2Chain.Addresses()["l1StandardBridgeProxy"] + require.True(t, ok, fmt.Errorf("no L1 proxy address configured for this test")) + + l1StandardBridge, err := bindings.NewL1StandardBridge(l1StandardBridgeAddress, l1Client) + require.NoError(t, err) + + // Approve the L1 bridge to spend tokens + logger.Info("Approving L1 bridge to spend tokens") + tx, err = l1Token.Approve(l1Opts, l1StandardBridgeAddress, mintAmount) + require.NoError(t, err) + _, err = wait.ForReceiptOK(ctx, l1Client, tx.Hash()) + require.NoError(t, err, "Failed to approve L1 bridge") + + // Amount to bridge + bridgeAmount := big.NewInt(100000000000000000) // 0.1 token + minGasLimit := uint32(200000) // Minimum gas limit for the L2 transaction + + // Bridge the tokens from L1 to L2 + logger.Info("Bridging tokens from L1 to L2", "amount", bridgeAmount) + tx, err = l1StandardBridge.DepositERC20To( + l1Opts, + l1TokenAddress, + l2TokenAddress, + l2User.Address(), + bridgeAmount, + minGasLimit, + []byte{}, // No extra data + ) + require.NoError(t, err) + depositReceipt, err := wait.ForReceiptOK(ctx, l1Client, tx.Hash()) + require.NoError(t, err, "Failed to deposit tokens to L2") + logger.Info("Deposit transaction confirmed on L1", "tx", tx.Hash().Hex()) + + // Get the OptimismPortal contract to find the deposit event + optimismPortalAddress := common.HexToAddress("0x4200000000000000000000000000000000000000") // Standard address for OptimismPortal + optimismPortal, err := bindings.NewOptimismPortal(optimismPortalAddress, l1Client) + require.NoError(t, err) + + // Find the TransactionDeposited event from the logs + var depositFound bool + for _, log := range depositReceipt.Logs { + depositEvent, err := optimismPortal.ParseTransactionDeposited(*log) + if err == nil && depositEvent != nil { + logger.Info("Found deposit event", "from", depositEvent.From) + depositFound = true + break + } + } + require.True(t, depositFound, "No deposit event found in transaction logs") + + // Wait for the deposit to be processed on L2 + // This may take some time as it depends on the L2 block time and the deposit processing + logger.Info("Waiting for deposit to be processed on L2...") + + // Poll for the L2 balance to change + err = wait.For(ctx, 200*time.Millisecond, func() (bool, error) { + l2Balance, err := l2Token.BalanceOf(&bind.CallOpts{}, l2User.Address()) + if err != nil { + return false, err + } + return l2Balance.Cmp(initialL2Balance) > 0, nil + }) + require.NoError(t, err, "Timed out waiting for L2 balance to change") + + // Verify the final L2 balance + finalL2Balance, err := l2Token.BalanceOf(&bind.CallOpts{}, l2User.Address()) + require.NoError(t, err) + require.True(t, bridgeAmount.Cmp(finalL2Balance) == 0, "L2 balance should match the bridged amount, L2 balance=%s, bridged amount=%s", finalL2Balance, bridgeAmount) + logger.Info("Successfully verified tokens on L2", "balance", finalL2Balance) + + logger.Info("ERC20 bridge test completed successfully!") + } +} diff --git a/op-node/rollup/chain_spec.go b/op-node/rollup/chain_spec.go index 936bb2b17dfa3..67a7372f98100 100644 --- a/op-node/rollup/chain_spec.go +++ b/op-node/rollup/chain_spec.go @@ -42,6 +42,7 @@ const ( Granite ForkName = "granite" Holocene ForkName = "holocene" Isthmus ForkName = "isthmus" + Jovian ForkName = "jovian" Interop ForkName = "interop" // ADD NEW FORKS TO AllForks BELOW! None ForkName = "none"