-
Notifications
You must be signed in to change notification settings - Fork 1
add offline EVM load scenarios #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| package offline | ||
|
|
||
| import ( | ||
| "crypto/ecdsa" | ||
| "fmt" | ||
| "math/big" | ||
| "sync" | ||
|
|
||
| "github.com/ethereum/go-ethereum/accounts/abi" | ||
| "github.com/ethereum/go-ethereum/common" | ||
| ethtypes "github.com/ethereum/go-ethereum/core/types" | ||
| "github.com/ethereum/go-ethereum/core/vm/runtime" | ||
| "github.com/ethereum/go-ethereum/crypto" | ||
| "github.com/ethereum/go-ethereum/params" | ||
|
|
||
| "github.com/sei-protocol/sei-load/generator/bindings" | ||
| ) | ||
|
|
||
| const erc20BalancesSlot = uint64(4) | ||
|
|
||
| var loadERC20Runtime = sync.OnceValues(buildERC20Runtime) | ||
|
|
||
| type erc20TransferScenario struct { | ||
| cfg Config | ||
| signer ethtypes.Signer | ||
| contract *abi.ABI | ||
| } | ||
|
|
||
| func newERC20TransferScenario(cfg Config) (*erc20TransferScenario, error) { | ||
| contract, err := bindings.ERC20MetaData.GetAbi() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("parse ERC20 ABI: %w", err) | ||
| } | ||
| return &erc20TransferScenario{ | ||
| cfg: cfg, | ||
| signer: ethtypes.LatestSignerForChainID(cfg.ChainID), | ||
| contract: contract, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *erc20TransferScenario) SetupGenesis(state GenesisWriter) error { | ||
| code, err := ERC20RuntimeCode() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| state.SetCode(s.cfg.ERC20Contract, code) | ||
| return nil | ||
| } | ||
|
|
||
| func (s *erc20TransferScenario) SeedSender(state GenesisWriter, sender common.Address) { | ||
| state.SetBalance(sender, s.cfg.SenderBalance) | ||
| state.SetState(s.cfg.ERC20Contract, ERC20BalanceSlot(sender), common.BigToHash(s.cfg.TransferValue)) | ||
| } | ||
|
|
||
| func (s *erc20TransferScenario) BuildTransaction(key *ecdsa.PrivateKey, nonce uint64, recipient common.Address) (*ethtypes.Transaction, error) { | ||
| if key == nil { | ||
| return nil, fmt.Errorf("sender private key is required") | ||
| } | ||
| data, err := s.contract.Pack("transfer", recipient, s.cfg.TransferValue) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("pack ERC20 transfer: %w", err) | ||
| } | ||
| tx := ethtypes.NewTx(ðtypes.LegacyTx{ | ||
| Nonce: nonce, | ||
| GasPrice: new(big.Int).Set(s.cfg.GasPrice), | ||
| Gas: s.cfg.GasLimit, | ||
| To: &s.cfg.ERC20Contract, | ||
| Value: new(big.Int), | ||
| Data: data, | ||
| }) | ||
| return ethtypes.SignTx(tx, s.signer, key) | ||
| } | ||
|
|
||
| // ERC20RuntimeCode returns a copy of the runtime produced by the committed | ||
| // sei-load ERC20 creation bytecode. | ||
| func ERC20RuntimeCode() ([]byte, error) { | ||
| code, err := loadERC20Runtime() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return append([]byte(nil), code...), nil | ||
| } | ||
|
|
||
| // ERC20BalanceSlot returns the storage key for _balances[owner] in the | ||
| // committed sei-load ERC20 contract. | ||
| func ERC20BalanceSlot(owner common.Address) common.Hash { | ||
| var encoded [64]byte | ||
| copy(encoded[12:32], owner.Bytes()) | ||
| new(big.Int).SetUint64(erc20BalancesSlot).FillBytes(encoded[32:]) | ||
| return crypto.Keccak256Hash(encoded[:]) | ||
| } | ||
|
|
||
| func buildERC20Runtime() ([]byte, error) { | ||
| contract, err := bindings.ERC20MetaData.GetAbi() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("parse ERC20 ABI: %w", err) | ||
| } | ||
| constructor, err := contract.Constructor.Inputs.Pack("LoadToken", "LT") | ||
| if err != nil { | ||
| return nil, fmt.Errorf("pack ERC20 constructor: %w", err) | ||
| } | ||
| initCode := append(common.FromHex(bindings.ERC20Bin), constructor...) | ||
| code, _, _, err := runtime.Create(initCode, &runtime.Config{ | ||
| ChainConfig: params.AllEthashProtocolChanges, | ||
| Origin: common.HexToAddress("0x1"), | ||
| BlockNumber: big.NewInt(1), | ||
| Time: 1_700_000_000, | ||
| GasLimit: 10_000_000, | ||
| GasPrice: new(big.Int), | ||
| Value: new(big.Int), | ||
| BaseFee: new(big.Int), | ||
| }) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("execute ERC20 constructor: %w", err) | ||
| } | ||
| if len(code) == 0 { | ||
| return nil, fmt.Errorf("ERC20 constructor returned empty runtime") | ||
| } | ||
| return code, nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| // Package offline provides backend-neutral load scenarios for executors that | ||
| // consume raw Ethereum transactions and an explicitly seeded genesis state. | ||
| package offline | ||
|
|
||
| import ( | ||
| "crypto/ecdsa" | ||
| "fmt" | ||
| "math/big" | ||
|
|
||
| "github.com/ethereum/go-ethereum/common" | ||
| ethtypes "github.com/ethereum/go-ethereum/core/types" | ||
| ) | ||
|
|
||
| const ( | ||
| Transfer = "transfer" | ||
| ERC20Transfer = "erc20-transfer" | ||
| ) | ||
|
|
||
| // GenesisWriter is the state surface needed to prepare an offline scenario. | ||
| // Implementations may ignore writes that are irrelevant to their backend. | ||
| type GenesisWriter interface { | ||
| SetBalance(common.Address, *big.Int) | ||
| SetCode(common.Address, []byte) | ||
| SetState(common.Address, common.Hash, common.Hash) | ||
| } | ||
|
|
||
| // Config contains transaction and genesis settings shared by offline scenarios. | ||
| type Config struct { | ||
| ChainID *big.Int | ||
| GasPrice *big.Int | ||
| SenderBalance *big.Int | ||
| TransferValue *big.Int | ||
| GasLimit uint64 | ||
| ERC20Contract common.Address | ||
| } | ||
|
|
||
| // Scenario builds signed transactions and the genesis state they require. | ||
| type Scenario interface { | ||
| SetupGenesis(GenesisWriter) error | ||
| SeedSender(GenesisWriter, common.Address) | ||
| BuildTransaction(*ecdsa.PrivateKey, uint64, common.Address) (*ethtypes.Transaction, error) | ||
| } | ||
|
|
||
| // NewScenario constructs a backend-neutral scenario. | ||
| func NewScenario(kind string, cfg Config) (Scenario, error) { | ||
| if err := validateConfig(cfg); err != nil { | ||
| return nil, err | ||
| } | ||
| cfg = cloneConfig(cfg) | ||
| switch kind { | ||
| case Transfer: | ||
| return newTransferScenario(cfg), nil | ||
| case ERC20Transfer: | ||
| if cfg.ERC20Contract == (common.Address{}) { | ||
| return nil, fmt.Errorf("erc20 contract must be non-zero") | ||
| } | ||
| return newERC20TransferScenario(cfg) | ||
| default: | ||
| return nil, fmt.Errorf("unsupported offline scenario %q", kind) | ||
| } | ||
| } | ||
|
|
||
| func validateConfig(cfg Config) error { | ||
| switch { | ||
| case cfg.ChainID == nil || cfg.ChainID.Sign() <= 0: | ||
| return fmt.Errorf("chain ID must be positive") | ||
| case cfg.GasPrice == nil || cfg.GasPrice.Sign() < 0: | ||
| return fmt.Errorf("gas price must be non-negative") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Check for max is missing
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (oops sorry for leaving these as comment instead of PR review comment) |
||
| case cfg.GasPrice.BitLen() > 256: | ||
| return fmt.Errorf("gas price must fit in 256 bits") | ||
| case cfg.SenderBalance == nil || cfg.SenderBalance.Sign() < 0: | ||
| return fmt.Errorf("sender balance must be non-negative") | ||
| case cfg.SenderBalance.BitLen() > 256: | ||
| return fmt.Errorf("sender balance must fit in 256 bits") | ||
| case cfg.TransferValue == nil || cfg.TransferValue.Sign() < 0: | ||
| return fmt.Errorf("transfer value must be non-negative") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ditto re checking for maximum. |
||
| case cfg.TransferValue.BitLen() > 256: | ||
| return fmt.Errorf("transfer value must fit in 256 bits") | ||
| case cfg.GasLimit == 0: | ||
| return fmt.Errorf("gas limit must be positive") | ||
| default: | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| func cloneConfig(cfg Config) Config { | ||
| cfg.ChainID = new(big.Int).Set(cfg.ChainID) | ||
| cfg.GasPrice = new(big.Int).Set(cfg.GasPrice) | ||
| cfg.SenderBalance = new(big.Int).Set(cfg.SenderBalance) | ||
| cfg.TransferValue = new(big.Int).Set(cfg.TransferValue) | ||
| return cfg | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| package offline | ||
|
|
||
| import ( | ||
| "math/big" | ||
| "testing" | ||
|
|
||
| "github.com/ethereum/go-ethereum/common" | ||
| "github.com/ethereum/go-ethereum/core/state" | ||
| ethtypes "github.com/ethereum/go-ethereum/core/types" | ||
| "github.com/ethereum/go-ethereum/core/vm/runtime" | ||
| "github.com/ethereum/go-ethereum/crypto" | ||
| "github.com/ethereum/go-ethereum/params" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| type testGenesis struct { | ||
| balances map[common.Address]*big.Int | ||
| code map[common.Address][]byte | ||
| storage map[common.Address]map[common.Hash]common.Hash | ||
| } | ||
|
|
||
| func newTestGenesis() *testGenesis { | ||
| return &testGenesis{ | ||
| balances: map[common.Address]*big.Int{}, | ||
| code: map[common.Address][]byte{}, | ||
| storage: map[common.Address]map[common.Hash]common.Hash{}, | ||
| } | ||
| } | ||
|
|
||
| func (s *testGenesis) SetBalance(address common.Address, balance *big.Int) { | ||
| s.balances[address] = new(big.Int).Set(balance) | ||
| } | ||
|
|
||
| func (s *testGenesis) SetCode(address common.Address, code []byte) { | ||
| s.code[address] = append([]byte(nil), code...) | ||
| } | ||
|
|
||
| func (s *testGenesis) SetState(address common.Address, key, value common.Hash) { | ||
| if s.storage[address] == nil { | ||
| s.storage[address] = map[common.Hash]common.Hash{} | ||
| } | ||
| s.storage[address][key] = value | ||
| } | ||
|
|
||
| func testConfig() Config { | ||
| return Config{ | ||
| ChainID: big.NewInt(713_715), | ||
| GasPrice: new(big.Int), | ||
| SenderBalance: new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil), | ||
| TransferValue: big.NewInt(17), | ||
| GasLimit: 100_000, | ||
| ERC20Contract: common.HexToAddress("0x1000"), | ||
| } | ||
| } | ||
|
|
||
| func TestTransferScenarioBuildsAndSeeds(t *testing.T) { | ||
| cfg := testConfig() | ||
| scenario, err := NewScenario(Transfer, cfg) | ||
| require.NoError(t, err) | ||
| state := newTestGenesis() | ||
| require.NoError(t, scenario.SetupGenesis(state)) | ||
|
|
||
| key, err := crypto.HexToECDSA("0000000000000000000000000000000000000000000000000000000000000001") | ||
| require.NoError(t, err) | ||
| sender := crypto.PubkeyToAddress(key.PublicKey) | ||
| recipient := common.HexToAddress("0x2000") | ||
| scenario.SeedSender(state, sender) | ||
| tx, err := scenario.BuildTransaction(key, 3, recipient) | ||
| require.NoError(t, err) | ||
|
|
||
| recovered, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(cfg.ChainID), tx) | ||
| require.NoError(t, err) | ||
| require.Equal(t, sender, recovered) | ||
| require.Equal(t, uint64(3), tx.Nonce()) | ||
| require.Equal(t, recipient, *tx.To()) | ||
| require.Zero(t, cfg.TransferValue.Cmp(tx.Value())) | ||
| require.Zero(t, cfg.SenderBalance.Cmp(state.balances[sender])) | ||
| } | ||
|
|
||
| func TestERC20TransferScenarioUsesCompiledRuntimeAndBalanceSlot(t *testing.T) { | ||
| cfg := testConfig() | ||
| scenario, err := NewScenario(ERC20Transfer, cfg) | ||
| require.NoError(t, err) | ||
| genesis := newTestGenesis() | ||
| require.NoError(t, scenario.SetupGenesis(genesis)) | ||
| require.NotEmpty(t, genesis.code[cfg.ERC20Contract]) | ||
|
|
||
| key, err := crypto.HexToECDSA("0000000000000000000000000000000000000000000000000000000000000002") | ||
| require.NoError(t, err) | ||
| sender := crypto.PubkeyToAddress(key.PublicKey) | ||
| recipient := common.HexToAddress("0x3000") | ||
| scenario.SeedSender(genesis, sender) | ||
| require.Equal(t, common.BigToHash(cfg.TransferValue), genesis.storage[cfg.ERC20Contract][ERC20BalanceSlot(sender)]) | ||
|
|
||
| tx, err := scenario.BuildTransaction(key, 0, recipient) | ||
| require.NoError(t, err) | ||
| require.Equal(t, cfg.ERC20Contract, *tx.To()) | ||
| require.Len(t, tx.Data(), 4+32+32) | ||
|
|
||
| db, err := state.New(ethtypes.EmptyRootHash, state.NewDatabaseForTesting()) | ||
| require.NoError(t, err) | ||
| db.CreateAccount(cfg.ERC20Contract) | ||
| db.SetCode(cfg.ERC20Contract, genesis.code[cfg.ERC20Contract]) | ||
| db.SetState(cfg.ERC20Contract, ERC20BalanceSlot(sender), common.BigToHash(cfg.TransferValue)) | ||
| _, _, err = runtime.Call(cfg.ERC20Contract, tx.Data(), &runtime.Config{ | ||
| ChainConfig: params.AllEthashProtocolChanges, | ||
| Origin: sender, | ||
| BlockNumber: big.NewInt(1), | ||
| Time: 1_700_000_000, | ||
| GasLimit: cfg.GasLimit, | ||
| GasPrice: new(big.Int), | ||
| Value: new(big.Int), | ||
| BaseFee: new(big.Int), | ||
| State: db, | ||
| }) | ||
| require.NoError(t, err) | ||
| require.Equal(t, common.Hash{}, db.GetState(cfg.ERC20Contract, ERC20BalanceSlot(sender))) | ||
| require.Equal(t, common.BigToHash(cfg.TransferValue), db.GetState(cfg.ERC20Contract, ERC20BalanceSlot(recipient))) | ||
| } | ||
|
|
||
| func TestNewScenarioRejectsInvalidConfig(t *testing.T) { | ||
| _, err := NewScenario(Transfer, Config{}) | ||
| require.ErrorContains(t, err, "chain ID") | ||
| _, err = NewScenario("unknown", testConfig()) | ||
| require.ErrorContains(t, err, "unsupported") | ||
| } | ||
|
|
||
| func TestNewScenarioValidatesUint256Values(t *testing.T) { | ||
| maxUint256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) | ||
| cfg := testConfig() | ||
| cfg.GasPrice = new(big.Int).Set(maxUint256) | ||
| cfg.SenderBalance = new(big.Int).Set(maxUint256) | ||
| cfg.TransferValue = new(big.Int).Set(maxUint256) | ||
| _, err := NewScenario(Transfer, cfg) | ||
| require.NoError(t, err) | ||
|
|
||
| overflow := new(big.Int).Add(maxUint256, big.NewInt(1)) | ||
| tests := []struct { | ||
| name string | ||
| field string | ||
| setOverflow func(*Config) | ||
| }{ | ||
| { | ||
| name: "gas price", | ||
| field: "gas price", | ||
| setOverflow: func(cfg *Config) { | ||
| cfg.GasPrice = overflow | ||
| }, | ||
| }, | ||
| { | ||
| name: "sender balance", | ||
| field: "sender balance", | ||
| setOverflow: func(cfg *Config) { | ||
| cfg.SenderBalance = overflow | ||
| }, | ||
| }, | ||
| { | ||
| name: "transfer value", | ||
| field: "transfer value", | ||
| setOverflow: func(cfg *Config) { | ||
| cfg.TransferValue = overflow | ||
| }, | ||
| }, | ||
| } | ||
| for _, test := range tests { | ||
| t.Run(test.name, func(t *testing.T) { | ||
| cfg := testConfig() | ||
| test.setOverflow(&cfg) | ||
| _, err := NewScenario(Transfer, cfg) | ||
| require.ErrorContains(t, err, test.field+" must fit in 256 bits") | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not a blocker but passing around a mutable clone the big int could cause subtle bugs that are hard to debug.
I see this pattern across the repo, which is worth addressing in a separate body of work.