Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
33d1584
fix(scenarios): size the ERC721 mint limit from a measurement
bdchatham Aug 28, 2026
6c7e41d
feat(scenarios): add an AMM swap scenario
bdchatham Aug 28, 2026
137675a
feat(health): serve healthz and readyz
bdchatham Aug 28, 2026
7ce00e6
feat(gas): ask the chain what a call costs instead of hard-coding it
bdchatham Aug 29, 2026
60d63a0
feat(fee): take the fee cap from the chain, not from three constants
bdchatham Aug 29, 2026
d51fb14
fix(gas): resolve limits at pricing time, and carry the value a call …
bdchatham Aug 29, 2026
26c1a64
docs(registry): commit arctic-1 alongside pacific-1 and atlantic-2
bdchatham Aug 29, 2026
a773910
feat(registry): record arctic-1's deployed contracts
bdchatham Aug 29, 2026
b0e54d4
fix(scenarios): cover read's peak with a constant, not with a tunable
bdchatham Aug 29, 2026
6ce1027
Merge main after #72 landed as a squash
bdchatham Aug 29, 2026
fa93aa7
Drop the inline NotReady the merge kept alongside the deferred one
bdchatham Aug 29, 2026
010bd04
fix(gas): make a priced call cost at least what its transactions cost
bdchatham Aug 29, 2026
511fc8f
fix(gas): assert the limit a transaction carries, not the calldata be…
bdchatham Aug 29, 2026
6f0233d
fix(gas): fund the caller of a priced call that carries a value
bdchatham Aug 29, 2026
ee754ed
Collapse the stack into one pull request
bdchatham Aug 29, 2026
f88fea4
fix(generator): do not read a hung gas quote as a clean shutdown
bdchatham Aug 29, 2026
47202fa
fix(scenarios): take the resolved fee cap on every raw transfer path
bdchatham Aug 29, 2026
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
46 changes: 46 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ type LoadConfig struct {
Funding *FundingConfig `json:"funding,omitempty"`
// Path to write a JSON report of the load test.
ReportPath string `json:"reportPath,omitempty"`
// gasFeeCapWei is the fee cap every transaction this run sends declares,
// resolved once from the chain at startup.
//
// It is deliberately not a profile field. A fee cap written down is a number
// that goes stale the next time the chain reprices, and a cap under the base
// fee is rejected before the transaction reaches the EVM. Sei's live networks
// have moved past two of the three caps this repo used to hard-code.
gasFeeCapWei *big.Int
// Seed roots the PRNG behind every workload draw: key and size
// distributions, gas pickers, operation mixes, and account selection. The
// same seed and the same config reproduce the same draw sequence.
Expand Down Expand Up @@ -159,6 +167,44 @@ func (c *LoadConfig) GetChainID() *big.Int {
return big.NewInt(c.ChainID)
}

// SetGasFeeCap records the cap resolved from the chain. The preparation step
// calls it once, before anything is signed.
func (c *LoadConfig) SetGasFeeCap(wei *big.Int) {
c.gasFeeCapWei = new(big.Int).Set(wei)
}

// GetGasFeeCap returns the cap resolved from the chain, and whether one was
// resolved.
//
// It reports absence rather than a default, because a default is what the three
// hard-coded caps were. A caller that cannot proceed without one says so.
func (c *LoadConfig) GetGasFeeCap() (*big.Int, bool) {
if c.gasFeeCapWei == nil {
return nil, false
}
return new(big.Int).Set(c.gasFeeCapWei), true
}

// GetGasFeeCapMultiplier returns what to scale the chain's reported gas price by.
func (c *LoadConfig) GetGasFeeCapMultiplier() float64 {
if c.Settings == nil || c.Settings.GasFeeCapMultiplier < 1 {
return DefaultSettings().GasFeeCapMultiplier
}
return c.Settings.GasFeeCapMultiplier
}

// 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
40 changes: 40 additions & 0 deletions config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,34 @@ 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"`
// GasFeeCapMultiplier scales what the chain reports gas costs into the fee
// cap every transaction declares.
//
// The cap is a ceiling, not a price: a transaction pays the base fee and the
// cap only says how high it will follow one. So a generous multiple costs
// nothing per transaction. What it does cost is balance, because the chain
// locks the cap times the gas limit while a transaction is in flight.
//
// It needs to be generous because the base fee moves. Sei raises it by up to
// about 1.9% per block while blocks are full, which is the state a load run
// is trying to produce, and a transaction whose cap the base fee has passed
// is rejected before it reaches the EVM.
GasFeeCapMultiplier float64 `json:"gasFeeCapMultiplier,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 +84,12 @@ func (s Settings) Validate() error {
if s.MaxInFlight <= 0 {
return fmt.Errorf("MaxInFlight = %v, want > 0", s.MaxInFlight)
}
if s.GasFeeCapMultiplier < 1 {
return fmt.Errorf("GasFeeCapMultiplier = %v, want >= 1: a cap under what the chain charges is rejected before the transaction reaches the EVM", s.GasFeeCapMultiplier)
}
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 +114,8 @@ func DefaultSettings() Settings {
PostSummaryFlushDelay: Duration(25 * time.Second),
ArrivalModel: ArrivalModelClosedLoop,
MaxInFlight: 10_000,
GasMargin: 1.20,
GasFeeCapMultiplier: 5,
}
}

Expand Down Expand Up @@ -133,6 +169,8 @@ 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)
viper.SetDefault("gasFeeCapMultiplier", defaults.GasFeeCapMultiplier)
return nil
}

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

if defaults != expected {
Expand All @@ -170,22 +172,32 @@ func TestSettingsValidate(t *testing.T) {
}{
{
name: "positive max-in-flight is valid",
settings: Settings{MaxInFlight: 1},
settings: Settings{MaxInFlight: 1, GasMargin: 1, GasFeeCapMultiplier: 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, GasFeeCapMultiplier: 1},
wantErr: "MaxInFlight = 0, want > 0",
},
{
name: "negative max-in-flight is rejected",
settings: Settings{MaxInFlight: -1},
settings: Settings{MaxInFlight: -1, GasMargin: 1, GasFeeCapMultiplier: 1},
wantErr: "MaxInFlight = -1, want > 0",
},
{
name: "a fee cap multiplier below one is rejected",
settings: Settings{MaxInFlight: 1, GasMargin: 1, GasFeeCapMultiplier: 0.5},
wantErr: "GasFeeCapMultiplier = 0.5, want >= 1",
},
{
name: "a margin below one is rejected",
settings: Settings{MaxInFlight: 1, GasMargin: 0.9, GasFeeCapMultiplier: 1},
wantErr: "GasMargin = 0.9, want >= 1",
},
}

for _, tt := range tests {
Expand Down
8 changes: 6 additions & 2 deletions funder/funder.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,12 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, root types.Accoun
return fmt.Errorf("funder: transactor: %w", err)
}
auth.Context = ctx
auth.GasTipCap = big.NewInt(1_000_000_000) // 1 gwei (chain min fee)
auth.GasFeeCap = big.NewInt(100_000_000_000) // 100 gwei
feeCap, ok := cfg.GetGasFeeCap()
if !ok {
return fmt.Errorf("funder: no fee cap resolved from the chain")
}
auth.GasTipCap = big.NewInt(1_000_000_000) // 1 gwei (chain min fee)
auth.GasFeeCap = feeCap

disperse, err := deployDisperse(ctx, client, auth)
if err != nil {
Expand Down
62 changes: 62 additions & 0 deletions generator/fee.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package generator

import (
"context"
"fmt"
"log"
"math/big"
"time"

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

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

// feeCapTimeout bounds the one call that resolves the fee cap. ethclient over
// HTTP sets no deadline of its own.
const feeCapTimeout = 30 * time.Second

// resolveGasFeeCap asks the chain what gas costs and records the cap every
// transaction this run declares.
//
// It runs before anything is signed, because a contract deployment carries a cap
// too and a deployment priced under the base fee is rejected the same way a load
// transaction is.
//
// The alternative was a constant, and this repo carried three of them: 20 gwei
// for a contract call, 100 for a deployment, 200 for a native transfer. Sei's
// live base fee is 50, so one of the three was already rejecting every
// transaction it priced and the other two were guesses that happened to clear.
func (g *generatorBuilder) resolveGasFeeCap(ctx context.Context, client *ethclient.Client) error {
return loadutils.WithinBudget(ctx, feeCapTimeout, "fee cap", func(ctx context.Context) error {
suggested, err := client.SuggestGasPrice(ctx)
if err != nil {
return fmt.Errorf("ask the chain what gas costs: %w", err)
}
if suggested.Sign() <= 0 {
return fmt.Errorf("the chain reported a gas price of %s, so no cap can be derived from it", suggested)
}
cap := scaleWei(suggested, g.config.GetGasFeeCapMultiplier())
Comment thread
seidroid[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Still open from the previous round: the cap is derived from the chain but the tip stayed a constant. utils.GasTipCapWei is 2 gwei (generator/utils/utils.go:37) and EVMTransfer.go:68 / EVMTransferNoop.go both declare it, while the funder declares 1 gwei. Nothing ties either to the value computed here, so if eth_gasPrice reports below ~0.4 gwei — a local dev node, or a chain configured with a lower min fee — suggested * 5 lands under the tip and every transaction is structurally invalid with max priority fee per gas higher than max fee per gas. That is a whole-run failure derived from a perfectly valid chain reading, and the Sign() <= 0 guard above does not catch it because the price is positive. The old constants (20/100/200 gwei) were all far above the tip and could not hit this. A cap = max(cap, gasTipCapWei) clamp here, or deriving the tip from the same read, closes it.

g.config.SetGasFeeCap(cap)
log.Printf("⛽ gas price %s wei, fee cap %s wei (x%.1f)", suggested, cap, g.config.GetGasFeeCapMultiplier())
return nil
})
}

// scaleWei multiplies a wei amount by a fractional factor without leaving the
// integer domain, so a large price cannot lose precision through float64.
func scaleWei(wei *big.Int, factor float64) *big.Int {
const scale = 1000
num := big.NewInt(int64(factor * scale))
out := new(big.Int).Mul(wei, num)
return out.Div(out, big.NewInt(scale))
}

// mockGasFeeCap records a placeholder cap for a run that reaches no chain.
func (g *generatorBuilder) mockGasFeeCap() {
g.config.SetGasFeeCap(big.NewInt(mockGasFeeCapWei))
}

// mockGasFeeCapWei is what a dry run declares. A dry run sends nothing, so this
// is a placeholder rather than a measurement.
const mockGasFeeCapWei = 100_000_000_000
70 changes: 70 additions & 0 deletions generator/fee_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package generator

import (
"math"
"math/big"
"testing"

"github.com/stretchr/testify/require"
)

// seiBaseFeeGrowthPerBlock is the most Sei raises the base fee in one block
// while blocks are full, which is the state a load run exists to produce.
const seiBaseFeeGrowthPerBlock = 1.019

// blocksOfHeadroom returns how many consecutive full blocks the base fee can
// climb through before it passes cap.
func blocksOfHeadroom(cap *big.Int, baseFee int64) float64 {
ratio, _ := new(big.Float).Quo(new(big.Float).SetInt(cap), big.NewFloat(float64(baseFee))).Float64()
return math.Log(ratio) / math.Log(seiBaseFeeGrowthPerBlock)
}

// TestTheFeeCapSurvivesBaseFeeDrift is the property the whole change exists for.
//
// A cap under the base fee is rejected by the fee ante before the transaction
// reaches the EVM, after the nonce is consumed, so it produces a failed receipt
// rather than no receipt at all. Clearing the base fee at the instant of the
// read is not enough: the run then fills blocks, which is what makes the base
// fee climb, so the cap has to clear it by enough to outlive the climb.
//
// The fixtures are what the three live networks reported: arctic-1 at 10 gwei
// base and 11 suggested, pacific-1 and atlantic-2 at 50 and 55. The constant
// this change removed declared 20 gwei, which cleared the first and not the
// other two.
func TestTheFeeCapSurvivesBaseFeeDrift(t *testing.T) {
// Enough blocks that a run notices the climb and can be restarted, rather
// than starting to fail seconds after it reaches full blocks.
const wantBlocks = 30

for _, tc := range []struct {
name string
baseFee int64
suggested int64
}{
{"arctic-1", 10_000_000_000, 11_000_000_000},
{"pacific-1", 50_000_000_000, 55_000_000_000},
{"atlantic-2", 50_000_000_000, 55_000_000_000},
} {
t.Run(tc.name, func(t *testing.T) {
cap := scaleWei(big.NewInt(tc.suggested), 5)
require.Positive(t, cap.Cmp(big.NewInt(tc.baseFee)),
"the cap is at or under the base fee, so the ante rejects every transaction before it runs")
require.GreaterOrEqual(t, blocksOfHeadroom(cap, tc.baseFee), float64(wantBlocks),
"the cap clears the base fee by only %.0f blocks of growth, so it lapses shortly after the run fills blocks",
blocksOfHeadroom(cap, tc.baseFee))
})
}
}

// TestScalingKeepsPrecisionAtChainScale guards the arithmetic. A price is wei,
// which passes what a float64 holds exactly, so scaling through one would move
// the cap by an amount nothing else in the run would explain.
func TestScalingKeepsPrecisionAtChainScale(t *testing.T) {
huge, ok := new(big.Int).SetString("123456789012345678901234567890", 10)
require.True(t, ok)

require.Equal(t, "246913578024691357802469135780", scaleWei(huge, 2).String(),
"doubling a chain-scale price did not double it, so the cap is derived through a lossy conversion")
require.Equal(t, "55000000000", scaleWei(big.NewInt(11_000_000_000), 5).String())
require.Equal(t, "16500000000", scaleWei(big.NewInt(11_000_000_000), 1.5).String())
}
41 changes: 41 additions & 0 deletions generator/fee_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package generator_test

import (
"math/big"
"testing"

"github.com/stretchr/testify/require"

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

// TestStartupResolvesTheFeeCapFromTheChain drives the real startup path against
// a chain and asserts the cap it came away with.
//
// The arithmetic has its own test. This one asserts startup actually applies it:
// a resolver that read the price and forgot to scale it would leave the run
// declaring what the chain charges right now, with no room for the base fee to
// climb once the run starts filling blocks.
func TestStartupResolvesTheFeeCapFromTheChain(t *testing.T) {
chain := newMockChain(t, mockChainConfig{})
cfg := &config.LoadConfig{
ChainID: 7777,
Endpoints: []string{chain.url},
Accounts: &config.AccountConfig{Accounts: 2},
Scenarios: []config.Scenario{{Name: scenarios.ERC20, Weight: 1}},
Settings: &config.Settings{GasFeeCapMultiplier: 5, GasMargin: 1.2, MaxInFlight: 10},
}

_, err := generator.NewGenerator(t.Context(), newTestRng(1), cfg, types.NewAccount(false))
require.NoError(t, err)

cap, ok := cfg.GetGasFeeCap()
require.True(t, ok, "startup finished without resolving a fee cap, so every transaction is priced by nothing")

want := new(big.Int).Mul(big.NewInt(mockGasPriceWei), big.NewInt(5))
require.Equal(t, want.String(), cap.String(),
"the cap is not the chain's price scaled by the configured multiplier, so it carries no room for the base fee to climb")
}
Loading
Loading