Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions generator/offline/erc20_transfer.go
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)

Copy link
Copy Markdown
Contributor

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.

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(&ethtypes.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
}
92 changes: 92 additions & 0 deletions generator/offline/scenario.go
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check for max is missing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
}
173 changes: 173 additions & 0 deletions generator/offline/scenario_test.go
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")
})
}
}
Loading
Loading