Skip to content
12 changes: 12 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,18 @@ func (c *LoadConfig) GetChainID() *big.Int {
return big.NewInt(c.ChainID)
}

// GetGasMargin returns the margin to apply to what the chain quotes for a call.
//
// It falls back to the default when a config carries no settings, which is how a
// config assembled in code rather than parsed from a profile arrives. A margin
// of zero would declare no gas at all.
func (c *LoadConfig) GetGasMargin() float64 {
if c.Settings == nil || c.Settings.GasMargin < 1 {
return DefaultSettings().GasMargin
}
return c.Settings.GasMargin
}

// AccountConfig stores the configuration for account generation.
type AccountConfig struct {
NewAccountRate float64 `json:"newAccountRate,omitempty"`
Expand Down
21 changes: 21 additions & 0 deletions config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ type Settings struct {
// coordinated-omission fix), "closed_loop" (default) keeps the legacy
// generate-then-send lockstep as the regression baseline.
ArrivalModel string `json:"arrivalModel,omitempty"`
// GasMargin multiplies what the chain quotes for a call, to give the limit
// room the quote does not carry.
//
// It is a margin on execution, not on calldata: the calldata part is a closed
// form over the exact bytes on the wire and needs none. The quote itself
// already carries about 1.5%, because the node stops its search once the
// bracket is that tight.
//
// Erring high is close to free and erring low is not. Sei fills a block
// against two budgets: one charged at the declared limit and one charged at
// what the transaction spends, and the declared one binds only past four
// times the spend. Below that, margin costs no block space, only the balance
// each in-flight transaction locks. A limit under what a call needs, by
// contrast, lands in a block, burns the whole limit, and reports as sent.
GasMargin float64 `json:"gasMargin,omitempty"`
// MaxInFlight bounds concurrent in-flight sends in the open-loop model;
// txs that would exceed it at their scheduled instant are dropped and
// counted rather than throttling the arrival clock.
Expand All @@ -56,6 +71,9 @@ func (s Settings) Validate() error {
if s.MaxInFlight <= 0 {
return fmt.Errorf("MaxInFlight = %v, want > 0", s.MaxInFlight)
}
if s.GasMargin < 1 {
return fmt.Errorf("GasMargin = %v, want >= 1: a margin below 1 declares less gas than the chain quoted, so every transaction burns its limit", s.GasMargin)
}
return nil
}

Expand All @@ -80,6 +98,7 @@ func DefaultSettings() Settings {
PostSummaryFlushDelay: Duration(25 * time.Second),
ArrivalModel: ArrivalModelClosedLoop,
MaxInFlight: 10_000,
GasMargin: 1.20,
}
}

Expand Down Expand Up @@ -133,6 +152,7 @@ func InitializeViper(cmd *cobra.Command) error {
viper.SetDefault("postSummaryFlushDelay", defaults.PostSummaryFlushDelay.ToDuration())
viper.SetDefault("arrivalModel", defaults.ArrivalModel)
viper.SetDefault("maxInFlight", defaults.MaxInFlight)
viper.SetDefault("gasMargin", defaults.GasMargin)
return nil
}

Expand Down Expand Up @@ -177,5 +197,6 @@ func ResolveSettings() *Settings {
PostSummaryFlushDelay: Duration(viper.GetDuration("postSummaryFlushDelay")),
ArrivalModel: viper.GetString("arrivalModel"),
MaxInFlight: viper.GetInt("maxInFlight"),
GasMargin: viper.GetFloat64("gasMargin"),
}
}
12 changes: 9 additions & 3 deletions config/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ func TestDefaultSettings(t *testing.T) {
PostSummaryFlushDelay: Duration(25 * time.Second),
ArrivalModel: ArrivalModelClosedLoop,
MaxInFlight: 10_000,
GasMargin: 1.20,
}

