diff --git a/CLAUDE.md b/CLAUDE.md index 61573b8fc..2fe87e382 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -317,6 +317,152 @@ go func() { }() ``` +## Additional Patterns & Lessons Learned + +### Random Variation Formula +When implementing random variation within a percentage range: + +```go +// ✅ DO: Correct uniform distribution +variationMin := 1.0 - variation // e.g., 0.7 for ±30% +variationMax := 1.0 + variation // e.g., 1.3 for ±30% +factor := variationMin + rand.Float64() * (variationMax - variationMin) +// Result: uniformly distributed between 0.7 and 1.3 + +// ❌ DON'T: Incorrect - produces wrong range +factor := variationMin + rand.Float64() * variationMax +// Result: distributed between 0.7 and 2.0 (not ±30%!) +``` + +### Type Conversion Safety (big.Int and int64) +When converting between types with different ranges: + +```go +// ✅ DO: Check bounds before conversion +numBlocks := len(blocks) +if numBlocks == 0 { + avgGasUsed = 0 +} else if numBlocks > math.MaxInt64 { + log.Warn().Msg("value exceeds int64 max") + avgGasUsed = 0 +} else { + result := new(big.Int).Div(total, big.NewInt(int64(numBlocks))) + if result.IsUint64() { + avgGasUsed = result.Uint64() + } else { + avgGasUsed = math.MaxUint64 + log.Warn().Msg("result exceeds uint64 max") + } +} + +// ❌ DON'T: Blindly convert without checking +avgGasUsed = new(big.Int).Div(total, big.NewInt(int64(len(blocks)))).Uint64() +// Can panic if len(blocks) > MaxInt64 or result > MaxUint64 +``` + +### Blocking Operations Must Accept Context +Any operation that can block must accept `context.Context` for cancellation: + +```go +// ✅ DO: Accept context, use ticker, handle cancellation +func (v *Vault) SpendOrWait(ctx context.Context, amount uint64) error { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + if v.trySpend(amount) { + return nil + } + select { + case <-ticker.C: + continue + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// ❌ DON'T: Use bare time.Sleep in infinite loop +func (v *Vault) SpendOrWait(amount uint64) { + for { + if v.trySpend(amount) { + break + } + time.Sleep(100 * time.Millisecond) // Cannot be cancelled! + } +} +``` + +### Constructor Validation +Validate inputs in constructors and return errors: + +```go +// ✅ DO: Validate and return error +func NewDynamicPricer(config Config) (*DynamicPricer, error) { + if len(config.Prices) == 0 { + return nil, fmt.Errorf("config.Prices cannot be empty") + } + return &DynamicPricer{config: config}, nil +} + +// ❌ DON'T: Panic on invalid input or allow invalid state +func NewDynamicPricer(config Config) *DynamicPricer { + // Will panic on first use if Prices is empty + return &DynamicPricer{config: config} +} +``` + +### Documentation Must Match Implementation +Keep documentation (especially README files) synchronized with code: + +**Critical to document:** +- Function signatures including context parameters and return types +- Error return conditions (not just success path) +- Async/timing behavior (e.g., "vault has zero budget until first block header received") +- Cancellation behavior + +**Example:** +```markdown + +- **`SpendOrWaitAvailableBudget(context.Context, uint64) error`**: + Attempts to spend gas; blocks if insufficient budget is available or until context is cancelled. + Returns nil on success, ctx.Err() if cancelled. + + +- **`SpendOrWaitAvailableBudget(uint64)`**: + Attempts to spend gas; blocks if insufficient budget is available. +``` + +### Test Coverage Requirements +When adding new components, always include comprehensive unit tests: + +**Required test categories:** +- Basic functionality (creation, getters, setters) +- Edge cases (zero values, nil inputs, overflow, division by zero) +- Error conditions (invalid configs, failed operations) +- Concurrency (multiple goroutines, race conditions) +- Context cancellation (immediate cancel, timeout, graceful shutdown) +- Blocking/waiting behavior (wait then succeed, wait then timeout) + +**Pattern for concurrent tests:** +```go +func TestConcurrentAccess(t *testing.T) { + component := NewComponent() + const numGoroutines = 100 + var wg sync.WaitGroup + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // Perform concurrent operations + }() + } + wg.Wait() + // Verify final state +} +``` + ## Code Style ### Cobra Flags diff --git a/README.md b/README.md index 98513c49c..7a1645365 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ Note: Do not modify this section! It is auto-generated by `cobra` using `make ge - [polycli parseethwallet](doc/polycli_parseethwallet.md) - Extract the private key from an eth wallet. +- [polycli plot](doc/polycli_plot.md) - Plot a chart of transaction gas prices and limits. + - [polycli publish](doc/polycli_publish.md) - Publish transactions to the network with high-throughput. - [polycli report](doc/polycli_report.md) - Generate a report analyzing a range of blocks from an Ethereum-compatible blockchain. diff --git a/cmd/loadtest/cmd.go b/cmd/loadtest/cmd.go index cfa30da52..7968b5e5b 100644 --- a/cmd/loadtest/cmd.go +++ b/cmd/loadtest/cmd.go @@ -34,6 +34,9 @@ var cfg = &config.Config{ // uniswapCfg holds UniswapV3-specific configuration. var uniswapCfg = &config.UniswapV3Config{} +// gasManagerCfg holds gas manager configuration. +var gasManagerCfg = &config.GasManagerConfig{} + // LoadtestCmd represents the loadtest command. var LoadtestCmd = &cobra.Command{ Use: "loadtest", @@ -55,6 +58,8 @@ var LoadtestCmd = &cobra.Command{ return cfg.Validate() }, RunE: func(cmd *cobra.Command, args []string) error { + // Attach gas manager config. + cfg.GasManager = gasManagerCfg return loadtest.Run(cmd.Context(), cfg) }, } @@ -69,9 +74,10 @@ var uniswapv3Cmd = &cobra.Command{ return uniswapCfg.Validate() }, RunE: func(cmd *cobra.Command, args []string) error { - // Override mode to uniswapv3 and attach UniswapV3 config. + // Override mode to uniswapv3 and attach configs. cfg.Modes = []string{"v3"} cfg.UniswapV3 = uniswapCfg + cfg.GasManager = gasManagerCfg return loadtest.Run(cmd.Context(), cfg) }, @@ -115,6 +121,24 @@ func initPersistentFlags() { pf.BoolVar(&cfg.LegacyTxMode, "legacy", false, "send a legacy transaction instead of an EIP1559 transaction") pf.BoolVar(&cfg.FireAndForget, "fire-and-forget", false, "send transactions and load without waiting for it to be mined") pf.BoolVar(&cfg.FireAndForget, "send-only", false, "alias for --fire-and-forget") + + initGasManagerFlags() +} + +func initGasManagerFlags() { + pf := LoadtestCmd.PersistentFlags() + + // Oscillation wave + pf.StringVar(&gasManagerCfg.OscillationWave, "gas-manager-oscillation-wave", "flat", "type of oscillation wave (flat | sine | square | triangle | sawtooth)") + pf.Uint64Var(&gasManagerCfg.Target, "gas-manager-target", 30_000_000, "target gas limit for oscillation wave") + pf.Uint64Var(&gasManagerCfg.Period, "gas-manager-period", 1, "period in blocks for oscillation wave") + pf.Uint64Var(&gasManagerCfg.Amplitude, "gas-manager-amplitude", 0, "amplitude for oscillation wave") + + // Pricing strategy + pf.StringVar(&gasManagerCfg.PriceStrategy, "gas-manager-price-strategy", "estimated", "gas price strategy (estimated | fixed | dynamic)") + pf.Uint64Var(&gasManagerCfg.FixedGasPriceWei, "gas-manager-fixed-gas-price-wei", 300000000, "fixed gas price in wei") + pf.StringVar(&gasManagerCfg.DynamicGasPricesWei, "gas-manager-dynamic-gas-prices-wei", "0,1000000,0,10000000,0,100000000", "comma-separated gas prices in wei for dynamic strategy") + pf.Float64Var(&gasManagerCfg.DynamicGasPricesVariation, "gas-manager-dynamic-gas-prices-variation", 0.3, "variation percentage for dynamic strategy") } func initFlags() { diff --git a/cmd/loadtest/loadtestUsage.md b/cmd/loadtest/loadtestUsage.md index 71374922f..d8c190333 100644 --- a/cmd/loadtest/loadtestUsage.md +++ b/cmd/loadtest/loadtestUsage.md @@ -52,6 +52,24 @@ Here is a simple example that runs 1000 requests at a max rate of 1 request per $ polycli loadtest --verbosity 700 --chain-id 1256 --concurrency 1 --requests 1000 --rate-limit 1 --mode t --rpc-url http://localhost:8888 ``` +### Gas Manager + +The loadtest command includes a gas manager for controlling transaction gas limits and pricing. Use the `--gas-manager-*` flags to: + +- **Oscillate gas limits** with wave patterns (flat, sine, square, triangle, sawtooth) +- **Control gas pricing** with strategies (estimated, fixed, dynamic) + +Example with sine wave oscillation: +```bash +$ polycli loadtest --rpc-url http://localhost:8545 \ + --gas-manager-oscillation-wave sine \ + --gas-manager-target 20000000 \ + --gas-manager-amplitude 10000000 \ + --gas-manager-period 100 +``` + +See [Gas Manager README](../../loadtest/gasmanager/README.md) for detailed documentation. + ### Load Test Contract The codebase has a contract that used for load testing. It's written in Solidity. The workflow for modifying this contract is. diff --git a/cmd/plot/chart.go b/cmd/plot/chart.go new file mode 100644 index 000000000..db3f7aae8 --- /dev/null +++ b/cmd/plot/chart.go @@ -0,0 +1,406 @@ +package plot + +import ( + "fmt" + "image/color" + "math" + "slices" + "strings" + + "github.com/dustin/go-humanize" + "github.com/rs/zerolog/log" + "gonum.org/v1/plot" + "gonum.org/v1/plot/font" + "gonum.org/v1/plot/font/liberation" + "gonum.org/v1/plot/plotter" + "gonum.org/v1/plot/vg" + "gonum.org/v1/plot/vg/draw" +) + +func init() { + // Use Liberation Sans (sans-serif) instead of the default Liberation Serif + plot.DefaultFont = font.Font{Typeface: "Liberation", Variant: "Sans"} + plotter.DefaultFont = font.Font{Typeface: "Liberation", Variant: "Sans"} + + // Register the Liberation font collection + font.DefaultCache.Add(liberation.Collection()) +} + +var ( + lineThickness = vg.Points(2) + + gasBlockLimitLineColor = color.NRGBA{130, 38, 89, 220} + gasTxsLimitLineColor = color.NRGBA{255, 0, 189, 220} + gasUsedLineColor = color.NRGBA{0, 255, 133, 220} + avgGasUsedLineColor = color.NRGBA{255, 193, 7, 220} + avgGasPriceLineColor = color.NRGBA{30, 144, 255, 220} + + txDotsColor = color.NRGBA{0, 0, 0, 25} + txDotsSizes = []vg.Length{ + vg.Points(3), // gasLimit <= 1M + vg.Points(4), // gasLimit <= 2M + vg.Points(5), // gasLimit <= 3M + vg.Points(6), // gasLimit <= 4M + vg.Points(7), // gasLimit <= 5M + vg.Points(8), // gasLimit > 5M + } + + targetTxDotsThickness = vg.Points(2) + targetTxDotsSize = vg.Points(8) + targetTxDotsColor = color.NRGBA{255, 0, 0, 255} +) + +// txGasChartMetadata holds metadata for generating the transaction gas chart. +type txGasChartMetadata struct { + rpcURL string + chainID uint64 + + targetAddr string + startBlock uint64 + endBlock uint64 + + blocksMetadata blocksMetadata + + scale string + + outputPath string +} + +// plotChart generates and saves the transaction gas chart based on the provided metadata. +func plotChart(metadata txGasChartMetadata) error { + p := plot.New() + createHeader(p, metadata) + createTxsDots(p, metadata) + createLines(p, metadata) + + p.X.Min = float64(metadata.startBlock) + p.X.Max = float64(metadata.endBlock) + (float64(metadata.endBlock-metadata.startBlock) * 0.02) + + // Protect min and max for logarithmic scale + if metadata.blocksMetadata.minTxGasPrice == 0 { + metadata.blocksMetadata.minTxGasPrice = 1 + } + if metadata.blocksMetadata.maxTxGasPrice == 0 { + metadata.blocksMetadata.maxTxGasPrice = 1 + } + + p.Y.Min = float64(metadata.blocksMetadata.minTxGasPrice) + p.Y.Max = float64(metadata.blocksMetadata.maxTxGasPrice) * 1.02 + + return save(p, metadata) +} + +// createHeader sets the title and header information for the plot. +func createHeader(p *plot.Plot, metadata txGasChartMetadata) { + p.Title.TextStyle.Font.Size = vg.Points(14) + scale := "logarithmic" + if !strings.EqualFold(metadata.scale, "log") { + scale = "linear" + } + + title := fmt.Sprintf("ChainID: %d | Blocks %d - %d (%d) | Txs: %d | Scale: %s", + metadata.chainID, metadata.startBlock, metadata.endBlock, + metadata.endBlock-metadata.startBlock, metadata.blocksMetadata.txCount, scale) + if len(metadata.targetAddr) > 0 { + title += fmt.Sprintf("\nTarget: %s (%d txs)", metadata.targetAddr, metadata.blocksMetadata.targetTxCount) + } + + p.Title.Text = title + + // Configure legend + p.Legend.Top = true + p.Legend.Left = true + p.Legend.TextStyle.Font.Size = vg.Points(10) +} + +// createTxsDots creates scatter plots for transaction gas prices. +func createTxsDots(p *plot.Plot, metadata txGasChartMetadata) { + p.X.Label.Text = "Block Number" + + // Custom ticker for X axis (block numbers must be integers) + p.X.Tick.Marker = plot.TickerFunc(func(min, max float64) []plot.Tick { + ticks := plot.DefaultTicks{}.Ticks(min, max) + for i := range ticks { + if ticks[i].Label == "" { + continue + } + // Format as integer without decimal places + ticks[i].Label = humanize.Comma(int64(math.Round(ticks[i].Value))) + } + return ticks + }) + + if strings.EqualFold(metadata.scale, "log") { + p.Y.Scale = plot.LogScale{} + p.Y.Label.Text = "Gas Price (wei, log)" + + // Custom ticker for logarithmic scale + p.Y.Tick.Marker = plot.TickerFunc(func(min, max float64) []plot.Tick { + // Protect against values <= 0 + if min <= 0 { + min = 1 + } + if max <= 0 { + max = 1 + } + + ticks := plot.LogTicks{}.Ticks(min, max) + for i := range ticks { + if ticks[i].Label == "" { + continue + } + ticks[i].Label = formatSI(ticks[i].Value) + } + return ticks + }) + } else { + p.Y.Scale = plot.LinearScale{} + p.Y.Label.Text = "Gas Price (wei, linear)" + + // Custom ticker for linear scale + p.Y.Tick.Marker = plot.TickerFunc(func(min, max float64) []plot.Tick { + ticks := plot.DefaultTicks{}.Ticks(min, max) + for i := range ticks { + if ticks[i].Label == "" { + continue + } + ticks[i].Label = formatSI(ticks[i].Value) + } + return ticks + }) + } + + txGroups := make(map[uint64]plotter.XYs) + txGroups[0] = make(plotter.XYs, 0) // target txs + txGroups[1] = make(plotter.XYs, 0) // gasLimit <= 1,000,000 + txGroups[2] = make(plotter.XYs, 0) // gasLimit <= 2,000,000 + txGroups[3] = make(plotter.XYs, 0) // gasLimit <= 3,000,000 + txGroups[4] = make(plotter.XYs, 0) // gasLimit <= 4,000,000 + txGroups[5] = make(plotter.XYs, 0) // gasLimit <= 5,000,000 + txGroups[6] = make(plotter.XYs, 0) // gasLimit > 5,000,000 + + for _, b := range metadata.blocksMetadata.blocks { + for _, t := range b.txs { + // For plotting on a logarithmic Y scale we must avoid zero/negative values. + // Clamp gasPrice to at least 1 for visualization purposes; this means the + // plotted gas price may differ from the original t.gasPrice when it is <= 0. + gasPrice := t.gasPrice + if gasPrice <= 0 { + gasPrice = 1 + } + + // Use the local gasPrice variable (protected) in all appends + if t.target { + txGroups[0] = append(txGroups[0], plotter.XY{X: float64(b.number), Y: float64(gasPrice)}) + } else if t.gasLimit <= 1000000 { + txGroups[1] = append(txGroups[1], plotter.XY{X: float64(b.number), Y: float64(gasPrice)}) + } else if t.gasLimit <= 2000000 { + txGroups[2] = append(txGroups[2], plotter.XY{X: float64(b.number), Y: float64(gasPrice)}) + } else if t.gasLimit <= 3000000 { + txGroups[3] = append(txGroups[3], plotter.XY{X: float64(b.number), Y: float64(gasPrice)}) + } else if t.gasLimit <= 4000000 { + txGroups[4] = append(txGroups[4], plotter.XY{X: float64(b.number), Y: float64(gasPrice)}) + } else if t.gasLimit <= 5000000 { + txGroups[5] = append(txGroups[5], plotter.XY{X: float64(b.number), Y: float64(gasPrice)}) + } else { + txGroups[6] = append(txGroups[6], plotter.XY{X: float64(b.number), Y: float64(gasPrice)}) + } + } + } + + // other transactions (groups 1-6, index 0 is for target txs) + for group := 1; group <= 6; group++ { + points := txGroups[uint64(group)] + if len(points) == 0 { + continue + } + sc, err := plotter.NewScatter(points) + if err != nil { + log.Error().Err(err).Int("group", group).Msg("Failed to create scatter plot") + continue + } + sc.GlyphStyle.Color = txDotsColor + sc.GlyphStyle.Shape = draw.CircleGlyph{} + sc.GlyphStyle.Radius = txDotsSizes[group-1] + p.Add(sc) + } + + // target transactions + if len(txGroups[0]) > 0 { + sc, err := plotter.NewScatter(txGroups[0]) + if err != nil { + log.Error().Err(err).Msg("Failed to create target tx scatter plot") + return + } + sc.GlyphStyle.Color = targetTxDotsColor + sc.GlyphStyle.Shape = ThickCrossGlyph{Width: targetTxDotsThickness} + sc.GlyphStyle.Radius = targetTxDotsSize + p.Add(sc) + p.Legend.Add("Target transactions", sc) + } +} + +// createLines creates line plots for various gas metrics. +func createLines(p *plot.Plot, metadata txGasChartMetadata) { + numBlocks := len(metadata.blocksMetadata.blocks) + blocks := make([]uint64, numBlocks) + perBlockAvgGasPrice := make(map[uint64]float64, numBlocks) + pointsBlockGasLimit := make(plotter.XYs, numBlocks) + pointsTxsGasLimit := make(plotter.XYs, numBlocks) + pointsAvgGasUsed := make(plotter.XYs, numBlocks) + pointsGasUsed := make(plotter.XYs, numBlocks) + + for i, b := range metadata.blocksMetadata.blocks { + blocks[i] = b.number + + // Protect avgGasPrice for logarithmic scale + avgGasPrice := float64(b.avgGasPrice) + if avgGasPrice <= 0 { + avgGasPrice = 1 + } + perBlockAvgGasPrice[b.number] = avgGasPrice + + pointsBlockGasLimit[i].X = float64(b.number) + pointsBlockGasLimit[i].Y = scaleGasToGasPrice(b.gasLimit, metadata) + + pointsTxsGasLimit[i].X = float64(b.number) + pointsTxsGasLimit[i].Y = scaleGasToGasPrice(b.txsGasLimit, metadata) + + pointsAvgGasUsed[i].X = float64(b.number) + pointsAvgGasUsed[i].Y = scaleGasToGasPrice(metadata.blocksMetadata.avgBlockGasUsed, metadata) + + pointsGasUsed[i].X = float64(b.number) + pointsGasUsed[i].Y = scaleGasToGasPrice(b.gasUsed, metadata) + } + + addLine := func(points plotter.XYs, c color.Color, label string) { + line, err := plotter.NewLine(points) + if err != nil { + log.Error().Err(err).Str("label", label).Msg("Failed to create line plot") + return + } + line.Color = c + line.Width = lineThickness + p.Add(line) + p.Legend.Add(label, line) + } + + addLine(rollingMean(blocks, perBlockAvgGasPrice, 30), avgGasPriceLineColor, "30-block avg gas price") + addLine(pointsGasUsed, gasUsedLineColor, "Block gas used") + addLine(pointsTxsGasLimit, gasTxsLimitLineColor, "Txs gas limit") + addLine(pointsBlockGasLimit, gasBlockLimitLineColor, "Block gas limit") + addLine(pointsAvgGasUsed, avgGasUsedLineColor, "Avg block gas used") +} + +// save saves the plot to the specified output path. +func save(p *plot.Plot, metadata txGasChartMetadata) error { + if err := p.Save(1600, 900, metadata.outputPath); err != nil { + return err + } + log.Info(). + Str("file", metadata.outputPath). + Msg("Chart saved successfully") + return nil +} + +// rollingMean calculates the rolling mean of per-block average gas prices over a specified window. +func rollingMean(blocks []uint64, perBlockAvg map[uint64]float64, window int) plotter.XYs { + slices.Sort(blocks) + points := make(plotter.XYs, len(blocks)) + sum := 0.0 + buffer := make([]float64, 0, window) + for i, b := range blocks { + val := perBlockAvg[b] + buffer = append(buffer, val) + sum += val + if len(buffer) > window { + sum -= buffer[0] + buffer = buffer[1:] + } + points[i].X = float64(b) + points[i].Y = sum / float64(len(buffer)) + } + return points +} + +// scaleGasToGasPrice scales the gas limit to a corresponding gas price based on the provided metadata. +func scaleGasToGasPrice(gasLimit uint64, metadata txGasChartMetadata) float64 { + minTxGasPrice := metadata.blocksMetadata.minTxGasPrice + maxTxGasPrice := metadata.blocksMetadata.maxTxGasPrice + + maxBlockGasLimit := metadata.blocksMetadata.maxBlockGasLimit + + if maxBlockGasLimit == 0 { + return 1 + } + + yRange := float64(maxTxGasPrice) - float64(minTxGasPrice) + proportion := float64(gasLimit) / float64(maxBlockGasLimit) + y := proportion*yRange + float64(minTxGasPrice) + + if y < 1 { + y = 1 + } + return y +} + +// formatSI formats a number with intuitive suffixes (k, M, B, T). +func formatSI(v float64) string { + if v < 1 { + return fmt.Sprintf("%g", v) + } + + var suffix string + var scaled float64 + + switch { + case v >= 1e12: + scaled = v / 1e12 + suffix = "T" + case v >= 1e9: + scaled = v / 1e9 + suffix = "B" + case v >= 1e6: + scaled = v / 1e6 + suffix = "M" + case v >= 1e3: + scaled = v / 1e3 + suffix = "k" + default: + return fmt.Sprintf("%.0f", v) + } + + // Remove trailing zeros and decimal point if not needed + formatted := fmt.Sprintf("%.2f", scaled) + formatted = strings.TrimRight(formatted, "0") + formatted = strings.TrimRight(formatted, ".") + return formatted + suffix +} + +// ThickCrossGlyph draws an 'X' with configurable stroke width. +type ThickCrossGlyph struct { + Width vg.Length +} + +// DrawGlyph implements the GlyphDrawer interface. +func (g ThickCrossGlyph) DrawGlyph(c *draw.Canvas, sty draw.GlyphStyle, p vg.Point) { + if !c.Contains(p) { + return + } + r := sty.Radius + ls := draw.LineStyle{Color: sty.Color, Width: g.Width} + + // Horizontal + h := []vg.Point{{X: p.X - r, Y: p.Y}, {X: p.X + r, Y: p.Y}} + // Vertical + v := []vg.Point{{X: p.X, Y: p.Y - r}, {X: p.X, Y: p.Y + r}} + // Diagonal 1 (top-left -> bottom-right) + d1 := []vg.Point{{X: p.X - r, Y: p.Y + r}, {X: p.X + r, Y: p.Y - r}} + // Diagonal 2 (bottom-left -> top-right) + d2 := []vg.Point{{X: p.X - r, Y: p.Y - r}, {X: p.X + r, Y: p.Y + r}} + + c.StrokeLines(ls, h) + c.StrokeLines(ls, v) + c.StrokeLines(ls, d1) + c.StrokeLines(ls, d2) +} diff --git a/cmd/plot/cmd.go b/cmd/plot/cmd.go new file mode 100644 index 000000000..ff7cf5d62 --- /dev/null +++ b/cmd/plot/cmd.go @@ -0,0 +1,392 @@ +package plot + +import ( + "cmp" + "context" + _ "embed" + "encoding/json" + "fmt" + "math" + "math/big" + "slices" + "strings" + "sync" + "time" + + "github.com/0xPolygon/polygon-cli/rpctypes" + "github.com/0xPolygon/polygon-cli/util" + "github.com/ethereum/go-ethereum/common" + ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "golang.org/x/time/rate" +) + +type args struct { + rpcURL string + rateLimit float64 + concurrency uint64 + + scale string + + startBlock uint64 + endBlock uint64 + + targetAddr string + + output string +} + +var inputArgs = args{} + +//go:embed usage.md +var usage string +var Cmd = &cobra.Command{ + Use: "plot", + Short: "Plot a chart of transaction gas prices and limits.", + Long: usage, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return buildChart(cmd) + }, +} + +func init() { + f := Cmd.PersistentFlags() + f.StringVar(&inputArgs.rpcURL, "rpc-url", "http://localhost:8545", "RPC URL of network") + f.Float64Var(&inputArgs.rateLimit, "rate-limit", 4, "requests per second limit (use negative value to remove limit)") + f.Uint64VarP(&inputArgs.concurrency, "concurrency", "c", 1, "number of tasks to perform concurrently (default: one at a time)") + + f.StringVar(&inputArgs.scale, "scale", "log", "scale for gas price axis (options: log, linear)") + + f.Uint64Var(&inputArgs.startBlock, "start-block", math.MaxUint64, "starting block number (inclusive)") + f.Uint64Var(&inputArgs.endBlock, "end-block", math.MaxUint64, "ending block number (inclusive)") + f.StringVar(&inputArgs.targetAddr, "target-address", "", "address that will have tx sent from or to highlighted in the chart") + f.StringVarP(&inputArgs.output, "output", "o", "tx_gasprice_chart.png", "output file path") +} + +// txGasChartConfig holds the configuration for generating the transaction gas chart. +type txGasChartConfig struct { + rateLimiter *rate.Limiter + concurrency uint64 + output string + + targetAddr string + startBlock uint64 + endBlock uint64 + + scale string +} + +// blocksMetadata holds metadata about a range of blocks. +type blocksMetadata struct { + blocks []block + + minTxGasLimit uint64 + maxTxGasLimit uint64 + minTxGasPrice uint64 + maxTxGasPrice uint64 + maxBlockGasLimit uint64 + avgBlockGasUsed uint64 + txCount uint64 + targetTxCount uint64 +} + +// block holds metadata about a single block. +type block struct { + number uint64 + avgGasPrice uint64 + gasLimit uint64 + txsGasLimit uint64 + gasUsed uint64 + txs []transaction +} + +// transaction holds metadata about a single transaction. +type transaction struct { + hash common.Hash + gasPrice uint64 + gasLimit uint64 + target bool +} + +// buildChart builds the transaction gas chart based on the provided command and input arguments. +func buildChart(cmd *cobra.Command) error { + ctx := cmd.Context() + log.Info(). + Str("rpc_url", inputArgs.rpcURL). + Float64("rate_limit", inputArgs.rateLimit). + Msg("RPC connection parameters") + + log.Info(). + Uint64("start_block", inputArgs.startBlock). + Uint64("end_block", inputArgs.endBlock). + Str("target_address", inputArgs.targetAddr). + Msg("Chart generation parameters") + + client, err := ethrpc.DialContext(ctx, inputArgs.rpcURL) + if err != nil { + return err + } + defer client.Close() + + chainID, err := util.GetChainID(ctx, client) + if err != nil { + return err + } + + config, err := parseFlags(ctx, client) + if err != nil { + return err + } + + bm := loadBlocksMetadata(ctx, config, client, chainID) + + chartMetadata := txGasChartMetadata{ + rpcURL: inputArgs.rpcURL, + chainID: chainID.Uint64(), + + targetAddr: config.targetAddr, + startBlock: config.startBlock, + endBlock: config.endBlock, + + blocksMetadata: bm, + + scale: config.scale, + + outputPath: config.output, + } + + logMostUsedGasPrices(bm) + + return plotChart(chartMetadata) +} + +// gasPriceCount holds a gas price and its occurrence count. +type gasPriceCount struct { + gasPrice uint64 + count uint64 +} + +// logMostUsedGasPrices logs the most frequently used gas prices in the provided blocks metadata. +func logMostUsedGasPrices(bm blocksMetadata) { + counts := make(map[uint64]uint64) + for _, b := range bm.blocks { + for _, t := range b.txs { + counts[t.gasPrice]++ + } + } + + sorted := make([]gasPriceCount, 0, len(counts)) + for price, count := range counts { + sorted = append(sorted, gasPriceCount{price, count}) + } + slices.SortFunc(sorted, func(a, b gasPriceCount) int { + return cmp.Compare(b.count, a.count) // descending + }) + + for i, v := range sorted { + if i >= 20 { + break + } + log.Debug().Uint64("gas_price_wei", v.gasPrice). + Uint64("count", v.count). + Msg("Most used gas price") + } +} + +// parseFlags parses the command-line flags and returns the corresponding txGasChartConfig. +func parseFlags(ctx context.Context, client *ethrpc.Client) (*txGasChartConfig, error) { + config := &txGasChartConfig{} + + config.startBlock = inputArgs.startBlock + config.endBlock = inputArgs.endBlock + + h, err := util.HeaderByBlockNumber(ctx, client, nil) + if err != nil { + return nil, err + } + + if config.endBlock == math.MaxUint64 || config.endBlock > h.Number.Uint64() { + config.endBlock = h.Number.Uint64() + log.Warn().Uint64("end_block", config.endBlock).Msg("End block not set or exceeds latest, using latest block") + } + + const defaultBlockRange = 500 + + if config.startBlock == math.MaxUint64 { + if config.endBlock < defaultBlockRange { + config.startBlock = 0 + } else { + config.startBlock = config.endBlock - defaultBlockRange + } + + log.Warn().Uint64("start_block", config.startBlock).Msg("Start block not set, using last 500 blocks") + } + + if config.startBlock > config.endBlock { + return nil, fmt.Errorf("start block %d cannot be greater than end block %d", config.startBlock, config.endBlock) + } + + config.rateLimiter = nil + if inputArgs.rateLimit > 0.0 { + config.rateLimiter = rate.NewLimiter(rate.Limit(inputArgs.rateLimit), 1) + } + + if len(inputArgs.targetAddr) > 0 && !common.IsHexAddress(inputArgs.targetAddr) { + return nil, fmt.Errorf("target address %s is not a valid hex address", inputArgs.targetAddr) + } + + config.targetAddr = inputArgs.targetAddr + config.concurrency = inputArgs.concurrency + config.output = inputArgs.output + config.scale = inputArgs.scale + + return config, nil +} + +// loadBlocksMetadata loads metadata for blocks in the specified range using the provided Ethereum client and configuration. +func loadBlocksMetadata(ctx context.Context, config *txGasChartConfig, client *ethrpc.Client, chainID *big.Int) blocksMetadata { + + // prepare worker pool + workers := make(chan struct{}, config.concurrency) + for i := 0; i < cap(workers); i++ { + workers <- struct{}{} + } + + blockMutex := &sync.Mutex{} + blocks := blocksMetadata{ + minTxGasLimit: math.MaxUint64, + maxTxGasLimit: 0, + minTxGasPrice: math.MaxUint64, + maxTxGasPrice: 0, + txCount: 0, + targetTxCount: 0, + } + + blocks.blocks = make([]block, config.endBlock-config.startBlock+1) + offset := config.startBlock + + log.Info().Msg("Reading blocks") + + wg := sync.WaitGroup{} + totalGasUsed := big.NewInt(0) + for blockNumber := config.startBlock; blockNumber <= config.endBlock; blockNumber++ { + wg.Add(1) // notify block to process + go func(blockNumber uint64) { + defer wg.Done() // notify block done + <-workers // wait for worker slot + defer func() { workers <- struct{}{} }() // release worker slot + + for { + log.Trace().Uint64("block_number", blockNumber).Msg("Processing block") + if config.rateLimiter != nil { + _ = config.rateLimiter.Wait(ctx) + } + blocksFromNetwork, err := util.GetBlockRange(ctx, blockNumber, blockNumber, client, false) + if err != nil { + log.Warn().Err(err).Uint64("block_number", blockNumber).Msg("Failed to fetch block, retrying") + time.Sleep(time.Second) + continue + } + + blockFromNetwork := blocksFromNetwork[0] + + var rawBlock rpctypes.RawBlockResponse + if err := json.Unmarshal(*blockFromNetwork, &rawBlock); err != nil { + log.Error().Bytes("block", *blockFromNetwork).Msg("Unable to unmarshal block") + continue + } + + parsedBlock := rpctypes.NewPolyBlock(&rawBlock) + txs := parsedBlock.Transactions() + + b := block{ + number: parsedBlock.Number().Uint64(), + gasLimit: parsedBlock.GasLimit(), + gasUsed: parsedBlock.GasUsed(), + txs: make([]transaction, len(parsedBlock.Transactions())), + } + + blockMutex.Lock() + blocks.maxBlockGasLimit = max(blocks.maxBlockGasLimit, b.gasLimit) + totalGasUsed = totalGasUsed.Add(totalGasUsed, new(big.Int).SetUint64(b.gasUsed)) + blockMutex.Unlock() + + totalGasPrice := uint64(0) + totalGasLimit := uint64(0) + for txi, tx := range txs { + from, err := util.GetSenderFromTx(ctx, tx) + if err != nil { + log.Error().Err(err).Uint64("block", b.number).Stringer("txHash", tx.Hash()).Msg("Unable to get sender from tx, skipping") + continue + } + + target := strings.EqualFold(from.String(), config.targetAddr) + if !target { + target = strings.EqualFold(tx.To().String(), config.targetAddr) + } + gasPrice := tx.GasPrice().Uint64() + gasLimit := tx.Gas() + + b.txs[txi] = transaction{ + hash: tx.Hash(), + gasPrice: gasPrice, + gasLimit: gasLimit, + target: target, + } + + totalGasPrice += gasPrice + totalGasLimit += gasLimit + + blockMutex.Lock() + blocks.minTxGasLimit = min(blocks.minTxGasLimit, gasLimit) + blocks.maxTxGasLimit = max(blocks.maxTxGasLimit, gasLimit) + blocks.minTxGasPrice = min(blocks.minTxGasPrice, gasPrice) + blocks.maxTxGasPrice = max(blocks.maxTxGasPrice, gasPrice) + + blocks.txCount++ + if target { + blocks.targetTxCount++ + log.Info(). + Uint64("block", b.number). + Stringer("txHash", tx.Hash()). + Uint64("gas_price_wei", gasPrice). + Uint64("gas_limit", gasLimit). + Msg("target tx found") + } + blockMutex.Unlock() + } + if len(txs) > 0 { + b.avgGasPrice = uint64(totalGasPrice / uint64(len(txs))) + } else { + b.avgGasPrice = 1 + } + + b.txsGasLimit = totalGasLimit + + blocks.blocks[blockNumber-offset] = b + break + } + }(blockNumber) + } + wg.Wait() + + // Calculate average block gas used safely + numBlocks := len(blocks.blocks) + if numBlocks == 0 { + blocks.avgBlockGasUsed = 0 + } else { + // len() returns int, which always fits in int64, so conversion is safe + avgGasUsed := new(big.Int).Div(totalGasUsed, big.NewInt(int64(numBlocks))) + if avgGasUsed.IsUint64() { + blocks.avgBlockGasUsed = avgGasUsed.Uint64() + } else { + // Result exceeds uint64 max, cap it to max value + blocks.avgBlockGasUsed = math.MaxUint64 + log.Warn().Msg("Average block gas used exceeds uint64 max, capping") + } + } + + return blocks +} diff --git a/cmd/plot/cmd_test.go b/cmd/plot/cmd_test.go new file mode 100644 index 000000000..de6f8399f --- /dev/null +++ b/cmd/plot/cmd_test.go @@ -0,0 +1,80 @@ +package plot + +import ( + "math" + "testing" +) + +// TestStartBlockLogic tests the start block calculation logic +func TestStartBlockLogic(t *testing.T) { + tests := []struct { + name string + startBlock uint64 + endBlock uint64 + expectedStart uint64 + shouldCalc bool // whether default calculation should happen + }{ + { + name: "explicit start block 0 should be respected", + startBlock: 0, + endBlock: 1000, + expectedStart: 0, + shouldCalc: false, + }, + { + name: "explicit start block 100 should be respected", + startBlock: 100, + endBlock: 1000, + expectedStart: 100, + shouldCalc: false, + }, + { + name: "unset start block with end > 500 should calculate default", + startBlock: math.MaxUint64, + endBlock: 1000, + expectedStart: 500, // 1000 - 500 + shouldCalc: true, + }, + { + name: "unset start block with end < 500 should default to 0", + startBlock: math.MaxUint64, + endBlock: 400, + expectedStart: 0, + shouldCalc: true, + }, + { + name: "unset start block with end = 500 should default to 0", + startBlock: math.MaxUint64, + endBlock: 500, + expectedStart: 0, + shouldCalc: true, + }, + { + name: "unset start block with end = 501 should calculate default", + startBlock: math.MaxUint64, + endBlock: 501, + expectedStart: 1, // 501 - 500 + shouldCalc: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate the logic from parseFlags + const defaultBlockRange = 500 + startBlock := tt.startBlock + + if startBlock == math.MaxUint64 { + if tt.endBlock < defaultBlockRange { + startBlock = 0 + } else { + startBlock = tt.endBlock - defaultBlockRange + } + } + + if startBlock != tt.expectedStart { + t.Errorf("Expected start block %d, got %d", tt.expectedStart, startBlock) + } + }) + } +} diff --git a/cmd/plot/examples/chart_all.png b/cmd/plot/examples/chart_all.png new file mode 100644 index 000000000..1c989b6c1 Binary files /dev/null and b/cmd/plot/examples/chart_all.png differ diff --git a/cmd/plot/examples/chart_sawtooth.png b/cmd/plot/examples/chart_sawtooth.png new file mode 100644 index 000000000..6b8159833 Binary files /dev/null and b/cmd/plot/examples/chart_sawtooth.png differ diff --git a/cmd/plot/examples/chart_sine.png b/cmd/plot/examples/chart_sine.png new file mode 100644 index 000000000..db1eafbc9 Binary files /dev/null and b/cmd/plot/examples/chart_sine.png differ diff --git a/cmd/plot/examples/chart_square.png b/cmd/plot/examples/chart_square.png new file mode 100644 index 000000000..161056486 Binary files /dev/null and b/cmd/plot/examples/chart_square.png differ diff --git a/cmd/plot/examples/chart_triangle.png b/cmd/plot/examples/chart_triangle.png new file mode 100644 index 000000000..52d11c86f Binary files /dev/null and b/cmd/plot/examples/chart_triangle.png differ diff --git a/cmd/plot/usage.md b/cmd/plot/usage.md new file mode 100644 index 000000000..7d0b8ae51 --- /dev/null +++ b/cmd/plot/usage.md @@ -0,0 +1,124 @@ +# plot + +`plot` generates visual charts analyzing transaction gas prices and limits across a range of blocks. It fetches block data from an Ethereum-compatible RPC endpoint and produces a PNG chart showing gas price distribution, transaction gas limits, block gas limits, and block gas usage over time. + +## Basic Usage + +Generate a chart for the last 500 blocks: + +```bash +polycli plot --rpc-url http://localhost:8545 +``` + +This will create a file named `tx_gasprice_chart.png` in the current directory. + +## Analyzing Specific Block Ranges + +Analyze blocks 9356826 to 9358826: + +```bash +polycli plot --rpc-url https://sepolia.infura.io/v3/YOUR_API_KEY \ + --start-block 9356826 \ + --end-block 9358826 \ + --output "sepolia_analysis.png" +``` + +## Highlighting Target Address Transactions + +Track transactions involving a specific address (either sent from or to): + +```bash +polycli plot --rpc-url http://localhost:8545 \ + --target-address "0xeE76bECaF80fFe451c8B8AFEec0c21518Def02f9" \ + --start-block 1000 \ + --end-block 2000 +``` + +Target transactions will be highlighted in the chart and logged during execution. + +## Performance Options + +When fetching large block ranges, adjust rate limiting and concurrency. + +Process 10 blocks concurrently with 10 requests/second rate limit: + +```bash +polycli plot --rpc-url http://localhost:8545 \ + --concurrency 10 \ + --rate-limit 10 \ + --start-block 1000 \ + --end-block 5000 +``` + +Remove rate limiting entirely (use with caution): + +```bash +polycli plot --rpc-url http://localhost:8545 \ + --rate-limit -1 \ + --concurrency 20 +``` + +## Chart Scale Options + +Choose between logarithmic (default) and linear scale for the gas price axis. + +Use linear scale for gas prices: + +```bash +polycli plot --rpc-url http://localhost:8545 \ + --scale "linear" \ + --output "linear_chart.png" +``` + +Use logarithmic scale (default): + +```bash +polycli plot --rpc-url http://localhost:8545 \ + --scale "log" \ + --output "log_chart.png" +``` + +## Understanding the Chart + +The generated chart displays four key metrics: + +1. **Transaction Gas Prices**: Individual transaction gas prices plotted as points, with target address transactions highlighted +2. **Transaction Gas Limits**: Gas limits for individual transactions +3. **Block Gas Limits**: Maximum gas limit per block +4. **Block Gas Used**: Actual gas consumed per block + +## Example Use Cases + +Analyzing gas price patterns during network congestion: + +```bash +polycli plot --rpc-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY \ + --start-block 18000000 \ + --end-block 18001000 \ + --scale log \ + --output mainnet_congestion.png +``` + +Tracking your contract deployment gas costs: + +```bash +polycli plot --rpc-url http://localhost:8545 \ + --target-address 0xYourContractAddress \ + --output my_contract_gas.png +``` + +Analyzing test network behavior: + +```bash +polycli plot --rpc-url http://localhost:8545 \ + --concurrency 1 \ + --rate-limit 4 \ + --output local_test.png +``` + +## Notes + +- If `--start-block` is not set, the command analyzes the last 500 blocks +- If `--end-block` exceeds the latest block or is not set, it defaults to the latest block +- The command logs the 20 most frequently used gas prices at debug level +- Charts are saved in PNG format diff --git a/cmd/root.go b/cmd/root.go index a9e159589..b6515cc6f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -34,6 +34,7 @@ import ( "github.com/0xPolygon/polygon-cli/cmd/retest" "github.com/0xPolygon/polygon-cli/cmd/rpcfuzz" "github.com/0xPolygon/polygon-cli/cmd/signer" + "github.com/0xPolygon/polygon-cli/cmd/plot" "github.com/0xPolygon/polygon-cli/cmd/ulxly" "github.com/0xPolygon/polygon-cli/cmd/version" "github.com/0xPolygon/polygon-cli/cmd/wallet" @@ -161,6 +162,7 @@ func NewPolycliCommand() *cobra.Command { publish.Cmd, dockerlogger.Cmd, contract.Cmd, + plot.Cmd, ) return cmd diff --git a/doc/polycli.md b/doc/polycli.md index a9f4f7b44..5a73b5660 100644 --- a/doc/polycli.md +++ b/doc/polycli.md @@ -80,6 +80,8 @@ Polycli is a collection of tools that are meant to be useful while building, tes - [polycli parseethwallet](polycli_parseethwallet.md) - Extract the private key from an eth wallet. +- [polycli plot](polycli_plot.md) - Plot a chart of transaction gas prices and limits. + - [polycli publish](polycli_publish.md) - Publish transactions to the network with high-throughput. - [polycli report](polycli_report.md) - Generate a report analyzing a range of blocks from an Ethereum-compatible blockchain. diff --git a/doc/polycli_loadtest.md b/doc/polycli_loadtest.md index 798d55619..ff1c7ec74 100644 --- a/doc/polycli_loadtest.md +++ b/doc/polycli_loadtest.md @@ -73,6 +73,24 @@ Here is a simple example that runs 1000 requests at a max rate of 1 request per $ polycli loadtest --verbosity 700 --chain-id 1256 --concurrency 1 --requests 1000 --rate-limit 1 --mode t --rpc-url http://localhost:8888 ``` +### Gas Manager + +The loadtest command includes a gas manager for controlling transaction gas limits and pricing. Use the `--gas-manager-*` flags to: + +- **Oscillate gas limits** with wave patterns (flat, sine, square, triangle, sawtooth) +- **Control gas pricing** with strategies (estimated, fixed, dynamic) + +Example with sine wave oscillation: +```bash +$ polycli loadtest --rpc-url http://localhost:8545 \ + --gas-manager-oscillation-wave sine \ + --gas-manager-target 20000000 \ + --gas-manager-amplitude 10000000 \ + --gas-manager-period 100 +``` + +See [Gas Manager README](../../loadtest/gasmanager/README.md) for detailed documentation. + ### Load Test Contract The codebase has a contract that used for load testing. It's written in Solidity. The workflow for modifying this contract is. @@ -88,71 +106,79 @@ The codebase has a contract that used for load testing. It's written in Solidity ## Flags ```bash - --account-funding-amount big.Int amount in wei to fund sending accounts (set to 0 to disable) - --adaptive-backoff-factor float multiplicative decrease factor for adaptive rate limiting (default 2) - --adaptive-cycle-duration-seconds uint interval in seconds to check queue size and adjust rates for adaptive rate limiting (default 10) - --adaptive-rate-limit enable AIMD-style congestion control to automatically adjust request rate - --adaptive-rate-limit-increment uint size of additive increases for adaptive rate limiting (default 50) - --adaptive-target-size uint target queue size for adaptive rate limiting (speed up if smaller, back off if larger) (default 1000) - --batch-size uint batch size for receipt fetching (default: 999) (default 999) - --blob-fee-cap uint blob fee cap, or maximum blob fee per chunk, in Gwei (default 100000) - --block-batch-size uint number of blocks to fetch per RPC batch request for recall and rpc modes (default 25) - --calldata string hex encoded calldata: function signature + encoded arguments (requires --mode contract-call and --contract-address) - --chain-id uint chain ID for the transactions - --check-balance-before-funding check account balance before funding sending accounts (saves gas when accounts are already funded) - -c, --concurrency int number of requests to perform concurrently (default: one at a time) (default 1) - --contract-address string contract address for --mode contract-call (requires --calldata) - --contract-call-payable mark function as payable using value from --eth-amount-in-wei (requires --mode contract-call and --contract-address) - --erc20-address string address of pre-deployed ERC20 contract - --erc721-address string address of pre-deployed ERC721 contract - --eth-amount-in-wei uint amount of ether in wei to send per transaction - --eth-call-only call contracts without sending transactions (incompatible with adaptive rate limiting and summarization) - --eth-call-only-latest execute on latest block instead of original block in call-only mode with recall - --fire-and-forget send transactions and load without waiting for it to be mined - --gas-limit uint manually specify gas limit (useful to avoid eth_estimateGas or when auto-computation fails) - --gas-price uint manually specify gas price (useful when auto-detection fails) - --gas-price-multiplier float a multiplier to increase or decrease the gas price (default 1) - -h, --help help for loadtest - --legacy send a legacy transaction instead of an EIP1559 transaction - --loadtest-contract-address string address of pre-deployed load test contract - --max-base-fee-wei uint maximum base fee in wei (pause sending new transactions when exceeded, useful during network congestion) - -m, --mode strings testing mode (can specify multiple like "d,t"): - 2, erc20 - send ERC20 tokens - 7, erc721 - mint ERC721 tokens - b, blob - send blob transactions - cc, contract-call - make contract calls - d, deploy - deploy contracts - inc, increment - increment a counter - r, random - random modes (excludes: blob, call, recall, rpc, uniswapv3) - R, recall - replay or simulate transactions - rpc - call random rpc methods - s, store - store bytes in a dynamic byte array - t, transaction - send transactions - v3, uniswapv3 - perform UniswapV3 swaps (default [t]) - --nonce uint use this flag to manually set the starting nonce - --output-mode string format mode for summary output (json | text) (default "text") - --output-raw-tx-only output raw signed transaction hex without sending (works with most modes except RPC and UniswapV3) - --pre-fund-sending-accounts fund all sending accounts at start instead of on first use - --priority-gas-price uint gas tip price for EIP-1559 transactions - --private-key string hex encoded private key to use for sending transactions (default "42b6e34dc21598a807dc19d7784c71b2a7a01f6480dc6f58258f78e539f1a1fa") - --proxy string use the proxy specified - --random-recipients send to random addresses instead of fixed address in transfer tests - --rate-limit float requests per second limit (use negative value to remove limit) (default 4) - --recall-blocks uint number of blocks that we'll attempt to fetch for recall (default 50) - --receipt-retry-initial-delay-ms uint initial delay in milliseconds for receipt polling (uses exponential backoff with jitter) (default 100) - --receipt-retry-max uint maximum polling attempts for transaction receipt with --wait-for-receipt (default 30) - --refund-remaining-funds refund remaining balance to funding account after completion - -n, --requests int number of requests to perform for the benchmarking session (default of 1 leads to non-representative results) (default 1) - -r, --rpc-url string the RPC endpoint URL (default "http://localhost:8545") - --seed int a seed for generating random values and addresses (default 123456) - --send-only alias for --fire-and-forget - --sending-accounts-count uint number of sending accounts to use (avoids pool account queue) - --sending-accounts-file string file with sending account private keys, one per line (avoids pool queue and preserves accounts across runs) - --store-data-size uint number of bytes to store in contract for store mode (default 1024) - --summarize produce execution summary after load test (can take a long time for large tests) - -t, --time-limit int maximum seconds to spend benchmarking (default: no limit) (default -1) - --to-address string recipient address for transactions (default "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") - --wait-for-receipt wait for transaction receipt to be mined instead of just sending + --account-funding-amount big.Int amount in wei to fund sending accounts (set to 0 to disable) + --adaptive-backoff-factor float multiplicative decrease factor for adaptive rate limiting (default 2) + --adaptive-cycle-duration-seconds uint interval in seconds to check queue size and adjust rates for adaptive rate limiting (default 10) + --adaptive-rate-limit enable AIMD-style congestion control to automatically adjust request rate + --adaptive-rate-limit-increment uint size of additive increases for adaptive rate limiting (default 50) + --adaptive-target-size uint target queue size for adaptive rate limiting (speed up if smaller, back off if larger) (default 1000) + --batch-size uint batch size for receipt fetching (default: 999) (default 999) + --blob-fee-cap uint blob fee cap, or maximum blob fee per chunk, in Gwei (default 100000) + --block-batch-size uint number of blocks to fetch per RPC batch request for recall and rpc modes (default 25) + --calldata string hex encoded calldata: function signature + encoded arguments (requires --mode contract-call and --contract-address) + --chain-id uint chain ID for the transactions + --check-balance-before-funding check account balance before funding sending accounts (saves gas when accounts are already funded) + -c, --concurrency int number of requests to perform concurrently (default: one at a time) (default 1) + --contract-address string contract address for --mode contract-call (requires --calldata) + --contract-call-payable mark function as payable using value from --eth-amount-in-wei (requires --mode contract-call and --contract-address) + --erc20-address string address of pre-deployed ERC20 contract + --erc721-address string address of pre-deployed ERC721 contract + --eth-amount-in-wei uint amount of ether in wei to send per transaction + --eth-call-only call contracts without sending transactions (incompatible with adaptive rate limiting and summarization) + --eth-call-only-latest execute on latest block instead of original block in call-only mode with recall + --fire-and-forget send transactions and load without waiting for it to be mined + --gas-limit uint manually specify gas limit (useful to avoid eth_estimateGas or when auto-computation fails) + --gas-manager-amplitude uint amplitude for oscillation wave + --gas-manager-dynamic-gas-prices-variation float variation percentage for dynamic strategy (default 0.3) + --gas-manager-dynamic-gas-prices-wei string comma-separated gas prices in wei for dynamic strategy (default "0,1000000,0,10000000,0,100000000") + --gas-manager-fixed-gas-price-wei uint fixed gas price in wei (default 300000000) + --gas-manager-oscillation-wave string type of oscillation wave (flat | sine | square | triangle | sawtooth) (default "flat") + --gas-manager-period uint period in blocks for oscillation wave (default 1) + --gas-manager-price-strategy string gas price strategy (estimated | fixed | dynamic) (default "estimated") + --gas-manager-target uint target gas limit for oscillation wave (default 30000000) + --gas-price uint manually specify gas price (useful when auto-detection fails) + --gas-price-multiplier float a multiplier to increase or decrease the gas price (default 1) + -h, --help help for loadtest + --legacy send a legacy transaction instead of an EIP1559 transaction + --loadtest-contract-address string address of pre-deployed load test contract + --max-base-fee-wei uint maximum base fee in wei (pause sending new transactions when exceeded, useful during network congestion) + -m, --mode strings testing mode (can specify multiple like "d,t"): + 2, erc20 - send ERC20 tokens + 7, erc721 - mint ERC721 tokens + b, blob - send blob transactions + cc, contract-call - make contract calls + d, deploy - deploy contracts + inc, increment - increment a counter + r, random - random modes (excludes: blob, call, recall, rpc, uniswapv3) + R, recall - replay or simulate transactions + rpc - call random rpc methods + s, store - store bytes in a dynamic byte array + t, transaction - send transactions + v3, uniswapv3 - perform UniswapV3 swaps (default [t]) + --nonce uint use this flag to manually set the starting nonce + --output-mode string format mode for summary output (json | text) (default "text") + --output-raw-tx-only output raw signed transaction hex without sending (works with most modes except RPC and UniswapV3) + --pre-fund-sending-accounts fund all sending accounts at start instead of on first use + --priority-gas-price uint gas tip price for EIP-1559 transactions + --private-key string hex encoded private key to use for sending transactions (default "42b6e34dc21598a807dc19d7784c71b2a7a01f6480dc6f58258f78e539f1a1fa") + --proxy string use the proxy specified + --random-recipients send to random addresses instead of fixed address in transfer tests + --rate-limit float requests per second limit (use negative value to remove limit) (default 4) + --recall-blocks uint number of blocks that we'll attempt to fetch for recall (default 50) + --receipt-retry-initial-delay-ms uint initial delay in milliseconds for receipt polling (uses exponential backoff with jitter) (default 100) + --receipt-retry-max uint maximum polling attempts for transaction receipt with --wait-for-receipt (default 30) + --refund-remaining-funds refund remaining balance to funding account after completion + -n, --requests int number of requests to perform for the benchmarking session (default of 1 leads to non-representative results) (default 1) + -r, --rpc-url string the RPC endpoint URL (default "http://localhost:8545") + --seed int a seed for generating random values and addresses (default 123456) + --send-only alias for --fire-and-forget + --sending-accounts-count uint number of sending accounts to use (avoids pool account queue) + --sending-accounts-file string file with sending account private keys, one per line (avoids pool queue and preserves accounts across runs) + --store-data-size uint number of bytes to store in contract for store mode (default 1024) + --summarize produce execution summary after load test (can take a long time for large tests) + -t, --time-limit int maximum seconds to spend benchmarking (default: no limit) (default -1) + --to-address string recipient address for transactions (default "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") + --wait-for-receipt wait for transaction receipt to be mined instead of just sending ``` The command also inherits flags from parent commands. diff --git a/doc/polycli_loadtest_uniswapv3.md b/doc/polycli_loadtest_uniswapv3.md index 6945ceea1..127a28f67 100644 --- a/doc/polycli_loadtest_uniswapv3.md +++ b/doc/polycli_loadtest_uniswapv3.md @@ -76,47 +76,55 @@ Contracts are cloned from the different Uniswap repositories, compiled with a sp The command also inherits flags from parent commands. ```bash - --adaptive-backoff-factor float multiplicative decrease factor for adaptive rate limiting (default 2) - --adaptive-cycle-duration-seconds uint interval in seconds to check queue size and adjust rates for adaptive rate limiting (default 10) - --adaptive-rate-limit enable AIMD-style congestion control to automatically adjust request rate - --adaptive-rate-limit-increment uint size of additive increases for adaptive rate limiting (default 50) - --adaptive-target-size uint target queue size for adaptive rate limiting (speed up if smaller, back off if larger) (default 1000) - --batch-size uint batch size for receipt fetching (default: 999) (default 999) - --chain-id uint chain ID for the transactions - -c, --concurrency int number of requests to perform concurrently (default: one at a time) (default 1) - --config string config file (default is $HOME/.polygon-cli.yaml) - --eth-amount-in-wei uint amount of ether in wei to send per transaction - --eth-call-only call contracts without sending transactions (incompatible with adaptive rate limiting and summarization) - --eth-call-only-latest execute on latest block instead of original block in call-only mode with recall - --fire-and-forget send transactions and load without waiting for it to be mined - --gas-limit uint manually specify gas limit (useful to avoid eth_estimateGas or when auto-computation fails) - --gas-price uint manually specify gas price (useful when auto-detection fails) - --gas-price-multiplier float a multiplier to increase or decrease the gas price (default 1) - --legacy send a legacy transaction instead of an EIP1559 transaction - --nonce uint use this flag to manually set the starting nonce - --output-mode string format mode for summary output (json | text) (default "text") - --output-raw-tx-only output raw signed transaction hex without sending (works with most modes except RPC and UniswapV3) - --pretty-logs output logs in pretty format instead of JSON (default true) - --priority-gas-price uint gas tip price for EIP-1559 transactions - --private-key string hex encoded private key to use for sending transactions (default "42b6e34dc21598a807dc19d7784c71b2a7a01f6480dc6f58258f78e539f1a1fa") - --random-recipients send to random addresses instead of fixed address in transfer tests - --rate-limit float requests per second limit (use negative value to remove limit) (default 4) - -n, --requests int number of requests to perform for the benchmarking session (default of 1 leads to non-representative results) (default 1) - -r, --rpc-url string the RPC endpoint URL (default "http://localhost:8545") - --seed int a seed for generating random values and addresses (default 123456) - --send-only alias for --fire-and-forget - --summarize produce execution summary after load test (can take a long time for large tests) - -t, --time-limit int maximum seconds to spend benchmarking (default: no limit) (default -1) - --to-address string recipient address for transactions (default "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") - -v, --verbosity string log level (string or int): - 0 - silent - 100 - panic - 200 - fatal - 300 - error - 400 - warn - 500 - info (default) - 600 - debug - 700 - trace (default "info") + --adaptive-backoff-factor float multiplicative decrease factor for adaptive rate limiting (default 2) + --adaptive-cycle-duration-seconds uint interval in seconds to check queue size and adjust rates for adaptive rate limiting (default 10) + --adaptive-rate-limit enable AIMD-style congestion control to automatically adjust request rate + --adaptive-rate-limit-increment uint size of additive increases for adaptive rate limiting (default 50) + --adaptive-target-size uint target queue size for adaptive rate limiting (speed up if smaller, back off if larger) (default 1000) + --batch-size uint batch size for receipt fetching (default: 999) (default 999) + --chain-id uint chain ID for the transactions + -c, --concurrency int number of requests to perform concurrently (default: one at a time) (default 1) + --config string config file (default is $HOME/.polygon-cli.yaml) + --eth-amount-in-wei uint amount of ether in wei to send per transaction + --eth-call-only call contracts without sending transactions (incompatible with adaptive rate limiting and summarization) + --eth-call-only-latest execute on latest block instead of original block in call-only mode with recall + --fire-and-forget send transactions and load without waiting for it to be mined + --gas-limit uint manually specify gas limit (useful to avoid eth_estimateGas or when auto-computation fails) + --gas-manager-amplitude uint amplitude for oscillation wave + --gas-manager-dynamic-gas-prices-variation float variation percentage for dynamic strategy (default 0.3) + --gas-manager-dynamic-gas-prices-wei string comma-separated gas prices in wei for dynamic strategy (default "0,1000000,0,10000000,0,100000000") + --gas-manager-fixed-gas-price-wei uint fixed gas price in wei (default 300000000) + --gas-manager-oscillation-wave string type of oscillation wave (flat | sine | square | triangle | sawtooth) (default "flat") + --gas-manager-period uint period in blocks for oscillation wave (default 1) + --gas-manager-price-strategy string gas price strategy (estimated | fixed | dynamic) (default "estimated") + --gas-manager-target uint target gas limit for oscillation wave (default 30000000) + --gas-price uint manually specify gas price (useful when auto-detection fails) + --gas-price-multiplier float a multiplier to increase or decrease the gas price (default 1) + --legacy send a legacy transaction instead of an EIP1559 transaction + --nonce uint use this flag to manually set the starting nonce + --output-mode string format mode for summary output (json | text) (default "text") + --output-raw-tx-only output raw signed transaction hex without sending (works with most modes except RPC and UniswapV3) + --pretty-logs output logs in pretty format instead of JSON (default true) + --priority-gas-price uint gas tip price for EIP-1559 transactions + --private-key string hex encoded private key to use for sending transactions (default "42b6e34dc21598a807dc19d7784c71b2a7a01f6480dc6f58258f78e539f1a1fa") + --random-recipients send to random addresses instead of fixed address in transfer tests + --rate-limit float requests per second limit (use negative value to remove limit) (default 4) + -n, --requests int number of requests to perform for the benchmarking session (default of 1 leads to non-representative results) (default 1) + -r, --rpc-url string the RPC endpoint URL (default "http://localhost:8545") + --seed int a seed for generating random values and addresses (default 123456) + --send-only alias for --fire-and-forget + --summarize produce execution summary after load test (can take a long time for large tests) + -t, --time-limit int maximum seconds to spend benchmarking (default: no limit) (default -1) + --to-address string recipient address for transactions (default "0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") + -v, --verbosity string log level (string or int): + 0 - silent + 100 - panic + 200 - fatal + 300 - error + 400 - warn + 500 - info (default) + 600 - debug + 700 - trace (default "info") ``` ## See also diff --git a/doc/polycli_plot.md b/doc/polycli_plot.md new file mode 100644 index 000000000..19b52fbd1 --- /dev/null +++ b/doc/polycli_plot.md @@ -0,0 +1,151 @@ +# `polycli plot` + +> Auto-generated documentation. + +## Table of Contents + +- [Description](#description) +- [Usage](#usage) +- [Flags](#flags) +- [See Also](#see-also) + +## Description + +Plot a chart of transaction gas prices and limits. + +```bash +polycli plot [flags] +``` + +## Usage + +# plot + +`plot` generates visual charts analyzing transaction gas prices and limits across a range of blocks. It fetches block data from an Ethereum-compatible RPC endpoint and produces a PNG chart showing gas price distribution, + transaction gas limits, block gas limits, and block gas usage over time. + +## Basic Usage + +Generate a chart for the last 500 blocks: + +$ polycli plot --rpc-url http://localhost:8545 + +This will create a file named tx_gasprice_chart.png in the current directory. + +Analyzing Specific Block Ranges + +Analyze blocks 9356826 to 9358826: +$ polycli plot --rpc-url https://sepolia.infura.io/v3/YOUR_API_KEY \ + --start-block 9356826 \ + --end-block 9358826 \ + --output "sepolia_analysis.png" + +Highlighting Target Address Transactions +Track transactions involving a specific address (either sent from or to): + +$ polycli plot --rpc-url http://localhost:8545 \ + --target-address "0xeE76bECaF80fFe451c8B8AFEec0c21518Def02f9" \ + --start-block 1000 \ + --end-block 2000 + +Target transactions will be highlighted in the chart and logged during execution. + +Performance Options + +When fetching large block ranges, adjust rate limiting and concurrency: + +Process 10 blocks concurrently with 10 requests/second rate limit: +$ polycli plot --rpc-url http://localhost:8545 \ + --concurrency 10 \ + --rate-limit 10 \ + --start-block 1000 \ + --end-block 5000 + +Remove rate limiting entirely (use with caution): +$ polycli plot --rpc-url http://localhost:8545 \ + --rate-limit -1 \ + --concurrency 20 + +Chart Scale Options + +Choose between logarithmic (default) and linear scale for the gas price axis: + +Use linear scale for gas prices: +$ polycli plot --rpc-url http://localhost:8545 \ + --scale "linear" \ + --output "linear_chart.png" + +Use logarithmic scale (default): +$ polycli plot --rpc-url http://localhost:8545 \ + --scale "log" \ + --output "log_chart.png" + +Understanding the Chart + +The generated chart displays four key metrics: + +1. Transaction Gas Prices: Individual transaction gas prices plotted as points, with target address transactions highlighted +2. Transaction Gas Limits: Gas limits for individual transactions +3. Block Gas Limits: Maximum gas limit per block +4. Block Gas Used: Actual gas consumed per block + +Example Use Cases + +Analyzing gas price patterns during network congestion: +$ polycli plot --rpc-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY \ + --start-block 18000000 \ + --end-block 18001000 \ + --scale log \ + --output mainnet_congestion.png + +Tracking your contract deployment gas costs: +$ polycli plot --rpc-url http://localhost:8545 \ + --target-address 0xYourContractAddress \ + --output my_contract_gas.png + +Analyzing test network behavior: +$ polycli plot --rpc-url http://localhost:8545 \ + --concurrency 1 \ + --rate-limit 4 \ + --output local_test.png + +Notes + +- If --start-block is not set, the command analyzes the last 500 blocks +- If --end-block exceeds the latest block or is not set, it defaults to the latest block +- The command logs the 20 most frequently used gas prices at debug level +- Charts use PNG format and can be opened with any image viewer + +## Flags + +```bash + -c, --concurrency uint number of tasks to perform concurrently (default: one at a time) (default 1) + --end-block uint ending block number (inclusive) (default 18446744073709551615) + -h, --help help for plot + -o, --output string where to save the chart image (default: tx_gasprice_chart.png) (default "tx_gasprice_chart.png") + --rate-limit float requests per second limit (use negative value to remove limit) (default 4) + --rpc-url string RPC URL of network (default "http://localhost:8545") + --scale string scale for gas price axis (options: log, linear) (default "log") + --start-block uint starting block number (inclusive) (default 18446744073709551615) + --target-address string address that will have tx sent from or to highlighted in the chart +``` + +The command also inherits flags from parent commands. + +```bash + --config string config file (default is $HOME/.polygon-cli.yaml) + --pretty-logs output logs in pretty format instead of JSON (default true) + -v, --verbosity string log level (string or int): + 0 - silent + 100 - panic + 200 - fatal + 300 - error + 400 - warn + 500 - info (default) + 600 - debug + 700 - trace (default "info") +``` + +## See also + +- [polycli](polycli.md) - A Swiss Army knife of blockchain tools. diff --git a/go.mod b/go.mod index 8819fe4a9..230fd0700 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.24.0 toolchain go1.24.1 +require gonum.org/v1/plot v0.14.0 + require ( cloud.google.com/go/datastore v1.21.0 github.com/btcsuite/btcutil v1.0.3-0.20201208143702-a53e38424cce @@ -40,7 +42,10 @@ require ( require github.com/alecthomas/participle/v2 v2.1.4 require ( + git.sr.ht/~sbinet/gg v0.5.0 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 // indirect + github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b // indirect + github.com/campoy/embedmd v1.0.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -55,11 +60,15 @@ require ( github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect github.com/ferranbt/fastssz v0.1.4 // indirect github.com/gdamore/encoding v1.0.1 // indirect + github.com/go-fonts/liberation v0.3.1 // indirect github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect + github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 // indirect + github.com/go-pdf/fpdf v0.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -82,6 +91,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/image v0.11.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gotest.tools/v3 v3.5.2 // indirect ) @@ -202,7 +212,7 @@ require ( github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect github.com/dgraph-io/badger/v4 v4.2.0 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dustin/go-humanize v1.0.1 github.com/ethereum/go-verkle v0.2.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-kit/log v0.2.1 // indirect diff --git a/go.sum b/go.sum index e6e341a7b..d55154910 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,10 @@ cloud.google.com/go/kms v1.24.0 h1:SWltUuoPhTdv9q/P0YEAWQfoYT32O5HdfPgTiWMvrH8= cloud.google.com/go/kms v1.24.0/go.mod h1:QDH3z2SJ50lfNOE8EokKC1G40i7I0f8xTMCoiptcb5g= cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= +git.sr.ht/~sbinet/cmpimg v0.1.0 h1:E0zPRk2muWuCqSKSVZIWsgtU9pjsw3eKHi8VmQeScxo= +git.sr.ht/~sbinet/cmpimg v0.1.0/go.mod h1:FU12psLbF4TfNXkKH2ZZQ29crIqoiqTZmeQ7dkp/pxE= +git.sr.ht/~sbinet/gg v0.5.0 h1:6V43j30HM623V329xA9Ntq+WJrMjDxRjuAB1LFWF5m8= +git.sr.ht/~sbinet/gg v0.5.0/go.mod h1:G2C0eRESqlKhS7ErsNey6HHrqU1PwsnCQlekFi9Q2Oo= github.com/0xPolygon/cdk-contracts-tooling v0.0.1 h1:2HH8KpO1CZRl1zHfn0IYwJhPA7l91DOWrjdExmaB9Kk= github.com/0xPolygon/cdk-contracts-tooling v0.0.1/go.mod h1:mFlcEjsm2YBBsu8atHJ3zyVnwM+Z/fMXpVmIJge+WVU= github.com/0xPolygon/cdk-rpc v0.0.0-20250213125803-179882ad6229 h1:6YhqNQVcXkoxqs5zQVg1bREuoeKvwpffpfoL8QQT+u4= @@ -35,6 +39,10 @@ github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233 github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= +github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U= @@ -62,6 +70,8 @@ github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVa github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= +github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -214,10 +224,18 @@ github.com/gizak/termui/v3 v3.1.1-0.20231111080052-b3569a6cd52d h1:oAvxuiOB52vYA github.com/gizak/termui/v3 v3.1.1-0.20231111080052-b3569a6cd52d/go.mod h1:G7SWm+OY7CWC3dxqXjsPO4taVBtDDEO0otCjaLSlf/0= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-fonts/dejavu v0.1.0 h1:JSajPXURYqpr+Cu8U9bt8K+XcACIHWqWrvWCKyeFmVQ= +github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= +github.com/go-fonts/latin-modern v0.3.1 h1:/cT8A7uavYKvglYXvrdDw4oS5ZLkcOU22fa2HJ1/JVM= +github.com/go-fonts/latin-modern v0.3.1/go.mod h1:ysEQXnuT/sCDOAONxC7ImeEDVINbltClhasMAqEtRK0= +github.com/go-fonts/liberation v0.3.1 h1:9RPT2NhUpxQ7ukUvz3jeUckmN42T9D9TpjtQcqK/ceM= +github.com/go-fonts/liberation v0.3.1/go.mod h1:jdJ+cqF+F4SUL2V+qxBth8fvBpBDS7yloUL5Fi8GTGY= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 h1:NxXI5pTAtpEaU49bpLpQoDsu1zrteW/vxzTz8Cd2UAs= +github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9/go.mod h1:gWuR/CrFDDeVRFQwHPvsv9soJVB/iqymhuZQuJ3a9OM= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -228,6 +246,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-pdf/fpdf v0.8.0 h1:IJKpdaagnWUeSkUFUjTcSzTppFxmv8ucGQyNPQWxYOQ= +github.com/go-pdf/fpdf v0.8.0/go.mod h1:gfqhcNwXrsd3XYKte9a7vM3smvU/jB4ZRDrmWSxpfdc= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= @@ -246,6 +266,8 @@ github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3 h1: github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3/go.mod h1:nPpo7qLxd6XL3hWJG/O60sR8ZKfMCiIoNap5GvD12KU= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= @@ -621,6 +643,8 @@ golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= +golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo= +golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -678,6 +702,7 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -727,6 +752,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -736,6 +762,8 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/plot v0.14.0 h1:+LBDVFYwFe4LHhdP8coW6296MBEY4nQ+Y4vuUpJopcE= +gonum.org/v1/plot v0.14.0/go.mod h1:MLdR9424SJed+5VqC6MsouEpig9pZX2VZ57H9ko2bXU= google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4= google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= @@ -790,7 +818,10 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= launchpad.net/gocheck v0.0.0-20140225173054-000000000087 h1:Izowp2XBH6Ya6rv+hqbceQyw/gSGoXfH/UPoTGduL54= launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM= lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/loadtest/config/config.go b/loadtest/config/config.go index e66b1761e..c6a4c2e4f 100644 --- a/loadtest/config/config.go +++ b/loadtest/config/config.go @@ -110,6 +110,9 @@ type Config struct { // UniswapV3-specific config (set by uniswapv3 subcommand) UniswapV3 *UniswapV3Config + // Gas manager config (optional, for gas oscillation features) + GasManager *GasManagerConfig + // Computed fields (populated during initialization) CurrentGasPrice *big.Int CurrentGasTipCap *big.Int @@ -125,6 +128,21 @@ type Config struct { BigGasPriceMultiplier *big.Float } +// GasManagerConfig holds gas manager configuration for oscillation waves and pricing strategies. +type GasManagerConfig struct { + // Oscillation wave options + OscillationWave string // flat, sine, square, triangle, sawtooth + Target uint64 // target gas limit baseline + Period uint64 // period in blocks + Amplitude uint64 // amplitude of oscillation + + // Pricing strategy options + PriceStrategy string // estimated, fixed, dynamic + FixedGasPriceWei uint64 // for fixed strategy + DynamicGasPricesWei string // comma-separated prices for dynamic strategy + DynamicGasPricesVariation float64 // ±percentage variation for dynamic +} + // UniswapV3Config holds UniswapV3-specific configuration. type UniswapV3Config struct { // Pre-deployed contract addresses (as hex strings). @@ -195,3 +213,22 @@ func (c *UniswapV3Config) Validate() error { return nil } + +// Validate validates the GasManagerConfig and returns an error if any validation fails. +func (c *GasManagerConfig) Validate() error { + switch c.OscillationWave { + case "flat", "sine", "square", "triangle", "sawtooth": + // Valid wave type. + default: + return fmt.Errorf("invalid oscillation wave type: %s", c.OscillationWave) + } + + switch c.PriceStrategy { + case "estimated", "fixed", "dynamic": + // Valid strategy. + default: + return fmt.Errorf("invalid price strategy: %s", c.PriceStrategy) + } + + return nil +} diff --git a/loadtest/gasmanager/README.md b/loadtest/gasmanager/README.md new file mode 100644 index 000000000..6fa4137cc --- /dev/null +++ b/loadtest/gasmanager/README.md @@ -0,0 +1,282 @@ +# Gas Manager + +The Gas Manager package provides sophisticated control over transaction gas usage and pricing during load testing. It acts as a throttling and pricing mechanism that helps simulate realistic blockchain transaction patterns, including oscillating gas limits and dynamic gas pricing strategies. + +## Overview + +The Gas Manager package consists of three main components that work together: + +1. **Gas Vault**: A budget-based gas limit controller +2. **Gas Provider**: Supplies gas budget to the vault based on configurable patterns +3. **Gas Pricer**: Determines transaction gas prices using different strategies + +## Architecture + +### Gas Vault + +The `GasVault` acts as a semaphore/throttle mechanism that stores a gas budget and allows controlled spending: + +- **`AddGas(uint64)`**: Adds gas to the available budget +- **`SpendOrWaitAvailableBudget(context.Context, uint64) error`**: Attempts to spend gas; blocks if insufficient budget is available or until context is cancelled +- **`GetAvailableBudget()`**: Returns current available gas budget + +The vault prevents overspending by blocking transaction sending until sufficient budget is replenished by the gas provider. + +### Gas Provider + +The `GasProvider` interface defines how gas budget is supplied to the vault. The package includes an `OscillatingGasProvider` implementation that: + +- Monitors blockchain for new blocks +- Uses configurable wave patterns to determine gas budget per block +- Automatically adds gas to the vault when new blocks are detected + +### Gas Pricer + +The `GasPricer` determines gas prices for transactions using pluggable strategies: + +**Available Strategies:** + +1. **Estimated** (default): Uses network-suggested gas price via `eth_gasPrice` +2. **Fixed**: Always returns a constant gas price +3. **Dynamic**: Cycles through a list of predefined gas prices with random variation + +## Wave Patterns + +The Gas Manager supports multiple oscillation wave patterns to simulate varying network conditions: + +### Flat Wave + +Maintains constant gas limit per block: +y = Target + +### Sine Wave + +Smooth oscillation following a sinusoidal pattern: +y = Amplitude × sin(2π/Period × x) + Target + +### Square Wave + +Alternates between high and low values: +y = Target ± Amplitude (alternates at half-period intervals) + +### Triangle Wave + +Linear increase and decrease: +y increases from (Target - Amplitude) to (Target + Amplitude) + then decreases back linearly over the period + +### Sawtooth Wave + +Linear increase with sharp drop: +y increases linearly from (Target - Amplitude) to (Target + Amplitude) + then resets sharply + +## Usage in Loadtest + +The `polycli loadtest` command integrates the Gas Manager through several flags: + +### Gas Limit Control (Wave Configuration) + +```bash +# Configure the oscillation wave pattern +--gas-manager-oscillation-wave string # Wave type: flat, sine, square, triangle, sawtooth (default: "flat") +--gas-manager-target uint64 # Target gas limit baseline (default: 30000000) +--gas-manager-period uint64 # Period in blocks for wave oscillation (default: 1) +--gas-manager-amplitude uint64 # Amplitude of oscillation (default: 0) +``` + +### Gas Price Control (Pricing Strategy) + +```bash +# Select and configure pricing strategy +--gas-manager-price-strategy string # Strategy: estimated, fixed, dynamic (default: "estimated") +--gas-manager-fixed-gas-price-wei uint64 # Fixed price in wei (default: 300000000) +--gas-manager-dynamic-gas-prices-wei string # Comma-separated prices for dynamic strategy + # Use 0 for network-suggested price + # (default: "0,1000000,0,10000000,0,100000000") +--gas-manager-dynamic-gas-prices-variation float64 # Variation ±percentage for dynamic prices (default: 0.3) +``` + +## Examples + +### Example 1: Constant Load with Fixed Gas Price + +Simulate steady transaction flow with predictable gas price: + +```bash +polycli loadtest \ + --rpc-url http://localhost:8545 \ + --gas-manager-oscillation-wave flat \ + --gas-manager-target 30000000 \ + --gas-manager-price-strategy fixed \ + --gas-manager-fixed-gas-price-wei 50000000000 +``` + +Result: Sends up to 30M gas per block at constant 50 Gwei + +### Example 2: Sine Wave with Estimated Pricing + +Simulate gradually increasing/decreasing load following network gas prices: + +```bash +polycli loadtest \ + --rpc-url http://localhost:8545 \ + --gas-manager-oscillation-wave sine \ + --gas-manager-target 20000000 \ + --gas-manager-amplitude 10000000 \ + --gas-manager-period 100 \ + --gas-manager-price-strategy estimated + ``` + +Result: Gas limit oscillates between 10M and 30M over 100 blocks using network gas prices + +### Example 3: Square Wave Traffic Pattern + +Simulate bursty traffic with alternating high/low load: + +```bash +polycli loadtest \ + --rpc-url http://localhost:8545 \ + --gas-manager-oscillation-wave square \ + --gas-manager-target 25000000 \ + --gas-manager-amplitude 15000000 \ + --gas-manager-period 20 \ + --gas-manager-price-strategy estimated +``` + +Result: Alternates between 10M gas (low) and 40M gas (high) every 10 blocks + +### Example 4: Dynamic Gas Prices with Variation + +Simulate diverse user behavior with varying gas prices: + +```bash +polycli loadtest \ + --rpc-url http://localhost:8545 \ + --gas-manager-oscillation-wave flat \ + --gas-manager-target 30000000 \ + --gas-manager-price-strategy dynamic \ + --gas-manager-dynamic-gas-prices-wei "1000000000,5000000000,10000000000,0" \ + --gas-manager-dynamic-gas-prices-variation 0.2 +``` + +Result: Cycles through prices: 1 Gwei, 5 Gwei, 10 Gwei, and network-suggested, each with ±20% random variation + +### Example 5: Stress Test with Sawtooth Pattern + +Gradually increase load then reset, simulating growing congestion: + +```bash +polycli loadtest \ + --rpc-url http://localhost:8545 \ + --gas-manager-oscillation-wave sawtooth \ + --gas-manager-target 20000000 \ + --gas-manager-amplitude 15000000 \ + --gas-manager-period 50 \ + --gas-manager-price-strategy dynamic \ + --gas-manager-dynamic-gas-prices-wei "0,2000000000,0,5000000000,0,10000000000" +``` + +Result: Gas limit ramps from 5M to 35M over 50 blocks, then resets; alternates between network price and fixed prices + +## Visualization with plot + +You can visualize the gas patterns generated by your loadtest using the plot command: + +### Run loadtest with sine wave pattern + +```bash +polycli loadtest \ + --rpc-url http://localhost:8545 \ + --gas-manager-oscillation-wave sine \ + --gas-manager-period 100 \ + --gas-manager-amplitude 10000000 \ + --target-address 0xYourAddress +``` + +### Generate chart to visualize results + +```bash +polycli plot \ + --rpc-url http://localhost:8545 \ + --target-address 0xYourAddress \ + --output sine_wave_result.png +``` + +## How It Works + +### Initialization Flow + +1. Setup Gas Vault: Creates vault with zero initial budget +2. Configure Wave: Instantiates selected wave pattern with period, amplitude, and target +3. Create Gas Provider: Wraps wave pattern in OscillatingGasProvider +4. Start Provider: + -> Begins watching for new blocks (polls every 1 second) + -> When first block header is fetched: adds initial gas budget based on wave's starting Y value + -> Note: Vault remains at zero budget until first header is received (~1 second delay) +5. Setup Gas Pricer: Creates pricer with selected strategy + +### Transaction Flow + +When a transaction is ready to send: + +1. Gas Limit Decision: + -> Loadtest calls gasVault.SpendOrWaitAvailableBudget(ctx, gasLimit) + -> If vault has sufficient budget: deducts amount and returns nil + -> If vault lacks budget: blocks until provider adds more gas + -> If context is cancelled: returns ctx.Err() for graceful shutdown +2. Gas Price Decision: + -> Loadtest calls gasPricer.GetGasPrice() + -> Returns price based on selected strategy: + -> Estimated: Returns nil (loadtest queries network) + -> Fixed: Returns configured constant + -> Dynamic: Returns next price in sequence with variation +3. Block Progression: + -> When new block is detected, provider's onNewHeader callback fires + -> Wave advances to next position via MoveNext() + -> Provider adds gas equal to wave's new Y value to vault + +### Throttling Behavior + +The Gas Manager creates realistic transaction throttling: + +- High wave values → More gas budget → More concurrent transactions +- Low wave values → Less gas budget → Throttled transaction sending +- Zero budget → Complete blocking until next block + +This simulates network congestion and varying block space availability. + +## Implementation Notes + +- Gas Vault uses mutex for thread-safe budget management +- Gas Provider watches blocks with 1-second polling interval +- Wave patterns pre-compute all points during initialization +- Dynamic gas prices use atomic operations for thread-safe index management +- Zero gas price in dynamic strategy indicates "use network-suggested price" +- Variation in dynamic strategy applies random multiplier: price × (1 ± variation%) + +## Configuration Tips + +For realistic network simulation: + +- Use sine or triangle waves with period matching expected congestion cycles +- Use estimated pricing to follow real market conditions +- Set target to average block gas limit + +For stress testing: + +- Use square waves for sudden load changes +- Use sawtooth for gradually increasing stress +- Use dynamic pricing with high variation to test diverse scenarios + +For consistent benchmarking: + +- Use flat wave with zero amplitude +- Use fixed pricing for reproducible results +- Set target to desired constant load + +See Also + +- [Loadtest Usage](../../cmd/loadtest/loadtestUsage.md) +- [plot Usage](../../cmd/plot/usage.md) +- [Wave visualization examples](../../cmd/plot/examples/) \ No newline at end of file diff --git a/loadtest/gasmanager/gas_pricer.go b/loadtest/gasmanager/gas_pricer.go new file mode 100644 index 000000000..3f3c6ac97 --- /dev/null +++ b/loadtest/gasmanager/gas_pricer.go @@ -0,0 +1,23 @@ +package gasmanager + +// PriceStrategy defines the interface for different gas price strategies. +type PriceStrategy interface { + GetGasPrice() *uint64 +} + +// GasPricer uses a PriceStrategy to determine the gas price. +type GasPricer struct { + strategy PriceStrategy +} + +// NewGasPricer creates a new GasPricer with the given PriceStrategy. +func NewGasPricer(strategy PriceStrategy) *GasPricer { + return &GasPricer{ + strategy: strategy, + } +} + +// GetGasPrice retrieves the gas price using the configured PriceStrategy. +func (gp *GasPricer) GetGasPrice() *uint64 { + return gp.strategy.GetGasPrice() +} diff --git a/loadtest/gasmanager/gas_pricer_test.go b/loadtest/gasmanager/gas_pricer_test.go new file mode 100644 index 000000000..8f939dba4 --- /dev/null +++ b/loadtest/gasmanager/gas_pricer_test.go @@ -0,0 +1,360 @@ +package gasmanager + +import ( + "math" + "testing" +) + +func TestNewGasPricer(t *testing.T) { + strategy := NewEstimatedGasPriceStrategy() + pricer := NewGasPricer(strategy) + + if pricer == nil { + t.Fatal("NewGasPricer returned nil") + } + if pricer.strategy == nil { + t.Fatal("GasPricer strategy is nil") + } +} + +func TestGasPricer_GetGasPrice(t *testing.T) { + fixedPrice := uint64(1000000000) // 1 Gwei + strategy := NewFixedGasPriceStrategy(FixedGasPriceConfig{ + GasPriceWei: fixedPrice, + }) + pricer := NewGasPricer(strategy) + + price := pricer.GetGasPrice() + if price == nil { + t.Fatal("GetGasPrice returned nil") + } + if *price != fixedPrice { + t.Errorf("Expected gas price %d, got %d", fixedPrice, *price) + } +} + +// Estimated Gas Price Strategy Tests + +func TestEstimatedGasPriceStrategy(t *testing.T) { + strategy := NewEstimatedGasPriceStrategy() + if strategy == nil { + t.Fatal("NewEstimatedGasPriceStrategy returned nil") + } +} + +func TestEstimatedGasPriceStrategy_GetGasPrice(t *testing.T) { + strategy := NewEstimatedGasPriceStrategy() + price := strategy.GetGasPrice() + + // Estimated strategy should return nil to indicate network price should be used + if price != nil { + t.Errorf("Expected nil (network-estimated price), got %v", price) + } +} + +// Fixed Gas Price Strategy Tests + +func TestFixedGasPriceStrategy(t *testing.T) { + tests := []struct { + name string + gasPrice uint64 + }{ + {"1 Gwei", 1000000000}, + {"50 Gwei", 50000000000}, + {"100 Gwei", 100000000000}, + {"zero", 0}, + {"max uint64", math.MaxUint64}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + strategy := NewFixedGasPriceStrategy(FixedGasPriceConfig{ + GasPriceWei: tt.gasPrice, + }) + + if strategy == nil { + t.Fatal("NewFixedGasPriceStrategy returned nil") + } + + price := strategy.GetGasPrice() + if price == nil { + t.Fatal("GetGasPrice returned nil") + } + if *price != tt.gasPrice { + t.Errorf("Expected gas price %d, got %d", tt.gasPrice, *price) + } + }) + } +} + +func TestFixedGasPriceStrategy_ConsistentPrice(t *testing.T) { + fixedPrice := uint64(25000000000) // 25 Gwei + strategy := NewFixedGasPriceStrategy(FixedGasPriceConfig{ + GasPriceWei: fixedPrice, + }) + + // Call multiple times and verify price is always the same + for i := 0; i < 100; i++ { + price := strategy.GetGasPrice() + if price == nil { + t.Fatal("GetGasPrice returned nil") + } + if *price != fixedPrice { + t.Errorf("Call %d: expected gas price %d, got %d", i, fixedPrice, *price) + } + } +} + +// Dynamic Gas Price Strategy Tests + +func TestDynamicGasPriceStrategy_InvalidConfig(t *testing.T) { + tests := []struct { + name string + gasPrices []uint64 + }{ + {"empty slice", []uint64{}}, + {"nil slice", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + strategy, err := NewDynamicGasPriceStrategy(DynamicGasPriceConfig{ + GasPrices: tt.gasPrices, + Variation: 0.3, + }) + + if err == nil { + t.Error("Expected error for empty gas prices, got nil") + } + if strategy != nil { + t.Error("Expected nil strategy for invalid config, got non-nil") + } + }) + } +} + +func TestDynamicGasPriceStrategy_SinglePrice(t *testing.T) { + basePrice := uint64(5000000000) // 5 Gwei + strategy, err := NewDynamicGasPriceStrategy(DynamicGasPriceConfig{ + GasPrices: []uint64{basePrice}, + Variation: 0.2, // ±20% + }) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Call multiple times, should always use the same base price (with variation) + for i := 0; i < 10; i++ { + price := strategy.GetGasPrice() + if price == nil { + t.Fatal("GetGasPrice returned nil") + } + + // Price should be within ±20% of base price + minPrice := uint64(float64(basePrice) * 0.8) + maxPrice := uint64(float64(basePrice) * 1.2) + if *price < minPrice || *price > maxPrice { + t.Errorf("Price %d is outside expected range [%d, %d]", *price, minPrice, maxPrice) + } + } +} + +func TestDynamicGasPriceStrategy_Cycling(t *testing.T) { + gasPrices := []uint64{1000000000, 2000000000, 3000000000} // 1, 2, 3 Gwei + strategy, err := NewDynamicGasPriceStrategy(DynamicGasPriceConfig{ + GasPrices: gasPrices, + Variation: 0, // No variation for predictable testing + }) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Should cycle through prices in order + for round := 0; round < 3; round++ { + for i, expectedBase := range gasPrices { + price := strategy.GetGasPrice() + if price == nil { + t.Fatalf("Round %d, index %d: GetGasPrice returned nil", round, i) + } + if *price != expectedBase { + t.Errorf("Round %d, index %d: expected %d, got %d", round, i, expectedBase, *price) + } + } + } +} + +func TestDynamicGasPriceStrategy_ZeroPriceReturnsNil(t *testing.T) { + gasPrices := []uint64{1000000000, 0, 2000000000} // Middle one is 0 + strategy, err := NewDynamicGasPriceStrategy(DynamicGasPriceConfig{ + GasPrices: gasPrices, + Variation: 0, + }) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // First call: non-zero price + price := strategy.GetGasPrice() + if price == nil || *price != 1000000000 { + t.Errorf("Expected 1000000000, got %v", price) + } + + // Second call: zero means use network price (nil) + price = strategy.GetGasPrice() + if price != nil { + t.Errorf("Expected nil for zero gas price, got %v", price) + } + + // Third call: back to non-zero + price = strategy.GetGasPrice() + if price == nil || *price != 2000000000 { + t.Errorf("Expected 2000000000, got %v", price) + } +} + +func TestDynamicGasPriceStrategy_VariationRange(t *testing.T) { + basePrice := uint64(10000000000) // 10 Gwei + variation := 0.3 // ±30% + strategy, err := NewDynamicGasPriceStrategy(DynamicGasPriceConfig{ + GasPrices: []uint64{basePrice}, + Variation: variation, + }) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + minExpected := uint64(float64(basePrice) * (1 - variation)) + maxExpected := uint64(float64(basePrice) * (1 + variation)) + + // Sample many times to check range + samples := 1000 + foundMin := false + foundMax := false + + for i := 0; i < samples; i++ { + price := strategy.GetGasPrice() + if price == nil { + t.Fatal("GetGasPrice returned nil") + } + + // Check within valid range + if *price < minExpected || *price > maxExpected { + t.Errorf("Price %d is outside expected range [%d, %d]", *price, minExpected, maxExpected) + } + + // Track if we've seen prices near the extremes + if *price <= minExpected+uint64(float64(basePrice)*0.05) { + foundMin = true + } + if *price >= maxExpected-uint64(float64(basePrice)*0.05) { + foundMax = true + } + } + + // With 1000 samples, we should see prices across the range + if !foundMin || !foundMax { + t.Logf("Warning: In %d samples, didn't see full range. foundMin=%v, foundMax=%v", samples, foundMin, foundMax) + } +} + +func TestDynamicGasPriceStrategy_NoVariation(t *testing.T) { + gasPrices := []uint64{5000000000, 10000000000} + strategy, err := NewDynamicGasPriceStrategy(DynamicGasPriceConfig{ + GasPrices: gasPrices, + Variation: 0, // No variation + }) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // With no variation, should return exact prices + price := strategy.GetGasPrice() + if price == nil || *price != gasPrices[0] { + t.Errorf("Expected exact price %d, got %v", gasPrices[0], price) + } + + price = strategy.GetGasPrice() + if price == nil || *price != gasPrices[1] { + t.Errorf("Expected exact price %d, got %v", gasPrices[1], price) + } +} + +func TestDynamicGasPriceStrategy_HighVariation(t *testing.T) { + basePrice := uint64(1000000000) // 1 Gwei + variation := 0.9 // ±90% + strategy, err := NewDynamicGasPriceStrategy(DynamicGasPriceConfig{ + GasPrices: []uint64{basePrice}, + Variation: variation, + }) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + minExpected := uint64(float64(basePrice) * (1 - variation)) + maxExpected := uint64(float64(basePrice) * (1 + variation)) + + // Check that high variation still stays within bounds + for i := 0; i < 100; i++ { + price := strategy.GetGasPrice() + if price == nil { + t.Fatal("GetGasPrice returned nil") + } + + if *price < minExpected || *price > maxExpected { + t.Errorf("Price %d is outside expected range [%d, %d]", *price, minExpected, maxExpected) + } + } +} + +func TestDynamicGasPriceStrategy_ConcurrentAccess(t *testing.T) { + gasPrices := []uint64{1000000000, 2000000000, 3000000000} + strategy, err := NewDynamicGasPriceStrategy(DynamicGasPriceConfig{ + GasPrices: gasPrices, + Variation: 0.1, + }) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Launch multiple goroutines calling GetGasPrice concurrently + const numGoroutines = 100 + results := make(chan *uint64, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func() { + price := strategy.GetGasPrice() + results <- price + }() + } + + // Collect all results + prices := make([]*uint64, 0, numGoroutines) + for i := 0; i < numGoroutines; i++ { + price := <-results + if price == nil { + t.Error("GetGasPrice returned nil in concurrent access") + } + prices = append(prices, price) + } + + // Verify all prices are valid (no panics, no invalid values) + for i, price := range prices { + if price == nil { + continue // Already reported above + } + + // Should be within reasonable bounds (base prices with variation) + minValid := uint64(float64(gasPrices[0]) * 0.9) + maxValid := uint64(float64(gasPrices[len(gasPrices)-1]) * 1.1) + if *price < minValid || *price > maxValid { + t.Errorf("Price %d at index %d is outside reasonable bounds [%d, %d]", *price, i, minValid, maxValid) + } + } +} diff --git a/loadtest/gasmanager/gas_provider.go b/loadtest/gasmanager/gas_provider.go new file mode 100644 index 000000000..a9b4f3b3d --- /dev/null +++ b/loadtest/gasmanager/gas_provider.go @@ -0,0 +1,70 @@ +package gasmanager + +import ( + "context" + "time" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/rs/zerolog/log" +) + +// GasProvider defines the interface for gas providers. +type GasProvider interface { + Start(ctx context.Context) +} + +// GasProviderBase provides common functionality for gas providers. +type GasProviderBase struct { + client *ethclient.Client + vault *GasVault + onNewHeader func(header *types.Header) +} + +// NewGasProviderBase creates a new GasProviderBase with the given Ethereum client and gas vault. +func NewGasProviderBase(client *ethclient.Client, vault *GasVault) *GasProviderBase { + return &GasProviderBase{ + client: client, + vault: vault, + } +} + +// Start begins the operation of the GasProviderBase by starting to watch for new block headers. +func (o *GasProviderBase) Start(ctx context.Context) { + log.Trace().Msg("Starting GasProviderBase") + go o.watchNewHeaders(ctx) +} + +// watchNewHeaders continuously monitors for new block headers and triggers the onNewHeader callback when a new header is detected. +func (o *GasProviderBase) watchNewHeaders(ctx context.Context) { + if o.onNewHeader == nil { + return + } + + const pollInterval = 1 * time.Second + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + log.Trace().Msg("Starting to watch for new block headers") + var lastHeader *types.Header + + for { + select { + case <-ctx.Done(): + log.Trace().Msg("Stopping block header watch") + return + case <-ticker.C: + header, err := o.client.HeaderByNumber(ctx, nil) + if err != nil { + log.Warn().Err(err).Msg("Failed to fetch latest block header, retrying") + continue + } + + if lastHeader == nil || header.Number.Cmp(lastHeader.Number) > 0 { + log.Trace().Uint64("block_number", header.Number.Uint64()).Msg("New block header detected") + o.onNewHeader(header) + lastHeader = header + } + } + } +} diff --git a/loadtest/gasmanager/gas_provider_oscillating.go b/loadtest/gasmanager/gas_provider_oscillating.go new file mode 100644 index 000000000..5caaa86af --- /dev/null +++ b/loadtest/gasmanager/gas_provider_oscillating.go @@ -0,0 +1,48 @@ +package gasmanager + +import ( + "context" + "math" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/rs/zerolog/log" +) + +// OscillatingGasProvider is a gas provider that adds gas to the vault based on an oscillating wave pattern. +type OscillatingGasProvider struct { + GasProviderBase + wave Wave +} + +// NewOscillatingGasProvider creates a new OscillatingGasProvider with the given Ethereum client, gas vault, and wave pattern. +// It also sets up the necessary callbacks for starting and processing new block headers. +func NewOscillatingGasProvider(client *ethclient.Client, vault *GasVault, wave Wave) *OscillatingGasProvider { + p := &OscillatingGasProvider{ + GasProviderBase: *NewGasProviderBase(client, vault), + wave: wave, + } + + p.GasProviderBase.onNewHeader = p.onNewHeader + return p +} + +// Start begins the operation of the OscillatingGasProvider by invoking the Start method of its base class. +func (o *OscillatingGasProvider) Start(ctx context.Context) { + o.GasProviderBase.Start(ctx) +} + +// onNewHeader is called when a new block header is received. +// It advances the wave and adds gas to the vault based on the new Y value of the wave. +func (o *OscillatingGasProvider) onNewHeader(header *types.Header) { + log.Trace().Uint64("block_number", header.Number.Uint64()).Msg("Processing new block header") + if o.vault != nil { + gasAmount := uint64(math.Floor(o.wave.Y())) + o.vault.AddGas(gasAmount) + log.Trace(). + Uint64("gas_added", gasAmount). + Uint64("available_budget", o.vault.GetAvailableBudget()). + Msg("Gas added from oscillation wave") + } + o.wave.MoveNext() +} diff --git a/loadtest/gasmanager/gas_provider_test.go b/loadtest/gasmanager/gas_provider_test.go new file mode 100644 index 000000000..ea1232034 --- /dev/null +++ b/loadtest/gasmanager/gas_provider_test.go @@ -0,0 +1,350 @@ +package gasmanager + +import ( + "math" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/core/types" +) + +func TestOscillatingGasProvider_NewOscillatingGasProvider(t *testing.T) { + vault := NewGasVault() + wave := NewFlatWave(WaveConfig{ + Period: 10, + Amplitude: 1000, + Target: 5000, + }) + + provider := NewOscillatingGasProvider(nil, vault, wave) + + if provider == nil { + t.Fatal("NewOscillatingGasProvider returned nil") + } + if provider.vault != vault { + t.Error("Provider vault doesn't match provided vault") + } + if provider.wave != wave { + t.Error("Provider wave doesn't match provided wave") + } +} + +func TestOscillatingGasProvider_OnNewHeader_AddsGasToVault(t *testing.T) { + vault := NewGasVault() + initialBudget := vault.GetAvailableBudget() + + // Create a flat wave with target 5000 + wave := NewFlatWave(WaveConfig{ + Period: 10, + Amplitude: 0, + Target: 5000, + }) + + provider := NewOscillatingGasProvider(nil, vault, wave) + + // Simulate receiving a new block header + header := &types.Header{ + Number: big.NewInt(100), + } + + provider.onNewHeader(header) + + // Verify gas was added to vault + newBudget := vault.GetAvailableBudget() + if newBudget == initialBudget { + t.Error("Gas was not added to vault after new header") + } + if newBudget != 5000 { + t.Errorf("Expected budget 5000, got %d", newBudget) + } +} + +func TestOscillatingGasProvider_OnNewHeader_AdvancesWave(t *testing.T) { + vault := NewGasVault() + + // Create a wave where we can track position changes + // Using a sawtooth with period 3, we can check Y values change + wave := NewSawtoothWave(WaveConfig{ + Period: 3, + Amplitude: 1000, + Target: 2000, + }) + + provider := NewOscillatingGasProvider(nil, vault, wave) + + // Get initial Y value + initialY := wave.Y() + + // Simulate receiving multiple headers + for i := 0; i < 3; i++ { + header := &types.Header{ + Number: big.NewInt(int64(100 + i)), + } + provider.onNewHeader(header) + } + + // After 3 headers (one complete period), X should have wrapped + // and Y should be different from initial + finalX := wave.X() + if finalX != 0 { + t.Errorf("Expected wave X to wrap to 0 after period, got %f", finalX) + } + + // Initial Y should equal final Y after one complete period + finalY := wave.Y() + if finalY != initialY { + t.Logf("Initial Y: %f, Final Y: %f", initialY, finalY) + } +} + +func TestOscillatingGasProvider_OnNewHeader_AccumulatesGas(t *testing.T) { + vault := NewGasVault() + + // Flat wave adds same amount each time + wave := NewFlatWave(WaveConfig{ + Period: 1, + Amplitude: 0, + Target: 1000, + }) + + provider := NewOscillatingGasProvider(nil, vault, wave) + + // Simulate multiple blocks + numBlocks := 5 + for i := 0; i < numBlocks; i++ { + header := &types.Header{ + Number: big.NewInt(int64(i)), + } + provider.onNewHeader(header) + } + + // Total gas should be numBlocks * target + expectedTotal := uint64(numBlocks * 1000) + actualTotal := vault.GetAvailableBudget() + if actualTotal != expectedTotal { + t.Errorf("Expected accumulated gas %d, got %d", expectedTotal, actualTotal) + } +} + +func TestOscillatingGasProvider_OnNewHeader_HandlesFlooring(t *testing.T) { + vault := NewGasVault() + + // Create a sine wave that will produce non-integer Y values + wave := NewSineWave(WaveConfig{ + Period: 100, + Amplitude: 500, + Target: 1000, + }) + + provider := NewOscillatingGasProvider(nil, vault, wave) + + header := &types.Header{ + Number: big.NewInt(25), // Quarter period, should be at peak + } + + initialY := wave.Y() + provider.onNewHeader(header) + + // Gas added should be floor of Y value + expectedGas := uint64(math.Floor(initialY)) + actualGas := vault.GetAvailableBudget() + + if actualGas != expectedGas { + t.Errorf("Expected gas %d (floor of %f), got %d", expectedGas, initialY, actualGas) + } +} + +func TestOscillatingGasProvider_OnNewHeader_WithSineWave(t *testing.T) { + vault := NewGasVault() + + wave := NewSineWave(WaveConfig{ + Period: 4, + Amplitude: 1000, + Target: 2000, + }) + + provider := NewOscillatingGasProvider(nil, vault, wave) + + gasAmounts := make([]uint64, 0, 4) + + // Collect gas amounts for one complete period + for i := 0; i < 4; i++ { + initialBudget := vault.GetAvailableBudget() + header := &types.Header{ + Number: big.NewInt(int64(i)), + } + provider.onNewHeader(header) + newBudget := vault.GetAvailableBudget() + gasAdded := newBudget - initialBudget + gasAmounts = append(gasAmounts, gasAdded) + } + + // Verify that gas amounts vary (sine wave oscillates) + allSame := true + first := gasAmounts[0] + for _, amount := range gasAmounts[1:] { + if amount != first { + allSame = false + break + } + } + + if allSame { + t.Error("Sine wave should produce varying gas amounts, but all were the same") + } +} + +func TestOscillatingGasProvider_OnNewHeader_WithSquareWave(t *testing.T) { + vault := NewGasVault() + + wave := NewSquareWave(WaveConfig{ + Period: 4, + Amplitude: 1000, + Target: 2000, + }) + + provider := NewOscillatingGasProvider(nil, vault, wave) + + gasAmounts := make([]uint64, 0, 4) + + // Collect gas amounts for one complete period + for i := 0; i < 4; i++ { + initialBudget := vault.GetAvailableBudget() + header := &types.Header{ + Number: big.NewInt(int64(i)), + } + provider.onNewHeader(header) + newBudget := vault.GetAvailableBudget() + gasAdded := newBudget - initialBudget + gasAmounts = append(gasAmounts, gasAdded) + } + + // Square wave should have two distinct values + // First half should be one value, second half another + firstHalf := gasAmounts[:2] + secondHalf := gasAmounts[2:] + + // Check first half is consistent + if firstHalf[0] != firstHalf[1] { + t.Errorf("First half of square wave should be consistent, got %d and %d", firstHalf[0], firstHalf[1]) + } + + // Check second half is consistent + if secondHalf[0] != secondHalf[1] { + t.Errorf("Second half of square wave should be consistent, got %d and %d", secondHalf[0], secondHalf[1]) + } + + // Check that first and second halves are different + if firstHalf[0] == secondHalf[0] { + t.Error("Square wave first and second halves should be different") + } +} + +func TestOscillatingGasProvider_OnNewHeader_WithTriangleWave(t *testing.T) { + vault := NewGasVault() + + wave := NewTriangleWave(WaveConfig{ + Period: 6, + Amplitude: 1000, + Target: 2000, + }) + + provider := NewOscillatingGasProvider(nil, vault, wave) + + gasAmounts := make([]uint64, 0, 6) + + // Collect gas amounts for one complete period + for i := 0; i < 6; i++ { + initialBudget := vault.GetAvailableBudget() + header := &types.Header{ + Number: big.NewInt(int64(i)), + } + provider.onNewHeader(header) + newBudget := vault.GetAvailableBudget() + gasAdded := newBudget - initialBudget + gasAmounts = append(gasAmounts, gasAdded) + } + + // Triangle wave should increase then decrease + // Check that first half generally increases + increasing := true + for i := 1; i < 3; i++ { + if gasAmounts[i] < gasAmounts[i-1] { + increasing = false + break + } + } + + if !increasing { + t.Error("First half of triangle wave should generally increase") + } + + // Check that second half generally decreases + decreasing := true + for i := 4; i < 6; i++ { + if gasAmounts[i] > gasAmounts[i-1] { + decreasing = false + break + } + } + + if !decreasing { + t.Error("Second half of triangle wave should generally decrease") + } +} + +func TestOscillatingGasProvider_OnNewHeader_NilVault(t *testing.T) { + wave := NewFlatWave(WaveConfig{ + Period: 10, // Use period > 1 so X doesn't wrap + Amplitude: 0, + Target: 1000, + }) + + // Create provider with nil vault + provider := NewOscillatingGasProvider(nil, nil, wave) + + header := &types.Header{ + Number: big.NewInt(100), + } + + // Should not panic with nil vault + defer func() { + if r := recover(); r != nil { + t.Errorf("onNewHeader panicked with nil vault: %v", r) + } + }() + + provider.onNewHeader(header) + + // Wave should still advance + if wave.X() != 1 { + t.Errorf("Wave should advance even with nil vault, expected X=1, got X=%f", wave.X()) + } +} + +func TestOscillatingGasProvider_OnNewHeader_LargeGasAmount(t *testing.T) { + vault := NewGasVault() + + // Create wave with very large target + wave := NewFlatWave(WaveConfig{ + Period: 1, + Amplitude: 0, + Target: math.MaxUint64 / 2, + }) + + provider := NewOscillatingGasProvider(nil, vault, wave) + + // Add gas twice + for i := 0; i < 2; i++ { + header := &types.Header{ + Number: big.NewInt(int64(i)), + } + provider.onNewHeader(header) + } + + // Should cap at max uint64 (tested in vault tests, but verify here too) + budget := vault.GetAvailableBudget() + if budget < math.MaxUint64/2 { + t.Errorf("Expected budget at least %d, got %d", math.MaxUint64/2, budget) + } +} diff --git a/loadtest/gasmanager/gas_vault.go b/loadtest/gasmanager/gas_vault.go new file mode 100644 index 000000000..95fb88704 --- /dev/null +++ b/loadtest/gasmanager/gas_vault.go @@ -0,0 +1,73 @@ +package gasmanager + +import ( + "context" + "math" + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +// GasVault manages a budget of gas that can be added to and spent from. +type GasVault struct { + mu sync.Mutex + gasBudgetAvailable uint64 +} + +// NewGasVault creates a new GasVault instance. +func NewGasVault() *GasVault { + return &GasVault{} +} + +// AddGas adds the specified amount of gas to the vault's available budget. +func (o *GasVault) AddGas(gas uint64) { + o.mu.Lock() + defer o.mu.Unlock() + if o.gasBudgetAvailable+gas < o.gasBudgetAvailable { + o.gasBudgetAvailable = math.MaxUint64 + log.Trace().Uint64("available_budget", o.gasBudgetAvailable).Msg("Gas budget capped to max uint64") + } else { + o.gasBudgetAvailable += gas + log.Trace().Uint64("available_budget", o.gasBudgetAvailable).Msg("Gas added to vault") + } +} + +// SpendOrWaitAvailableBudget attempts to spend the specified amount of gas from the vault's available budget. +// It blocks until sufficient budget is available or the context is cancelled. +func (o *GasVault) SpendOrWaitAvailableBudget(ctx context.Context, gas uint64) error { + const intervalToCheckBudgetAvailability = 100 * time.Millisecond + ticker := time.NewTicker(intervalToCheckBudgetAvailability) + defer ticker.Stop() + + for { + if spent := o.trySpendBudget(gas); spent { + return nil + } + select { + case <-ticker.C: + continue + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// trySpendBudget tries to spend the specified amount of gas from the vault's available budget. +// It returns true if the gas was successfully spent, or false if there was insufficient budget. +func (o *GasVault) trySpendBudget(gas uint64) bool { + o.mu.Lock() + defer o.mu.Unlock() + if gas <= o.gasBudgetAvailable { + o.gasBudgetAvailable -= gas + return true + } + return false +} + +// GetAvailableBudget returns the current available gas budget in the vault. +func (o *GasVault) GetAvailableBudget() uint64 { + o.mu.Lock() + defer o.mu.Unlock() + return o.gasBudgetAvailable +} diff --git a/loadtest/gasmanager/gas_vault_test.go b/loadtest/gasmanager/gas_vault_test.go new file mode 100644 index 000000000..0d9d10c1c --- /dev/null +++ b/loadtest/gasmanager/gas_vault_test.go @@ -0,0 +1,315 @@ +package gasmanager + +import ( + "context" + "math" + "sync" + "testing" + "time" +) + +func TestNewGasVault(t *testing.T) { + vault := NewGasVault() + if vault == nil { + t.Fatal("NewGasVault returned nil") + } + if vault.GetAvailableBudget() != 0 { + t.Errorf("Expected initial budget to be 0, got %d", vault.GetAvailableBudget()) + } +} + +func TestGasVault_AddGas(t *testing.T) { + tests := []struct { + name string + initialBudget uint64 + addAmounts []uint64 + expectedFinal uint64 + }{ + { + name: "add single amount", + initialBudget: 0, + addAmounts: []uint64{1000}, + expectedFinal: 1000, + }, + { + name: "add multiple amounts", + initialBudget: 500, + addAmounts: []uint64{200, 300, 100}, + expectedFinal: 1100, + }, + { + name: "add zero", + initialBudget: 1000, + addAmounts: []uint64{0}, + expectedFinal: 1000, + }, + { + name: "overflow protection - cap at max uint64", + initialBudget: math.MaxUint64 - 100, + addAmounts: []uint64{200}, + expectedFinal: math.MaxUint64, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vault := NewGasVault() + if tt.initialBudget > 0 { + vault.AddGas(tt.initialBudget) + } + + for _, amount := range tt.addAmounts { + vault.AddGas(amount) + } + + got := vault.GetAvailableBudget() + if got != tt.expectedFinal { + t.Errorf("Expected final budget %d, got %d", tt.expectedFinal, got) + } + }) + } +} + +func TestGasVault_SpendOrWaitAvailableBudget_Success(t *testing.T) { + vault := NewGasVault() + vault.AddGas(1000) + + ctx := context.Background() + err := vault.SpendOrWaitAvailableBudget(ctx, 500) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + remaining := vault.GetAvailableBudget() + if remaining != 500 { + t.Errorf("Expected remaining budget 500, got %d", remaining) + } +} + +func TestGasVault_SpendOrWaitAvailableBudget_ExactAmount(t *testing.T) { + vault := NewGasVault() + vault.AddGas(1000) + + ctx := context.Background() + err := vault.SpendOrWaitAvailableBudget(ctx, 1000) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + remaining := vault.GetAvailableBudget() + if remaining != 0 { + t.Errorf("Expected remaining budget 0, got %d", remaining) + } +} + +func TestGasVault_SpendOrWaitAvailableBudget_ContextCancelled(t *testing.T) { + vault := NewGasVault() + // Don't add any budget, so it will block + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + err := vault.SpendOrWaitAvailableBudget(ctx, 500) + if err == nil { + t.Error("Expected error due to cancelled context, got nil") + } + if err != context.Canceled { + t.Errorf("Expected context.Canceled error, got %v", err) + } + + // Budget should remain unchanged + remaining := vault.GetAvailableBudget() + if remaining != 0 { + t.Errorf("Expected budget to remain 0, got %d", remaining) + } +} + +func TestGasVault_SpendOrWaitAvailableBudget_ContextTimeout(t *testing.T) { + vault := NewGasVault() + // Don't add any budget + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + start := time.Now() + err := vault.SpendOrWaitAvailableBudget(ctx, 500) + elapsed := time.Since(start) + + if err == nil { + t.Error("Expected error due to timeout, got nil") + } + if err != context.DeadlineExceeded { + t.Errorf("Expected context.DeadlineExceeded error, got %v", err) + } + if elapsed < 200*time.Millisecond { + t.Errorf("Expected to wait at least 200ms, waited %v", elapsed) + } +} + +func TestGasVault_SpendOrWaitAvailableBudget_WaitThenSuccess(t *testing.T) { + vault := NewGasVault() + vault.AddGas(100) // Not enough initially + + ctx := context.Background() + + // Start goroutine that will add budget after a delay + go func() { + time.Sleep(200 * time.Millisecond) + vault.AddGas(1000) // Now enough budget available + }() + + start := time.Now() + err := vault.SpendOrWaitAvailableBudget(ctx, 500) + elapsed := time.Since(start) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if elapsed < 200*time.Millisecond { + t.Errorf("Expected to wait at least 200ms for budget, waited %v", elapsed) + } + + remaining := vault.GetAvailableBudget() + expected := uint64(600) // 100 + 1000 - 500 + if remaining != expected { + t.Errorf("Expected remaining budget %d, got %d", expected, remaining) + } +} + +func TestGasVault_ConcurrentAccess(t *testing.T) { + vault := NewGasVault() + vault.AddGas(10000) + + ctx := context.Background() + const numGoroutines = 100 + const spendAmount = 50 + + var wg sync.WaitGroup + errors := make(chan error, numGoroutines) + + // Spawn multiple goroutines trying to spend gas concurrently + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + err := vault.SpendOrWaitAvailableBudget(ctx, spendAmount) + if err != nil { + errors <- err + } + }() + } + + wg.Wait() + close(errors) + + // Check for errors + for err := range errors { + t.Errorf("Unexpected error in concurrent access: %v", err) + } + + // Verify final budget + expected := uint64(10000 - (numGoroutines * spendAmount)) + remaining := vault.GetAvailableBudget() + if remaining != expected { + t.Errorf("Expected remaining budget %d after concurrent access, got %d", expected, remaining) + } +} + +func TestGasVault_ConcurrentAddAndSpend(t *testing.T) { + vault := NewGasVault() + vault.AddGas(5000) + + ctx := context.Background() + const numAdders = 50 + const numSpenders = 50 + const addAmount = 100 + const spendAmount = 100 + + var wg sync.WaitGroup + + // Adders + for i := 0; i < numAdders; i++ { + wg.Add(1) + go func() { + defer wg.Done() + vault.AddGas(addAmount) + }() + } + + // Spenders + errors := make(chan error, numSpenders) + for i := 0; i < numSpenders; i++ { + wg.Add(1) + go func() { + defer wg.Done() + err := vault.SpendOrWaitAvailableBudget(ctx, spendAmount) + if err != nil { + errors <- err + } + }() + } + + wg.Wait() + close(errors) + + // Check for errors + for err := range errors { + t.Errorf("Unexpected error in concurrent add/spend: %v", err) + } + + // Verify final budget: initial + added - spent + expected := uint64(5000 + (numAdders * addAmount) - (numSpenders * spendAmount)) + remaining := vault.GetAvailableBudget() + if remaining != expected { + t.Errorf("Expected remaining budget %d, got %d", expected, remaining) + } +} + +func TestGasVault_MultipleSpendersWaiting(t *testing.T) { + vault := NewGasVault() + // Start with no budget + + ctx := context.Background() + const numSpenders = 5 + const spendAmount = 100 + + var wg sync.WaitGroup + successCount := make(chan int, numSpenders) + + // Start multiple spenders that will all wait + for i := 0; i < numSpenders; i++ { + wg.Add(1) + go func() { + defer wg.Done() + err := vault.SpendOrWaitAvailableBudget(ctx, spendAmount) + if err == nil { + successCount <- 1 + } + }() + } + + // Give goroutines time to start waiting + time.Sleep(100 * time.Millisecond) + + // Add enough budget for all spenders + vault.AddGas(uint64(numSpenders * spendAmount)) + + wg.Wait() + close(successCount) + + // Count successful spends + count := 0 + for range successCount { + count++ + } + + if count != numSpenders { + t.Errorf("Expected %d successful spends, got %d", numSpenders, count) + } + + // Budget should be depleted + remaining := vault.GetAvailableBudget() + if remaining != 0 { + t.Errorf("Expected remaining budget 0, got %d", remaining) + } +} diff --git a/loadtest/gasmanager/strategy_dynamic_gas.go b/loadtest/gasmanager/strategy_dynamic_gas.go new file mode 100644 index 000000000..a5d6c99ff --- /dev/null +++ b/loadtest/gasmanager/strategy_dynamic_gas.go @@ -0,0 +1,46 @@ +package gasmanager + +import ( + "fmt" + "math/rand/v2" + "sync/atomic" +) + +// DynamicGasPriceConfig holds the configuration for the DynamicGasPriceStrategy. +type DynamicGasPriceConfig struct { + GasPrices []uint64 + Variation float64 +} + +// DynamicGasPriceStrategy provides gas prices from a predefined list with random variation. +type DynamicGasPriceStrategy struct { + config DynamicGasPriceConfig + i atomic.Int32 +} + +// NewDynamicGasPriceStrategy creates a new DynamicGasPriceStrategy with the given configuration. +func NewDynamicGasPriceStrategy(config DynamicGasPriceConfig) (*DynamicGasPriceStrategy, error) { + if len(config.GasPrices) == 0 { + return nil, fmt.Errorf("DynamicGasPriceConfig.GasPrices cannot be empty") + } + s := &DynamicGasPriceStrategy{ + config: config, + } + return s, nil +} + +// GetGasPrice retrieves the next gas price from the list, applying random variation. +func (s *DynamicGasPriceStrategy) GetGasPrice() *uint64 { + idx := s.i.Load() + s.i.Store((idx + 1) % int32(len(s.config.GasPrices))) + gp := s.config.GasPrices[idx] + if gp == 0 { + return nil + } + + variationMin := float64(1) - s.config.Variation + variationMax := float64(1) + s.config.Variation + factor := variationMin + rand.Float64()*(variationMax-variationMin) + varied := uint64(float64(gp) * factor) + return &varied +} diff --git a/loadtest/gasmanager/strategy_estimated_gas.go b/loadtest/gasmanager/strategy_estimated_gas.go new file mode 100644 index 000000000..decee772a --- /dev/null +++ b/loadtest/gasmanager/strategy_estimated_gas.go @@ -0,0 +1,16 @@ +package gasmanager + +// EstimatedGasPriceStrategy provides gas prices estimated from the network. +type EstimatedGasPriceStrategy struct { +} + +// NewEstimatedGasPriceStrategy creates a new EstimatedGasPriceStrategy. +func NewEstimatedGasPriceStrategy() *EstimatedGasPriceStrategy { + return &EstimatedGasPriceStrategy{} +} + +// GetGasPrice retrieves the estimated gas price from the network. +// For this strategy, we return nil to indicate that the default network gas price should be used. +func (s *EstimatedGasPriceStrategy) GetGasPrice() *uint64 { + return nil +} diff --git a/loadtest/gasmanager/strategy_fixed_gas.go b/loadtest/gasmanager/strategy_fixed_gas.go new file mode 100644 index 000000000..e2e9f228c --- /dev/null +++ b/loadtest/gasmanager/strategy_fixed_gas.go @@ -0,0 +1,24 @@ +package gasmanager + +// FixedGasPriceConfig holds the configuration for the FixedGasPriceStrategy. +type FixedGasPriceConfig struct { + GasPriceWei uint64 +} + +// FixedGasPriceStrategy provides a fixed gas price. +type FixedGasPriceStrategy struct { + config FixedGasPriceConfig +} + +// NewFixedGasPriceStrategy creates a new FixedGasPriceStrategy with the given configuration. +func NewFixedGasPriceStrategy(config FixedGasPriceConfig) *FixedGasPriceStrategy { + return &FixedGasPriceStrategy{ + config: config, + } +} + +// GetGasPrice retrieves the fixed gas price. +func (s *FixedGasPriceStrategy) GetGasPrice() *uint64 { + price := s.config.GasPriceWei + return &price +} diff --git a/loadtest/gasmanager/wave.go b/loadtest/gasmanager/wave.go new file mode 100644 index 000000000..fcc7d3c74 --- /dev/null +++ b/loadtest/gasmanager/wave.go @@ -0,0 +1,59 @@ +package gasmanager + +type WaveConfig struct { + Period uint64 + Amplitude uint64 + Target uint64 +} + +type Wave interface { + Period() uint64 + Amplitude() uint64 + Target() uint64 + X() float64 + Y() float64 + MoveNext() +} + +type BaseWave struct { + config WaveConfig + x float64 + points map[float64]float64 +} + +func NewBaseWave(config WaveConfig) *BaseWave { + return &BaseWave{ + config: config, + x: 0, + points: make(map[float64]float64), + } +} + +// MoveNext advances the wave to the next position. +func (w *BaseWave) MoveNext() { + w.x++ + if w.x >= float64(w.config.Period) { + w.x = 0 + } +} + +// Y returns the current value of the wave at position x. +func (w *BaseWave) Y() float64 { + return w.points[w.x] +} + +func (w *BaseWave) Period() uint64 { + return w.config.Period +} + +func (w *BaseWave) Amplitude() uint64 { + return w.config.Amplitude +} + +func (w *BaseWave) Target() uint64 { + return w.config.Target +} + +func (w *BaseWave) X() float64 { + return w.x +} diff --git a/loadtest/gasmanager/wave_flat.go b/loadtest/gasmanager/wave_flat.go new file mode 100644 index 000000000..ebe7f3393 --- /dev/null +++ b/loadtest/gasmanager/wave_flat.go @@ -0,0 +1,23 @@ +package gasmanager + +// FlatWave represents a wave that maintains a constant target value over its period. +type FlatWave struct { + *BaseWave +} + +// NewFlatWave creates a new FlatWave with the given configuration. +func NewFlatWave(config WaveConfig) *FlatWave { + c := FlatWave{ + BaseWave: NewBaseWave(config), + } + c.computeWave(config) + return &c +} + +// computeWave sets all points to the target value. +func (c *FlatWave) computeWave(config WaveConfig) { + target := float64(config.Target) + for x := 0.0; x <= float64(config.Period); x++ { + c.points[x] = target + } +} diff --git a/loadtest/gasmanager/wave_sawtooth.go b/loadtest/gasmanager/wave_sawtooth.go new file mode 100644 index 000000000..d874e0ace --- /dev/null +++ b/loadtest/gasmanager/wave_sawtooth.go @@ -0,0 +1,31 @@ +package gasmanager + +import "math" + +// SawtoothWave represents a wave that linearly rises from the minimum to the maximum value over its period. +type SawtoothWave struct { + *BaseWave +} + +// NewSawtoothWave creates a new SawtoothWave with the given configuration. +func NewSawtoothWave(config WaveConfig) *SawtoothWave { + c := &SawtoothWave{ + BaseWave: NewBaseWave(config), + } + + c.computeWave(config) + + return c +} + +// computeWave creates a sawtooth that rises linearly from min to max over the period. +func (c *SawtoothWave) computeWave(config WaveConfig) { + period := float64(config.Period) + offset := float64(config.Target - config.Amplitude) + rangeOfWave := float64(2 * config.Amplitude) + + for x := 0.0; x <= period; x++ { + fractionalTime := math.Mod(x, period) / period + c.points[x] = rangeOfWave*fractionalTime + offset + } +} diff --git a/loadtest/gasmanager/wave_sine.go b/loadtest/gasmanager/wave_sine.go new file mode 100644 index 000000000..429c048e6 --- /dev/null +++ b/loadtest/gasmanager/wave_sine.go @@ -0,0 +1,32 @@ +package gasmanager + +import "math" + +// SineWave represents a sine wave pattern for gas price modulation. +type SineWave struct { + *BaseWave +} + +// NewSineWave creates a new SineWave with the given configuration. +func NewSineWave(config WaveConfig) *SineWave { + c := &SineWave{ + BaseWave: NewBaseWave(config), + } + + c.computeWave(config) + + return c +} + +// computeWave calculates the wave points using: y = A * sin(2π/P * x) + T +// where A = Amplitude, P = Period, T = Target (vertical shift) +func (c *SineWave) computeWave(config WaveConfig) { + period := float64(config.Period) + amplitude := float64(config.Amplitude) + target := float64(config.Target) + b := (2 * math.Pi) / period + + for x := 0.0; x <= period; x++ { + c.points[x] = amplitude*math.Sin(b*x) + target + } +} diff --git a/loadtest/gasmanager/wave_square.go b/loadtest/gasmanager/wave_square.go new file mode 100644 index 000000000..7f97cfbe8 --- /dev/null +++ b/loadtest/gasmanager/wave_square.go @@ -0,0 +1,35 @@ +package gasmanager + +import "math" + +// SquareWave represents a square wave pattern for gas price modulation. +type SquareWave struct { + *BaseWave +} + +// NewSquareWave creates a new SquareWave with the given configuration. +func NewSquareWave(config WaveConfig) *SquareWave { + c := &SquareWave{ + BaseWave: NewBaseWave(config), + } + + c.computeWave(config) + + return c +} + +// computeWave alternates between high and low values over the period. +func (c *SquareWave) computeWave(config WaveConfig) { + period := float64(config.Period) + highValue := float64(config.Target) + float64(config.Amplitude) + lowValue := float64(config.Target) - float64(config.Amplitude) + halfPeriod := period / 2.0 + + for x := 0.0; x <= period; x++ { + if math.Mod(x, period) < halfPeriod { + c.points[x] = highValue + } else { + c.points[x] = lowValue + } + } +} diff --git a/loadtest/gasmanager/wave_test.go b/loadtest/gasmanager/wave_test.go new file mode 100644 index 000000000..f48081d0a --- /dev/null +++ b/loadtest/gasmanager/wave_test.go @@ -0,0 +1,102 @@ +package gasmanager + +import ( + "testing" + + "github.com/0xPolygon/polygon-cli/util" + "github.com/stretchr/testify/assert" +) + +func TestWaves(t *testing.T) { + type TestCase struct { + name string + config WaveConfig + expectedPoints map[float64]float64 + createWave func(WaveConfig) Wave + } + + testCases := []TestCase{ + { + name: "Flat Wave", + config: WaveConfig{Target: 10, Amplitude: 5, Period: 10}, + createWave: func(config WaveConfig) Wave { return NewFlatWave(config) }, + expectedPoints: map[float64]float64{ + 0: 10.00000, 1: 10.00000, 2: 10.00000, 3: 10.00000, 4: 10.00000, + 5: 10.00000, 6: 10.00000, 7: 10.00000, 8: 10.00000, 9: 10.00000, + }, + }, + { + name: "Sine Wave", + config: WaveConfig{Target: 10, Amplitude: 5, Period: 20}, + createWave: func(config WaveConfig) Wave { return NewSineWave(config) }, + expectedPoints: map[float64]float64{ + 0: 10.00000, 1: 11.545085, 2: 12.938926, 3: 14.045085, 4: 14.755283, + 5: 15.000000, 6: 14.755283, 7: 14.045085, 8: 12.938926, 9: 11.545085, + 10: 10.000000, 11: 8.454915, 12: 7.061074, 13: 5.954915, 14: 5.244717, + 15: 5.000000, 16: 5.244717, 17: 5.954915, 18: 7.061074, 19: 8.454915, + }, + }, + { + name: "Sawtooth Wave", + config: WaveConfig{Target: 10, Amplitude: 5, Period: 20}, + createWave: func(config WaveConfig) Wave { return NewSawtoothWave(config) }, + expectedPoints: map[float64]float64{ + 0: 5.0, 1: 5.5, 2: 6.0, 3: 6.5, 4: 7.0, + 5: 7.5, 6: 8.0, 7: 8.5, 8: 9.0, 9: 9.5, + 10: 10.0, 11: 10.5, 12: 11.0, 13: 11.5, 14: 12.0, + 15: 12.5, 16: 13.0, 17: 13.5, 18: 14.0, 19: 14.5, + }, + }, + { + name: "Triangle Wave", + config: WaveConfig{Target: 10, Amplitude: 5, Period: 20}, + createWave: func(config WaveConfig) Wave { return NewTriangleWave(config) }, + expectedPoints: map[float64]float64{ + 0: 5.0, 1: 6.0, 2: 7.0, 3: 8.0, 4: 9.0, + 5: 10.0, 6: 11.0, 7: 12.0, 8: 13.0, 9: 14.0, + 10: 15.0, 11: 14.0, 12: 13.0, 13: 12.0, 14: 11.0, + 15: 10.0, 16: 9.0, 17: 8.0, 18: 7.0, 19: 6.0, + }, + }, + { + name: "Square Wave", + config: WaveConfig{Target: 10, Amplitude: 5, Period: 20}, + createWave: func(config WaveConfig) Wave { return NewSquareWave(config) }, + expectedPoints: map[float64]float64{ + 0: 15.00000, 1: 15.00000, 2: 15.00000, 3: 15.00000, 4: 15.00000, + 5: 15.00000, 6: 15.00000, 7: 15.00000, 8: 15.00000, 9: 15.00000, + 10: 5.00000, 11: 5.00000, 12: 5.00000, 13: 5.00000, 14: 5.00000, + 15: 5.00000, 16: 5.00000, 17: 5.00000, 18: 5.00000, 19: 5.00000, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + wave := tc.createWave(tc.config) + + assert.Equal(t, tc.config.Period, wave.Period()) + assert.Equal(t, tc.config.Amplitude, wave.Amplitude()) + assert.Equal(t, tc.config.Target, wave.Target()) + + startPoint := wave.X() + points := map[float64]float64{} + for i := 0; i < len(tc.expectedPoints); i++ { + points[wave.X()] = wave.Y() + wave.MoveNext() + if wave.X() == startPoint { + break + } + } + + assert.Equal(t, len(tc.expectedPoints), len(points)) + + for k, v := range tc.expectedPoints { + if !util.CompareFloatsWithTolerance(points[k], v, 0.00001) { + t.Errorf("At x=%f, expected y=%f, got y=%f, diff=%f", k, v, points[k], v-points[k]) + } + } + }) + } + +} diff --git a/loadtest/gasmanager/wave_triangle.go b/loadtest/gasmanager/wave_triangle.go new file mode 100644 index 000000000..18523b474 --- /dev/null +++ b/loadtest/gasmanager/wave_triangle.go @@ -0,0 +1,33 @@ +package gasmanager + +import "math" + +// TriangleWave represents a triangle wave pattern for gas price modulation. +type TriangleWave struct { + *BaseWave +} + +// NewTriangleWave creates a new TriangleWave with the given configuration. +func NewTriangleWave(config WaveConfig) *TriangleWave { + c := &TriangleWave{ + BaseWave: NewBaseWave(config), + } + + c.computeWave(config) + + return c +} + +// computeWave creates a triangle wave that rises and falls linearly. +func (c *TriangleWave) computeWave(config WaveConfig) { + period := float64(config.Period) + amplitude := float64(config.Amplitude) + target := float64(config.Target) + peakToPeak := 2.0 * amplitude + + for x := 0.0; x <= period; x++ { + normalizedTime := math.Mod(x, period) / period + // abs(2*t - 1) creates a triangle oscillating between 0 and 1 + c.points[x] = target + amplitude - peakToPeak*math.Abs(2*normalizedTime-1) + } +} diff --git a/loadtest/runner.go b/loadtest/runner.go index 782abd45d..e15ce2df5 100644 --- a/loadtest/runner.go +++ b/loadtest/runner.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "os/signal" + "strconv" "strings" "sync" "sync/atomic" @@ -19,6 +20,7 @@ import ( "github.com/0xPolygon/polygon-cli/bindings/tester" "github.com/0xPolygon/polygon-cli/bindings/tokens" "github.com/0xPolygon/polygon-cli/loadtest/config" + "github.com/0xPolygon/polygon-cli/loadtest/gasmanager" "github.com/0xPolygon/polygon-cli/loadtest/mode" "github.com/0xPolygon/polygon-cli/loadtest/modes" "github.com/0xPolygon/polygon-cli/loadtest/uniswapv3" @@ -49,6 +51,10 @@ type Runner struct { modes []mode.Runner waitBaseFeeToDrop atomic.Bool + // Gas manager + gasVault *gasmanager.GasVault + gasPricer *gasmanager.GasPricer + // Clients client *ethclient.Client rpcClient *ethrpc.Client @@ -114,6 +120,11 @@ func (r *Runner) Init(ctx context.Context) error { return err } + // Initialize gas manager if configured + if err := r.setupGasManager(ctx); err != nil { + return err + } + return nil } @@ -570,6 +581,14 @@ func (r *Runner) mainLoop(ctx context.Context) error { sendingTops = r.configureTransactOpts(ctx, sendingTops) + // Spend gas budget if gas vault is configured + if r.gasVault != nil && sendingTops.GasLimit > 0 { + if budgetErr := r.gasVault.SpendOrWaitAvailableBudget(ctx, sendingTops.GasLimit); budgetErr != nil { + log.Error().Err(budgetErr).Msg("Error waiting for gas budget") + return + } + } + // Execute the selected mode startReq, endReq, ltTxHash, tErr = selectedMode.Execute(ctx, cfg, r.deps, sendingTops) @@ -1135,8 +1154,12 @@ func (r *Runner) getSuggestedGasPrices(ctx context.Context) (*big.Int, *big.Int) defer r.cachedGasPriceLock.Unlock() bn := r.getLatestBlockNumber(ctx) - if r.cachedBlockNumber != nil && bn <= *r.cachedBlockNumber { - return r.cachedGasPrice, r.cachedGasTipCap + + // Cache is used only when gas pricer is not used + if r.gasPricer == nil { + if r.cachedBlockNumber != nil && bn <= *r.cachedBlockNumber { + return r.cachedGasPrice, r.cachedGasTipCap + } } var gasPrice, gasTipCap = big.NewInt(0), big.NewInt(0) @@ -1147,12 +1170,23 @@ func (r *Runner) getSuggestedGasPrices(ctx context.Context) (*big.Int, *big.Int) if cfg.ForceGasPrice != 0 { gasPrice = new(big.Int).SetUint64(cfg.ForceGasPrice) } else { - gasPrice, pErr = r.client.SuggestGasPrice(ctx) - if pErr == nil { - gasPrice = r.biasGasPrice(gasPrice) + var gp *uint64 + if r.gasPricer != nil { + gp = r.gasPricer.GetGasPrice() + } + if gp != nil { + gasPrice = big.NewInt(0).SetUint64(*gp) } else { - log.Error().Err(pErr).Msg("Unable to suggest gas price") - return r.cachedGasPrice, r.cachedGasTipCap + if r.cachedBlockNumber != nil && bn <= *r.cachedBlockNumber { + return r.cachedGasPrice, r.cachedGasTipCap + } + gasPrice, pErr = r.client.SuggestGasPrice(ctx) + if pErr == nil { + gasPrice = r.biasGasPrice(gasPrice) + } else { + log.Error().Err(pErr).Msg("Unable to suggest gas price") + return r.cachedGasPrice, r.cachedGasTipCap + } } } } else { @@ -1161,12 +1195,16 @@ func (r *Runner) getSuggestedGasPrices(ctx context.Context) (*big.Int, *big.Int) gasTipCap = new(big.Int).SetUint64(cfg.ForcePriorityGasPrice) forcePriorityGasPrice = gasTipCap } else if cfg.ChainSupportBaseFee { - gasTipCap, tErr = r.client.SuggestGasTipCap(ctx) - if tErr == nil { - gasTipCap = r.biasGasPrice(gasTipCap) + if r.cachedBlockNumber != nil && bn <= *r.cachedBlockNumber { + gasTipCap = r.cachedGasTipCap } else { - log.Error().Err(tErr).Msg("Unable to suggest gas tip cap") - return r.cachedGasPrice, r.cachedGasTipCap + gasTipCap, tErr = r.client.SuggestGasTipCap(ctx) + if tErr == nil { + gasTipCap = r.biasGasPrice(gasTipCap) + } else { + log.Error().Err(tErr).Msg("Unable to suggest gas tip cap") + return r.cachedGasPrice, r.cachedGasTipCap + } } } else { log.Fatal().Msg("Chain does not support base fee. Please set priority-gas-price flag with a value to use for gas tip cap") @@ -1175,7 +1213,18 @@ func (r *Runner) getSuggestedGasPrices(ctx context.Context) (*big.Int, *big.Int) if cfg.ForceGasPrice != 0 { gasPrice = new(big.Int).SetUint64(cfg.ForceGasPrice) } else if cfg.ChainSupportBaseFee { - gasPrice = r.suggestMaxFeePerGas(ctx, bn, forcePriorityGasPrice) + var gp *uint64 + if r.gasPricer != nil { + gp = r.gasPricer.GetGasPrice() + } + if gp != nil { + gasPrice = big.NewInt(0).SetUint64(*gp) + } else { + if r.cachedBlockNumber != nil && bn <= *r.cachedBlockNumber { + return r.cachedGasPrice, r.cachedGasTipCap + } + gasPrice = r.suggestMaxFeePerGas(ctx, bn, forcePriorityGasPrice) + } } else { log.Fatal().Msg("Chain does not support base fee. Please set gas-price flag with a value to use for max fee per gas") } @@ -1185,11 +1234,14 @@ func (r *Runner) getSuggestedGasPrices(ctx context.Context) (*big.Int, *big.Int) r.cachedGasPrice = gasPrice r.cachedGasTipCap = gasTipCap - log.Debug(). - Uint64("cachedBlockNumber", bn). - Interface("cachedGasPrice", r.cachedGasPrice). - Interface("cachedGasTipCap", r.cachedGasTipCap). - Msg("Updating gas prices") + // Only log when cache is used (gasPricer not active) + if r.gasPricer == nil { + log.Debug(). + Uint64("cachedBlockNumber", bn). + Interface("cachedGasPrice", r.cachedGasPrice). + Interface("cachedGasTipCap", r.cachedGasTipCap). + Msg("Updating gas prices") + } return r.cachedGasPrice, r.cachedGasTipCap } @@ -1367,6 +1419,132 @@ func (r *Runner) GetConfig() *config.Config { return r.cfg } +func (r *Runner) setupGasManager(ctx context.Context) error { + if r.cfg.GasManager == nil { + return nil + } + + gasVault, err := r.setupGasVault(ctx) + if err != nil { + return err + } + r.gasVault = gasVault + + gasPricer, err := r.setupGasPricer() + if err != nil { + return err + } + r.gasPricer = gasPricer + + return nil +} + +func (r *Runner) setupGasVault(ctx context.Context) (*gasmanager.GasVault, error) { + log.Trace().Msg("Setting up gas limiter") + gm := r.cfg.GasManager + + waveCfg := gasmanager.WaveConfig{ + Period: gm.Period, + Amplitude: gm.Amplitude, + Target: gm.Target, + } + + wave, err := createWave(gm.OscillationWave, waveCfg) + if err != nil { + return nil, err + } + + log.Trace(). + Str("Wave", gm.OscillationWave). + Uint64("Period", gm.Period). + Uint64("Amplitude", gm.Amplitude). + Uint64("Target", gm.Target). + Msg("Using oscillation wave") + + gasVault := gasmanager.NewGasVault() + gasProvider := gasmanager.NewOscillatingGasProvider(r.client, gasVault, wave) + gasProvider.Start(ctx) + + return gasVault, nil +} + +func createWave(waveType string, cfg gasmanager.WaveConfig) (gasmanager.Wave, error) { + switch waveType { + case "flat": + return gasmanager.NewFlatWave(cfg), nil + case "sine": + return gasmanager.NewSineWave(cfg), nil + case "sawtooth": + return gasmanager.NewSawtoothWave(cfg), nil + case "square": + return gasmanager.NewSquareWave(cfg), nil + case "triangle": + return gasmanager.NewTriangleWave(cfg), nil + default: + return nil, fmt.Errorf("unknown gas oscillation wave: %s", waveType) + } +} + +func (r *Runner) setupGasPricer() (*gasmanager.GasPricer, error) { + gm := r.cfg.GasManager + + strategy, err := createPriceStrategy(gm) + if err != nil { + return nil, err + } + + log.Trace().Str("Strategy", gm.PriceStrategy).Msg("Using gas price strategy") + return gasmanager.NewGasPricer(strategy), nil +} + +func createPriceStrategy(gm *config.GasManagerConfig) (gasmanager.PriceStrategy, error) { + switch gm.PriceStrategy { + case "fixed": + return gasmanager.NewFixedGasPriceStrategy(gasmanager.FixedGasPriceConfig{ + GasPriceWei: gm.FixedGasPriceWei, + }), nil + case "estimated": + return gasmanager.NewEstimatedGasPriceStrategy(), nil + case "dynamic": + return createDynamicPriceStrategy(gm) + default: + return nil, fmt.Errorf("unknown gas price strategy: %s", gm.PriceStrategy) + } +} + +func createDynamicPriceStrategy(gm *config.GasManagerConfig) (gasmanager.PriceStrategy, error) { + gasPrices, err := parseDynamicGasPrices(gm.DynamicGasPricesWei) + if err != nil { + return nil, err + } + + if len(gasPrices) > 0 { + log.Trace().Any("GasPrices", gasPrices).Msg("Using custom dynamic gas prices") + } + + return gasmanager.NewDynamicGasPriceStrategy(gasmanager.DynamicGasPriceConfig{ + GasPrices: gasPrices, + Variation: gm.DynamicGasPricesVariation, + }) +} + +func parseDynamicGasPrices(pricesStr string) ([]uint64, error) { + if pricesStr == "" { + return nil, nil + } + + parts := strings.Split(pricesStr, ",") + prices := make([]uint64, 0, len(parts)) + for _, part := range parts { + price, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid gas price in dynamic gas prices list: %s", part) + } + prices = append(prices, price) + } + return prices, nil +} + // Run is a convenience function that creates a runner, initializes it, and runs the load test. // This allows both the main loadtest command and subcommands to use the same entry point. func Run(ctx context.Context, cfg *config.Config) error { diff --git a/util/float64.go b/util/float64.go new file mode 100644 index 000000000..c1f56515d --- /dev/null +++ b/util/float64.go @@ -0,0 +1,7 @@ +package util + +import "math" + +func CompareFloatsWithTolerance(a, b, tolerance float64) bool { + return math.Abs(a-b) < tolerance +} diff --git a/util/util.go b/util/util.go index 0894d9cae..5e4bf8fd3 100644 --- a/util/util.go +++ b/util/util.go @@ -4,16 +4,22 @@ import ( "context" "encoding/json" "fmt" + "math" + "math/big" "reflect" "strconv" "strings" "time" + "github.com/0xPolygon/polygon-cli/rpctypes" "github.com/cenkalti/backoff" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/consensus/clique" + "github.com/ethereum/go-ethereum/crypto" ethcrypto "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rlp" ethrpc "github.com/ethereum/go-ethereum/rpc" "github.com/rs/zerolog/log" @@ -375,6 +381,9 @@ func getZkEVMBatch(rpc *ethrpc.Client, batchType batch) (uint64, error) { func tryCastToUint64(val any) (uint64, error) { switch t := val.(type) { case float64: + if t < 0 || t > float64(math.MaxUint64) { + return 0, fmt.Errorf("value %v is out of range for uint64", t) + } return uint64(t), nil case string: return convHexToUint64(t) @@ -429,7 +438,11 @@ func WrapDeployedCode(deployedBytecode string, storageBytecode string) string { func GetHexString(data any) string { var result string if reflect.TypeOf(data).Kind() == reflect.Float64 { - result = fmt.Sprintf("%x", int64(data.(float64))) + f := data.(float64) + if f < 0 || f > float64(math.MaxUint64) { + log.Fatal().Float64("value", f).Msg("value out of range for uint64") + } + result = fmt.Sprintf("%x", uint64(f)) } else if reflect.TypeOf(data).Kind() == reflect.String { var ok bool result, ok = strings.CutPrefix(data.(string), "0x") @@ -444,3 +457,242 @@ func GetHexString(data any) string { } return strings.ToLower(result) } + +func GetChainID(ctx context.Context, ec *ethrpc.Client) (*big.Int, error) { + var chainIDHex string + err := ec.CallContext(ctx, &chainIDHex, "eth_chainId") + if err != nil { + return nil, err + } + chainID, err := hexutil.DecodeBig(chainIDHex) + if err != nil { + return nil, err + } + return chainID, nil +} + +// HeaderByBlockNumber retrieves a block header using rpc.BlockNumber. +// If blockNum is nil, it defaults to latest block. +// Examples: +// - HeaderByBlockNumber(ctx, client, nil) // latest +// - HeaderByBlockNumber(ctx, client, &rpc.LatestBlockNumber) +// - HeaderByBlockNumber(ctx, client, &rpc.FinalizedBlockNumber) +// - num := rpc.BlockNumber(12345); HeaderByBlockNumber(ctx, client, &num) +func HeaderByBlockNumber(ctx context.Context, ec *ethrpc.Client, blockNum *ethrpc.BlockNumber) (*types.Header, error) { + var blockParam string + if blockNum == nil { + blockParam = ethrpc.LatestBlockNumber.String() + } else { + blockParam = blockNum.String() + } + + var raw json.RawMessage + err := ec.CallContext(ctx, &raw, "eth_getBlockByNumber", blockParam, false) + if err != nil { + return nil, err + } + + var block types.Header + err = json.Unmarshal(raw, &block) + if err != nil { + return nil, err + } + return &block, nil +} + +// GetSenderFromTx recovers the sender address from a transaction without using types.Transaction +func GetSenderFromTx(ctx context.Context, tx rpctypes.PolyTransaction) (common.Address, error) { + // Get transaction type + txType := tx.Type() + + // For non-standard transaction types, we assume the sender is already set + if txType > 2 { + return tx.From(), nil + } + + // Get transaction fields + chainID := tx.ChainID() + nonce := tx.Nonce() + value := tx.Value() + gas := tx.Gas() + to := tx.To() + data := tx.Data() + v := tx.V() + r := tx.R() + s := tx.S() + + // Calculate the signing hash based on transaction type + var sigHash []byte + var err error + + switch txType { + case 0: // Legacy transaction + sigHash, err = calculateLegacySigningHash(chainID, nonce, tx.GasPrice(), gas, to, value, data) + case 1: // EIP-2930 (Access List) + // For now, we can try with empty access list + // If you need full support, you'll need to add AccessList to PolyTransaction interface + sigHash, err = calculateEIP2930SigningHash(chainID, nonce, tx.GasPrice(), gas, to, value, data, []interface{}{}) + case 2: // EIP-1559 + maxPriorityFee := new(big.Int).SetUint64(tx.MaxPriorityFeePerGas()) + maxFee := new(big.Int).SetUint64(tx.MaxFeePerGas()) + sigHash, err = calculateEIP1559SigningHash(chainID, nonce, maxPriorityFee, maxFee, gas, to, value, data) + default: + return common.Address{}, fmt.Errorf("unsupported transaction type: %d (0x%x)", txType, txType) + } + + if err != nil { + return common.Address{}, fmt.Errorf("failed to calculate signing hash: %w", err) + } + + // Normalize v value for recovery + var recoveryID byte + if txType == 0 { + // Legacy transaction with EIP-155 + if chainID > 0 { + // EIP-155: v = chainId * 2 + 35 + {0,1} + // Extract recovery id: recoveryID = v - (chainId * 2 + 35) + vBig := new(big.Int).Set(v) + vBig.Sub(vBig, big.NewInt(35)) + vBig.Sub(vBig, new(big.Int).Mul(new(big.Int).SetUint64(chainID), big.NewInt(2))) + recoveryID = byte(vBig.Uint64()) + } else { + // Pre-EIP-155: v is 27 or 28 + recoveryID = byte(v.Uint64() - 27) + } + } else { + // EIP-2930 and EIP-1559: v is 0 or 1 (or 27/28) + vVal := v.Uint64() + if vVal >= 27 { + recoveryID = byte(vVal - 27) + } else { + recoveryID = byte(vVal) + } + } + + // Validate recoveryID + if recoveryID > 1 { + return common.Address{}, fmt.Errorf("invalid recovery id: %d (v=%s, chainID=%d, type=%d)", recoveryID, v.String(), chainID, txType) + } + + // Build signature in the [R || S || V] format (65 bytes) + sig := make([]byte, 65) + // Use FillBytes to ensure proper padding with leading zeros + r.FillBytes(sig[0:32]) + s.FillBytes(sig[32:64]) + sig[64] = recoveryID + + // Recover public key from signature using go-ethereum's crypto package + pubKey, err := crypto.Ecrecover(sigHash, sig) + if err != nil { + return common.Address{}, fmt.Errorf("failed to recover public key: %w", err) + } + + // Derive address from public key + // The public key returned by Ecrecover is 65 bytes: [0x04 || X || Y] + // We hash the X and Y coordinates (skip first byte) and take last 20 bytes + hash := crypto.Keccak256(pubKey[1:]) + address := common.BytesToAddress(hash[12:]) + + return address, nil +} + +// calculateLegacySigningHash calculates the signing hash for legacy (type 0) transactions +func calculateLegacySigningHash(chainID uint64, nonce uint64, gasPrice *big.Int, gas uint64, to common.Address, value *big.Int, data []byte) ([]byte, error) { + var items []interface{} + + // Handle contract creation (to = zero address) + var toPtr *common.Address + if to != (common.Address{}) { + toPtr = &to + } + + if chainID > 0 { + // EIP-155: RLP([nonce, gasPrice, gas, to, value, data, chainId, 0, 0]) + items = []interface{}{ + nonce, + gasPrice, + gas, + toPtr, + value, + data, + chainID, + uint(0), + uint(0), + } + } else { + // Pre-EIP-155: RLP([nonce, gasPrice, gas, to, value, data]) + items = []interface{}{ + nonce, + gasPrice, + gas, + toPtr, + value, + data, + } + } + + encoded, err := rlp.EncodeToBytes(items) + if err != nil { + return nil, fmt.Errorf("failed to RLP encode legacy transaction: %w", err) + } + return crypto.Keccak256(encoded), nil +} + +// calculateEIP2930SigningHash calculates the signing hash for EIP-2930 (type 1) transactions +func calculateEIP2930SigningHash(chainID uint64, nonce uint64, gasPrice *big.Int, gas uint64, to common.Address, value *big.Int, data []byte, accessList []interface{}) ([]byte, error) { + var toPtr *common.Address + if to != (common.Address{}) { + toPtr = &to + } + + // EIP-2930: keccak256(0x01 || rlp([chainId, nonce, gasPrice, gas, to, value, data, accessList])) + items := []interface{}{ + chainID, + nonce, + gasPrice, + gas, + toPtr, + value, + data, + accessList, + } + + encoded, err := rlp.EncodeToBytes(items) + if err != nil { + return nil, fmt.Errorf("failed to RLP encode EIP-2930 transaction: %w", err) + } + + // Prepend transaction type byte (0x01) + typedData := append([]byte{0x01}, encoded...) + return crypto.Keccak256(typedData), nil +} + +// calculateEIP1559SigningHash calculates the signing hash for EIP-1559 (type 2) transactions +func calculateEIP1559SigningHash(chainID uint64, nonce uint64, maxPriorityFee, maxFee *big.Int, gas uint64, to common.Address, value *big.Int, data []byte) ([]byte, error) { + var toPtr *common.Address + if to != (common.Address{}) { + toPtr = &to + } + + // EIP-1559: keccak256(0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gas, to, value, data, accessList])) + items := []interface{}{ + chainID, + nonce, + maxPriorityFee, + maxFee, + gas, + toPtr, + value, + data, + []interface{}{}, // empty access list + } + + encoded, err := rlp.EncodeToBytes(items) + if err != nil { + return nil, fmt.Errorf("failed to RLP encode EIP-1559 transaction: %w", err) + } + + // Prepend transaction type byte (0x02) + typedData := append([]byte{0x02}, encoded...) + return crypto.Keccak256(typedData), nil +} diff --git a/util/util_test.go b/util/util_test.go new file mode 100644 index 000000000..7a5c1f98c --- /dev/null +++ b/util/util_test.go @@ -0,0 +1,450 @@ +package util + +import ( + "context" + "crypto/ecdsa" + "fmt" + "math/big" + "testing" + + "github.com/0xPolygon/polygon-cli/rpctypes" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" +) + +// TestGetSenderFromTx tests the sender recovery logic for all transaction types +func TestGetSenderFromTx(t *testing.T) { + ctx := context.Background() + + // Generate a test private key and derive the expected address + privateKey, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("failed to generate private key: %v", err) + } + expectedAddress := crypto.PubkeyToAddress(privateKey.PublicKey) + + chainID := uint64(1337) + + tests := []struct { + name string + txType uint8 + chainID uint64 + privateKey *ecdsa.PrivateKey + wantAddress common.Address + }{ + { + name: "Legacy transaction with EIP-155", + txType: 0, + chainID: chainID, + privateKey: privateKey, + wantAddress: expectedAddress, + }, + { + name: "EIP-2930 transaction", + txType: 1, + chainID: chainID, + privateKey: privateKey, + wantAddress: expectedAddress, + }, + { + name: "EIP-1559 transaction", + txType: 2, + chainID: chainID, + privateKey: privateKey, + wantAddress: expectedAddress, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a signed transaction based on type + var tx *types.Transaction + var err error + + to := common.HexToAddress("0x1234567890123456789012345678901234567890") + value := big.NewInt(1000) + gasLimit := uint64(21000) + nonce := uint64(0) + data := []byte{} + + switch tt.txType { + case 0: // Legacy + gasPrice := big.NewInt(1000000000) + tx = types.NewTx(&types.LegacyTx{ + Nonce: nonce, + GasPrice: gasPrice, + Gas: gasLimit, + To: &to, + Value: value, + Data: data, + }) + + case 1: // EIP-2930 + gasPrice := big.NewInt(1000000000) + tx = types.NewTx(&types.AccessListTx{ + ChainID: big.NewInt(int64(tt.chainID)), + Nonce: nonce, + GasPrice: gasPrice, + Gas: gasLimit, + To: &to, + Value: value, + Data: data, + AccessList: types.AccessList{}, + }) + + case 2: // EIP-1559 + gasTipCap := big.NewInt(1000000000) + gasFeeCap := big.NewInt(2000000000) + tx = types.NewTx(&types.DynamicFeeTx{ + ChainID: big.NewInt(int64(tt.chainID)), + Nonce: nonce, + GasTipCap: gasTipCap, + GasFeeCap: gasFeeCap, + Gas: gasLimit, + To: &to, + Value: value, + Data: data, + AccessList: types.AccessList{}, + }) + } + + // Sign the transaction + signer := types.LatestSignerForChainID(big.NewInt(int64(tt.chainID))) + signedTx, err := types.SignTx(tx, signer, tt.privateKey) + if err != nil { + t.Fatalf("failed to sign transaction: %v", err) + } + + // Convert to PolyTransaction for testing + polyTx := createPolyTransactionFromSignedTx(t, signedTx) + + // Test GetSenderFromTx + recoveredAddress, err := GetSenderFromTx(ctx, polyTx) + if err != nil { + t.Fatalf("GetSenderFromTx failed: %v", err) + } + + if recoveredAddress != tt.wantAddress { + t.Errorf("address mismatch: got %s, want %s", recoveredAddress.Hex(), tt.wantAddress.Hex()) + } + }) + } +} + +// TestGetSenderFromTx_PreEIP155Legacy tests legacy transactions without EIP-155 (chainID = 0) +func TestGetSenderFromTx_PreEIP155Legacy(t *testing.T) { + t.Skip("Pre-EIP-155 transactions require different signing logic and are rarely used") + // Note: This would require special handling as go-ethereum's SignTx doesn't easily support pre-EIP-155 +} + +// TestGetSenderFromTx_InvalidSignature tests error handling for invalid signatures +func TestGetSenderFromTx_InvalidSignature(t *testing.T) { + ctx := context.Background() + + // Create a transaction with an invalid signature + to := common.HexToAddress("0x1234567890123456789012345678901234567890") + + // Create a mock PolyTransaction with invalid signature values + mockTx := &mockPolyTransaction{ + txType: 0, + chainID: 1, + nonce: 0, + gasPrice: big.NewInt(1000000000), + gas: 21000, + to: to, + value: big.NewInt(1000), + data: []byte{}, + v: big.NewInt(27), // Invalid v, r, s combination + r: big.NewInt(0), + s: big.NewInt(0), + } + + _, err := GetSenderFromTx(ctx, mockTx) + if err == nil { + t.Error("expected error for invalid signature, got nil") + } +} + +// TestGetSenderFromTx_ContractCreation tests transactions with no 'to' address (contract creation) +func TestGetSenderFromTx_ContractCreation(t *testing.T) { + ctx := context.Background() + + privateKey, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("failed to generate private key: %v", err) + } + expectedAddress := crypto.PubkeyToAddress(privateKey.PublicKey) + + chainID := uint64(1337) + + // Create a contract creation transaction (to = nil) + gasPrice := big.NewInt(1000000000) + tx := types.NewTx(&types.LegacyTx{ + Nonce: 0, + GasPrice: gasPrice, + Gas: 100000, + To: nil, // Contract creation + Value: big.NewInt(0), + Data: []byte{0x60, 0x60, 0x60}, // Some bytecode + }) + + signer := types.LatestSignerForChainID(big.NewInt(int64(chainID))) + signedTx, err := types.SignTx(tx, signer, privateKey) + if err != nil { + t.Fatalf("failed to sign transaction: %v", err) + } + + polyTx := createPolyTransactionFromSignedTx(t, signedTx) + + recoveredAddress, err := GetSenderFromTx(ctx, polyTx) + if err != nil { + t.Fatalf("GetSenderFromTx failed for contract creation: %v", err) + } + + if recoveredAddress != expectedAddress { + t.Errorf("address mismatch: got %s, want %s", recoveredAddress.Hex(), expectedAddress.Hex()) + } +} + +// TestGetSenderFromTx_UnsupportedType tests handling of unsupported transaction types +func TestGetSenderFromTx_UnsupportedType(t *testing.T) { + ctx := context.Background() + + to := common.HexToAddress("0x1234567890123456789012345678901234567890") + + // Create a mock transaction with an unsupported type (e.g., type 3) + mockTx := &mockPolyTransaction{ + txType: 3, // Unsupported type + chainID: 1, + from: common.HexToAddress("0x0000000000000000000000000000000000000000"), + nonce: 0, + gasPrice: big.NewInt(1000000000), + gas: 21000, + to: to, + value: big.NewInt(1000), + data: []byte{}, + } + + // For type > 2, GetSenderFromTx should return the 'from' field directly + recoveredAddress, err := GetSenderFromTx(ctx, mockTx) + if err != nil { + t.Fatalf("GetSenderFromTx failed: %v", err) + } + + if recoveredAddress != mockTx.from { + t.Errorf("expected to use 'from' field for unsupported type, got %s, want %s", + recoveredAddress.Hex(), mockTx.from.Hex()) + } +} + +// TestGetSenderFromTx_DifferentChainIDs tests sender recovery with various chain IDs +func TestGetSenderFromTx_DifferentChainIDs(t *testing.T) { + ctx := context.Background() + + privateKey, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("failed to generate private key: %v", err) + } + expectedAddress := crypto.PubkeyToAddress(privateKey.PublicKey) + + chainIDs := []uint64{1, 137, 1337, 80001, 100000} + + for _, chainID := range chainIDs { + t.Run(fmt.Sprintf("ChainID_%d", chainID), func(t *testing.T) { + to := common.HexToAddress("0x1234567890123456789012345678901234567890") + value := big.NewInt(1000) + gasLimit := uint64(21000) + gasPrice := big.NewInt(1000000000) + + tx := types.NewTx(&types.LegacyTx{ + Nonce: 0, + GasPrice: gasPrice, + Gas: gasLimit, + To: &to, + Value: value, + Data: []byte{}, + }) + + signer := types.LatestSignerForChainID(big.NewInt(int64(chainID))) + signedTx, err := types.SignTx(tx, signer, privateKey) + if err != nil { + t.Fatalf("failed to sign transaction: %v", err) + } + + polyTx := createPolyTransactionFromSignedTx(t, signedTx) + + recoveredAddress, err := GetSenderFromTx(ctx, polyTx) + if err != nil { + t.Fatalf("GetSenderFromTx failed for chainID %d: %v", chainID, err) + } + + if recoveredAddress != expectedAddress { + t.Errorf("address mismatch for chainID %d: got %s, want %s", + chainID, recoveredAddress.Hex(), expectedAddress.Hex()) + } + }) + } +} + +// TestGetSenderFromTx_WithData tests transactions with various data payloads +func TestGetSenderFromTx_WithData(t *testing.T) { + ctx := context.Background() + + privateKey, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("failed to generate private key: %v", err) + } + expectedAddress := crypto.PubkeyToAddress(privateKey.PublicKey) + + chainID := uint64(1337) + + testCases := []struct { + name string + data []byte + }{ + { + name: "empty data", + data: []byte{}, + }, + { + name: "small data", + data: []byte{0x01, 0x02, 0x03}, + }, + { + name: "function call data", + data: []byte{0xa9, 0x05, 0x9c, 0xbb}, // transfer(address,uint256) selector + }, + { + name: "large data", + data: make([]byte, 1024), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + to := common.HexToAddress("0x1234567890123456789012345678901234567890") + value := big.NewInt(0) + gasLimit := uint64(100000) + gasTipCap := big.NewInt(1000000000) + gasFeeCap := big.NewInt(2000000000) + + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: big.NewInt(int64(chainID)), + Nonce: 0, + GasTipCap: gasTipCap, + GasFeeCap: gasFeeCap, + Gas: gasLimit, + To: &to, + Value: value, + Data: tc.data, + AccessList: types.AccessList{}, + }) + + signer := types.LatestSignerForChainID(big.NewInt(int64(chainID))) + signedTx, err := types.SignTx(tx, signer, privateKey) + if err != nil { + t.Fatalf("failed to sign transaction: %v", err) + } + + polyTx := createPolyTransactionFromSignedTx(t, signedTx) + + recoveredAddress, err := GetSenderFromTx(ctx, polyTx) + if err != nil { + t.Fatalf("GetSenderFromTx failed: %v", err) + } + + if recoveredAddress != expectedAddress { + t.Errorf("address mismatch: got %s, want %s", recoveredAddress.Hex(), expectedAddress.Hex()) + } + }) + } +} + +// createPolyTransactionFromSignedTx converts a signed types.Transaction to a mock PolyTransaction +func createPolyTransactionFromSignedTx(t *testing.T, signedTx *types.Transaction) rpctypes.PolyTransaction { + t.Helper() + + v, r, s := signedTx.RawSignatureValues() + + var to common.Address + if signedTx.To() != nil { + to = *signedTx.To() + } + + mockTx := &mockPolyTransaction{ + txType: uint64(signedTx.Type()), + chainID: signedTx.ChainId().Uint64(), + nonce: signedTx.Nonce(), + gas: signedTx.Gas(), + to: to, + value: signedTx.Value(), + data: signedTx.Data(), + v: v, + r: r, + s: s, + hash: signedTx.Hash(), + } + + // Set price fields based on transaction type + switch signedTx.Type() { + case 0, 1: // Legacy and EIP-2930 + mockTx.gasPrice = signedTx.GasPrice() + case 2: // EIP-1559 + mockTx.maxPriorityFeePerGas = signedTx.GasTipCap().Uint64() + mockTx.maxFeePerGas = signedTx.GasFeeCap().Uint64() + // For testing, use effective gas price + mockTx.gasPrice = signedTx.GasPrice() + } + + return mockTx +} + +// mockPolyTransaction implements rpctypes.PolyTransaction for testing +type mockPolyTransaction struct { + txType uint64 + chainID uint64 + from common.Address + nonce uint64 + gasPrice *big.Int + maxPriorityFeePerGas uint64 + maxFeePerGas uint64 + gas uint64 + to common.Address + value *big.Int + data []byte + v, r, s *big.Int + hash common.Hash + blockNumber *big.Int +} + +func (m *mockPolyTransaction) Type() uint64 { return m.txType } +func (m *mockPolyTransaction) ChainID() uint64 { return m.chainID } +func (m *mockPolyTransaction) From() common.Address { return m.from } +func (m *mockPolyTransaction) Nonce() uint64 { return m.nonce } +func (m *mockPolyTransaction) GasPrice() *big.Int { return m.gasPrice } +func (m *mockPolyTransaction) MaxPriorityFeePerGas() uint64 { return m.maxPriorityFeePerGas } +func (m *mockPolyTransaction) MaxFeePerGas() uint64 { return m.maxFeePerGas } +func (m *mockPolyTransaction) Gas() uint64 { return m.gas } +func (m *mockPolyTransaction) To() common.Address { return m.to } +func (m *mockPolyTransaction) Value() *big.Int { return m.value } +func (m *mockPolyTransaction) Data() []byte { return m.data } +func (m *mockPolyTransaction) DataStr() string { + return "0x" + common.Bytes2Hex(m.data) +} +func (m *mockPolyTransaction) V() *big.Int { return m.v } +func (m *mockPolyTransaction) R() *big.Int { return m.r } +func (m *mockPolyTransaction) S() *big.Int { return m.s } +func (m *mockPolyTransaction) Hash() common.Hash { return m.hash } +func (m *mockPolyTransaction) BlockNumber() *big.Int { + if m.blockNumber != nil { + return m.blockNumber + } + return big.NewInt(0) +} +func (m *mockPolyTransaction) String() string { + return m.hash.Hex() +} +func (m *mockPolyTransaction) MarshalJSON() ([]byte, error) { + return []byte("{}"), nil +}