if defaults != expected {
Expand All @@ -170,22 +171,27 @@ func TestSettingsValidate(t *testing.T) {
}{
{
name: "positive max-in-flight is valid",
settings: Settings{MaxInFlight: 1},
settings: Settings{MaxInFlight: 1, GasMargin: 1},
},
{
name: "default settings are valid",
settings: DefaultSettings(),
},
{
name: "zero max-in-flight is rejected",
settings: Settings{MaxInFlight: 0},
settings: Settings{MaxInFlight: 0, GasMargin: 1},
wantErr: "MaxInFlight = 0, want > 0",
},
{
name: "negative max-in-flight is rejected",
settings: Settings{MaxInFlight: -1},
settings: Settings{MaxInFlight: -1, GasMargin: 1},
wantErr: "MaxInFlight = -1, want > 0",
},
{
name: "a margin below one is rejected",
settings: Settings{MaxInFlight: 1, GasMargin: 0.9},
wantErr: "GasMargin = 0.9, want >= 1",
},
}

for _, tt := range tests {
Expand Down
161 changes: 161 additions & 0 deletions generator/gas.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package generator

import (
"context"
"fmt"
"log"
"time"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/ethclient"

"github.com/sei-protocol/sei-load/generator/scenarios"
"github.com/sei-protocol/sei-load/types"
loadutils "github.com/sei-protocol/sei-load/utils"
)

// gasMeasureTimeout bounds the whole pricing step. ethclient over HTTP sets no
// deadline of its own, so an endpoint that accepts the connection and never
// answers would hold startup open with nothing logged.
const gasMeasureTimeout = 60 * time.Second

// gasEstimateCap is the largest limit a node will estimate. Sei caps
// eth_estimateGas at its simulation gas limit, so a shape needing more than this
// cannot be priced at all and the error names the allowance rather than the
// shape.
const gasEstimateCap = 10_000_000

// measureGasLimits asks the chain what each scenario's calls cost, and stores
// the answer for the run.
//
// It runs after every contract is bound, because a call is priced against the
// deployment the run will actually drive. It runs before funding, because
// pricing needs no funded account: the estimate carries no fee cap, so the node
// does not check the caller's balance.
//
// A failure here stops the run. The alternative is a hard-coded limit, and a
// limit below what a call needs does not fail visibly: the transaction reaches a
// block, burns the whole limit, and is reported as sent. Refusing to start says
// so once, at startup, instead of publishing a throughput number that is a
// fabrication.
func (g *generatorBuilder) measureGasLimits(ctx context.Context, client *ethclient.Client, bindings []*binding) error {
type priced struct {
name string
address common.Address
price scenarios.GasEstimateCaller
}
var work []priced
for _, b := range bindings {
for _, instance := range b.instances {
if price := instance.Scenario.GasEstimateCaller(); price != nil {
work = append(work, priced{instance.Name, b.address, price})
}
}
}
// A profile of native transfers alone prices nothing, so it reads no header
// and issues no estimate. Startup costs what the profile asks for.
if len(work) == 0 {
return nil
}

return loadutils.WithinBudget(ctx, gasMeasureTimeout, "gas measurement", func(ctx context.Context) error {
blockGasLimit, err := blockGasLimit(ctx, client)
if err != nil {
return err
}
margin := g.config.GetGasMargin()
for _, w := range work {
estimate := gasEstimator(client, w.address, w.name, margin, blockGasLimit)
if err := w.price(ctx, estimate); err != nil {
return fmt.Errorf("price %s: %w", w.name, err)
}
}
return nil
})
Comment thread
cursor[bot] marked this conversation as resolved.
}

// gasEstimator returns the estimator one scenario's calls are priced through.
func gasEstimator(client *ethclient.Client, address common.Address, name string,
margin float64, blockGasLimit uint64) scenarios.GasEstimator {
return func(ctx context.Context, call scenarios.GasEstimateCall) (scenarios.GasModel, error) {
// The three fee fields stay unset. A call carrying one makes the node
// check the caller's balance, and this caller has none by design: it is a
// fresh address chosen so every slot the call writes is still zero.
required, err := client.EstimateGas(ctx, ethereum.CallMsg{
From: types.NewAccount(false).Address,
To: &address,
Data: call.Data,
Value: call.Value,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unfunded estimate From rejects payable calls

High Severity

The estimate now forwards call.Value while From is still a freshly generated address with a zero balance. Nodes check balance >= value even when fee fields are unset, so a payable probe can fail with insufficient funds. Disperse is the scenario that sets Value, so a profile that names it can refuse to start — the same fail-closed outcome this field was added to avoid.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 511fc8f. Configure here.

})
Comment thread
cursor[bot] marked this conversation as resolved.
if err != nil {
return scenarios.GasModel{}, err
}

intrinsic, err := core.IntrinsicGas(call.Data, nil, nil, false, true, true, true)
if err != nil {
return scenarios.GasModel{}, fmt.Errorf("intrinsic gas: %w", err)
}
if required <= intrinsic {
return scenarios.GasModel{}, fmt.Errorf(
"the chain quoted %d, at or under this call's own intrinsic cost of %d", required, intrinsic)
}

model := scenarios.GasModel{Exec: required - intrinsic, Margin: margin}
limit, err := model.Limit(call.Data)
if err != nil {
return scenarios.GasModel{}, err
}
// Against the priced call's own bytes. A scenario whose calldata varies
// recomposes per transaction and can exceed this without the check seeing
// it: StorageRW at its largest pad wants several times what its empty-pad
// probe does. Covering that needs the size distribution's maximum visible
// here, which it is not.
if limit > blockGasLimit {
return scenarios.GasModel{}, fmt.Errorf(
"needs %d gas for the call priced here, past the %d this run will admit, so no limit carries it",
limit, blockGasLimit)
}
log.Printf("⛽ %s/%s: chain quoted %d, limit %d (margin %.2f)", name, call.Operation, required, limit, margin)
return model, nil
}
}

// blockGasLimit reads what one block admits, so a priced call that cannot fit in
// any block fails at startup rather than on every send.
func blockGasLimit(ctx context.Context, client *ethclient.Client) (uint64, error) {
header, err := client.HeaderByNumber(ctx, nil)
if err != nil {
return 0, fmt.Errorf("read the latest header for the block gas limit: %w", err)
}
// The smaller of what a block admits and what a node will estimate, so the
// error above names the bound that actually applies.
return min(header.GasLimit, gasEstimateCap), nil
}

// mockGasLimitExec is what a dry run reports as the execution term. A dry run
// reaches no chain, so nothing here is a measurement. One shared value keeps
// that obvious: a per-scenario number would read like one.
const mockGasLimitExec = 200_000

// mockGasLimits gives every scenario a limit without asking a chain, so a dry
// run can preview a profile. It is not a measurement and the log says so.
func (g *generatorBuilder) mockGasLimits(ctx context.Context, bindings []*binding) error {
log.Printf("⛽ dry run: gas limits are not measured")
for _, b := range bindings {
for _, instance := range b.instances {
price := instance.Scenario.GasEstimateCaller()
if price == nil {
continue
}
estimate := func(context.Context, scenarios.GasEstimateCall) (scenarios.GasModel, error) {
return scenarios.GasModel{Exec: mockGasLimitExec, Margin: g.config.GetGasMargin()}, nil
}
if err := price(ctx, estimate); err != nil {
return fmt.Errorf("price %s: %w", instance.Name, err)
}
}
}
return nil
}
38 changes: 37 additions & 1 deletion generator/mockchain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,44 @@ func (m *mockChain) codeReads() []common.Address {
return reads
}

// mockQuotedGas is what the chain quotes for every call. It is well under
// mockBlockGasLimit, so the startup guard that rejects a call too large for any
// block does not fire on a shape a test never meant to be oversized.
const mockQuotedGas = 200_000

// mockBlockGasLimit is what one block admits. Gas sizing reads it to reject a
// call no block could carry.
const mockBlockGasLimit = 12_500_000

func (m *mockChain) EstimateGas(_ context.Context, _ json.RawMessage, _ *rpc.BlockNumberOrHash) (hexutil.Uint64, error) {
return hexutil.Uint64(1_000_000), nil
return hexutil.Uint64(mockQuotedGas), nil
}

// GetBlockByNumber serves a header carrying the block gas limit. Gas sizing
// reads it once at startup.
func (m *mockChain) GetBlockByNumber(_ context.Context, _ rpc.BlockNumber, _ bool) (map[string]any, error) {
return map[string]any{
"number": hexutil.Uint64(1),
"hash": common.Hash{},
"parentHash": common.Hash{},
"sha3Uncles": common.Hash{},
"stateRoot": common.Hash{},
"transactionsRoot": common.Hash{},
"receiptsRoot": common.Hash{},
"logsBloom": hexutil.Bytes(make([]byte, 256)),
"difficulty": (*hexutil.Big)(big.NewInt(0)),
"gasLimit": hexutil.Uint64(mockBlockGasLimit),
"gasUsed": hexutil.Uint64(0),
"timestamp": hexutil.Uint64(1),
"extraData": hexutil.Bytes{},
"miner": common.Address{},
"nonce": ethtypes.BlockNonce{},
"mixHash": common.Hash{},
"size": hexutil.Uint64(0),
"totalDifficulty": (*hexutil.Big)(big.NewInt(0)),
"transactions": []common.Hash{},
"uncles": []common.Hash{},
}, nil
}

// txCount returns how many transactions the chain has accepted.
Expand Down
12 changes: 10 additions & 2 deletions generator/prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,12 @@ func (g *generatorBuilder) prepareAll(ctx context.Context, deployer types.Accoun
if err := g.bindAll(client, bindings); err != nil {
return err
}
return g.recordDeployments(bindings, reg)
if err := g.recordDeployments(bindings, reg); err != nil {
return err
}
// Last, so a pricing failure does not discard the record of deployments this
// run already paid for.
return g.measureGasLimits(ctx, client, bindings)
}

// planAll decides one address per contract the profile drives. It deploys
Expand Down Expand Up @@ -379,7 +384,10 @@ func (g *generatorBuilder) mockPrepareAll() error {
for _, b := range bindings {
b.address = types.NewAccount(false).Address
}
return g.bindAll(nil, bindings)
if err := g.bindAll(nil, bindings); err != nil {
return err
}
return g.mockGasLimits(context.Background(), bindings)
}

// recordDeployments writes a chain file describing this chain, for an operator to
Expand Down
Loading
Loading