diff --git a/CLAUDE.md b/CLAUDE.md index 2a1cf39..e19821b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ Comprehensive documentation is available in the `docs/` directory for different - **`docs/app-users.md`**: Complete guide for CLI usage, daemon mode, configuration, and troubleshooting - **`docs/api-consumers.md`**: REST API documentation with bash/curl examples for all endpoints - **`docs/scenario-developers.md`**: Comprehensive guide for implementing custom transaction scenarios +- **`docs/contributing-workloads.md`**: Where new benchmarks/tests belong (spammer config, plugin, or core primitive) and the shared placeholder reference **Before working on any scenario development or API integration, Claude MUST read the relevant documentation files to understand:** - Critical development rules (no root wallet usage, proper nonce management, etc.) diff --git a/README.md b/README.md index ccad77b..594b3a2 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,12 @@ Comprehensive guide for implementing custom transaction scenarios. Covers: - Contract interaction patterns - Testing and debugging scenarios +### 🧩 [Contributing Workloads](./docs/contributing-workloads.md) +Where a new benchmark or test belongs: a spammer config, a plugin, or a core primitive. Covers: +- Choosing between configs, plugins and core changes +- Placeholder reference for generic scenarios +- Checklist for core primitive PRs + ### 🔌 [Plugin System Guide](./docs/plugin-system.md) Guide for developing, distributing, and managing plugins. Covers: - Plugin development and scenario implementation @@ -354,7 +360,7 @@ Guide for developing, distributing, and managing plugins. Covers: - CLI and daemon mode usage - REST API for runtime plugin management - Architecture and Yaegi interpreter limitations -- See [example plugin](./plugins/_example-plugin/) +- See [example plugin](./plugins/_example-plugin/) and the [EIP-7702 delegation example](./plugins/_example-delegation/) ## ✨ Key Features diff --git a/docs/contributing-workloads.md b/docs/contributing-workloads.md new file mode 100644 index 0000000..47fc920 --- /dev/null +++ b/docs/contributing-workloads.md @@ -0,0 +1,122 @@ +# Contributing Workloads + +Spamoor core is the transaction machinery (wallets, nonces, fees, clients, rate limiting, receipts) plus a set of **generic, programmable scenarios**. Benchmarks, attack patterns and EIP tests are **workloads** built on top of it. Contribute a workload at the highest layer that can express it, and change core only to add a primitive that a workload is missing. + +| Layer | What you contribute | Lives in | Review bar | +|---|---|---|---| +| 1. Spammer config | YAML composing existing scenarios | `spammer-configs/*.yaml` (Spammer Library) | Runs on a devnet; headers filled in | +| 2. Plugin | Go scenario with custom logic | Your own repo, loaded with `--plugin ` | Yours; core does not review it | +| 3. Core primitive | Generic capability that layers 1–2 need | This repo | Proven by a layer 1/2 consumer, tested, documented | + +## Choosing a layer + +**Start with a spammer config.** Most workloads are a parameterisation of a generic scenario: + +| Need | Scenario | +|---|---| +| Run custom bytecode in every tx | `geastx` (inline geas or raw bytecode) | +| Deploy a contract and call it with ABI-encoded args | `calltx` | +| Plain value transfers to a fixed or derived address | `eoatx` | +| Deploy many contracts at deterministic CREATE2 addresses | `factorydeploytx` | +| Multi-step deploy/call sequences per cycle | `taskrunner` | +| Replay execution-spec test fixtures | `replay-eest` | + +A config file can hold several spammers, e.g. a `factorydeploytx` preparation spammer and an attack spammer that targets the deployed contracts. See [`max-contract-read.yaml`](../spammer-configs/max-contract-read.yaml) and [`create2-receiver-transfers.yaml`](../spammer-configs/create2-receiver-transfers.yaml). + +**Write a plugin** when the workload needs logic that config cannot express: multi-phase setup that depends on receipts, EIP-7702 delegation flows, per-tx state tracking, custom tx types. Plugins are Go source interpreted at runtime, have access to everything exported in [`plugin/symbols`](../plugin/symbols), and are distributed as `tar.gz` archives that the CLI and daemon load from a URL. See the [Plugin System Guide](./plugin-system.md). + +**Propose a core change** only when a workload cannot be built at layer 1 or 2 because a primitive is missing, for example: +- a placeholder, option or helper that generic scenarios or plugins should share +- a symbol that plugins need but `plugin/symbols` does not export +- a new transaction type or a fix to wallet, nonce, fee or client handling + +Scenario-specific options on a generic scenario (target pools, salt strides, benchmark presets) are not primitives. If a feature only makes sense for one workload, it belongs in that workload's config or plugin. + +## Examples + +One reference contribution per layer. + +### Spammer config: CREATE2 receiver transfers + +[`spammer-configs/create2-receiver-transfers.yaml`](../spammer-configs/create2-receiver-transfers.yaml) sends ETH transfers to a set of contracts at CREATE2 addresses. Two generic scenarios, no Go code: +- `factorydeploytx` deploys 1000 contracts with distinct 1 KiB code through the shared factory. +- `eoatx` sends each tx to `{create2:{factory_address}::{random:1000}}`. + +```bash +spamoor run spammer-configs/create2-receiver-transfers.yaml -h -p -s 0 # deploy receivers +spamoor run spammer-configs/create2-receiver-transfers.yaml -h -p -s 1 # transfers +``` + +In the daemon the same file appears in the Spammer Library after `make generate-spammer-index`. + +### Plugin: delegated counter + +[`plugins/_example-delegation`](../plugins/_example-delegation) deploys a counter contract, delegates an EOA to it with an EIP-7702 set-code tx, then sends txs to the EOA so each one increments its storage. The delegation needs the deployment receipt and the spam targets the delegated EOA, so no spammer config can express it. + +```bash +spamoor --plugin ./plugins/_example-delegation delegated-counter -h -p -c 40 -t 20 +``` + +The [plugin README](../plugins/_example-delegation/README.md) covers validation, packaging and loading from a URL. + +### Core primitive: CREATE2 placeholders + +The `{create2:...}` and `{factory_address}` placeholders ([#297](https://github.com/ethpandaops/spamoor/pull/297)) were added because the config above could not otherwise address a CREATE2 contract set. Against the checklist below: + +| Checklist item | In the change | +|---|---| +| Consumer | `spammer-configs/create2-receiver-transfers.yaml` | +| Generic | One resolver (`utils/placeholders.go`, `scenario/placeholders.go`) replacing three copies, used by `eoatx`, `calltx`, `geastx`, `taskrunner` and plugins | +| Tests and symbols | `utils/placeholders_test.go`, `scenarios/taskrunner/placeholders_test.go`, regenerated `plugin/symbols` | +| Docs | Scenario READMEs, this guide, the Plugin System Guide | + +## Placeholders + +Generic scenarios resolve these placeholders in user-supplied values. Placeholders nest and resolve innermost-first. + +| Placeholder | Value | +|---|---| +| `{txid}` | Transaction index | +| `{random}` | Random uint256 | +| `{random:N}` | Random integer in `[0, N)` | +| `{randomaddr}` | Random address | +| `{factory_address}` | Shared CREATE2 factory deployed by `factorydeploytx` (`well_known_factory: true`) | +| `{create2:::}` | CREATE2 address; `salt` is a decimal or `0x` uint256 | + +Where they apply: + +| Scenario | Field | Placeholders | +|---|---|---| +| `eoatx` | `to` (per tx) | all | +| `calltx` | `call_args` (per tx) | all | +| `calltx` | `call_data` | `{factory_address}` | +| `geastx` | `geas_code` / `geas_file` | `{factory_address}` | +| `taskrunner` | task data | all except `{factory_address}`, plus `{stepid}`, `{contract:name}`, `{contract:name:nonce}` | + +Example: send to a random contract from a 1000-contract CREATE2 set. + +```yaml +to: "{create2:{factory_address}:0xd0923e8cfd0a2bfc5da9c3ba0e9c938e9bc40774659544ab488bf241b918a07a:{random:1000}}" +``` + +Plugins use the same resolver: `scenario.TxPlaceholders(walletPool, txIdx).Resolve(s)`, extended with their own entries if needed. + +## Core primitive checklist + +A core PR must: +1. Ship with, or link to, the spammer config or plugin that needs it. +2. Be generic: usable by more than one scenario or workload, with no workload-specific options. +3. Include tests, and regenerate symbols (`make generate-symbols`) when the exported API changes. +4. Document the primitive where users look for it (scenario README, this guide, the Plugin System Guide). +5. Pass `go fmt ./...`, `go vet ./...` and `staticcheck ./...`. + +## Worked example: account-access benchmarks + +Porting the execution-specs `test_account_access` benchmarks maps onto the layers like this: + +| Workload | Layer | How | +|---|---|---| +| ETH transfers to a CREATE2 contract set | Config | `factorydeploytx` + `eoatx` with `to: "{create2:...}"` | +| CALL/EXTCODEHASH loops over a CREATE2 contract set | Config | `factorydeploytx` + `calltx` (attack bytecode, `{factory_address}` in `call_args`) or `geastx` (`{factory_address}` in the geas code) | +| SLOAD/SSTORE on a storage-bloated EIP-7702 EOA | Plugin | Delegation and slot-range bookkeeping are custom logic | +| Deriving CREATE2 addresses in config | Core primitive | `{create2:...}` and `{factory_address}` placeholders, shared by all scenarios | diff --git a/docs/plugin-system.md b/docs/plugin-system.md index ce97eb5..e580a6c 100644 --- a/docs/plugin-system.md +++ b/docs/plugin-system.md @@ -481,6 +481,8 @@ Plugins can import the following pre-extracted packages: **Standard library:** All Go standard library packages are available (e.g. `fmt`, `context`, `time`, `math/big`, `math/rand`). +Generic helpers that plugins should reuse instead of reimplementing include `scenario.TxPlaceholders` (the shared [placeholder resolver](./contributing-workloads.md#placeholders)) and `scenario.Create2FactoryAddress` (the shared CREATE2 factory used by `factorydeploytx`). + If you need a package that is not listed here, symbols must be extracted first. See [Adding New Package Symbols](#adding-new-package-symbols) in the troubleshooting section. @@ -1038,3 +1040,5 @@ See `plugins/_example-plugin/` for a complete working example that demonstrates: - Fee resolution supporting both gwei and wei - Nonce management with `MarkSkippedNonce` - Wallet pool sizing based on transaction volume + +See `plugins/_example-delegation/` for a workload that needs a plugin rather than a spammer config: a receipt-dependent EIP-7702 delegation followed by a transaction loop against the delegated EOA. diff --git a/plugin/symbols/github_com-ethpandaops-spamoor-scenario.go b/plugin/symbols/github_com-ethpandaops-spamoor-scenario.go index 8cd675a..0beac6e 100644 --- a/plugin/symbols/github_com-ethpandaops-spamoor-scenario.go +++ b/plugin/symbols/github_com-ethpandaops-spamoor-scenario.go @@ -14,18 +14,22 @@ import ( func init() { Symbols["github.com/ethpandaops/spamoor/scenario/scenario"] = map[string]reflect.Value{ // function, constant and variable definitions - "ErrNoClients": reflect.ValueOf(&scenario.ErrNoClients).Elem(), - "ErrNoWallet": reflect.ValueOf(&scenario.ErrNoWallet).Elem(), - "GetScenarioValidFields": reflect.ValueOf(scenario.GetScenarioValidFields), - "GlobalSlotDuration": reflect.ValueOf(&scenario.GlobalSlotDuration).Elem(), - "GroupScenarioName": reflect.ValueOf(constant.MakeFromLiteral("\"group\"", token.STRING, 0)), - "NewConfigValidator": reflect.ValueOf(scenario.NewConfigValidator), - "NewRegistry": reflect.ValueOf(scenario.NewRegistry), - "ParseAndValidateConfig": reflect.ValueOf(scenario.ParseAndValidateConfig), - "RunTransactionScenario": reflect.ValueOf(scenario.RunTransactionScenario), - "ScenarioSourceNative": reflect.ValueOf(scenario.ScenarioSourceNative), - "ScenarioSourcePlugin": reflect.ValueOf(scenario.ScenarioSourcePlugin), - "ValidateScenarioConfig": reflect.ValueOf(scenario.ValidateScenarioConfig), + "Create2FactoryAddress": reflect.ValueOf(scenario.Create2FactoryAddress), + "Create2FactoryWalletName": reflect.ValueOf(constant.MakeFromLiteral("\"create2-factory-deployer\"", token.STRING, 0)), + "ErrNoClients": reflect.ValueOf(&scenario.ErrNoClients).Elem(), + "ErrNoWallet": reflect.ValueOf(&scenario.ErrNoWallet).Elem(), + "FactoryPlaceholders": reflect.ValueOf(scenario.FactoryPlaceholders), + "GetScenarioValidFields": reflect.ValueOf(scenario.GetScenarioValidFields), + "GlobalSlotDuration": reflect.ValueOf(&scenario.GlobalSlotDuration).Elem(), + "GroupScenarioName": reflect.ValueOf(constant.MakeFromLiteral("\"group\"", token.STRING, 0)), + "NewConfigValidator": reflect.ValueOf(scenario.NewConfigValidator), + "NewRegistry": reflect.ValueOf(scenario.NewRegistry), + "ParseAndValidateConfig": reflect.ValueOf(scenario.ParseAndValidateConfig), + "RunTransactionScenario": reflect.ValueOf(scenario.RunTransactionScenario), + "ScenarioSourceNative": reflect.ValueOf(scenario.ScenarioSourceNative), + "ScenarioSourcePlugin": reflect.ValueOf(scenario.ScenarioSourcePlugin), + "TxPlaceholders": reflect.ValueOf(scenario.TxPlaceholders), + "ValidateScenarioConfig": reflect.ValueOf(scenario.ValidateScenarioConfig), // type definitions "Category": reflect.ValueOf((*scenario.Category)(nil)), diff --git a/plugin/symbols/github_com-ethpandaops-spamoor-utils.go b/plugin/symbols/github_com-ethpandaops-spamoor-utils.go index 9aacc86..3c6bc95 100644 --- a/plugin/symbols/github_com-ethpandaops-spamoor-utils.go +++ b/plugin/symbols/github_com-ethpandaops-spamoor-utils.go @@ -25,12 +25,15 @@ func init() { "ReadFileLinesTrimmed": reflect.ValueOf(utils.ReadFileLinesTrimmed), "ReadableAmount": reflect.ValueOf(utils.ReadableAmount), "RecoverPanic": reflect.ValueOf(utils.RecoverPanic), + "TxPlaceholders": reflect.ValueOf(utils.TxPlaceholders), "WeiToEther": reflect.ValueOf(utils.WeiToEther), // type definitions "ABICallDataBuilder": reflect.ValueOf((*utils.ABICallDataBuilder)(nil)), "DeterministicRNG": reflect.ValueOf((*utils.DeterministicRNG)(nil)), "FlexibleJsonUInt64": reflect.ValueOf((*utils.FlexibleJsonUInt64)(nil)), + "PlaceholderFn": reflect.ValueOf((*utils.PlaceholderFn)(nil)), + "Placeholders": reflect.ValueOf((*utils.Placeholders)(nil)), "TxFees": reflect.ValueOf((*utils.TxFees)(nil)), } } diff --git a/plugins/README.md b/plugins/README.md index 575d8f8..bba95d0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -71,6 +71,8 @@ See `docs/scenario-developers.md` for the complete scenario development guide, i - The `onComplete` callback pattern - Configuration via flags and YAML +See `_example-delegation/` for a workload that needs a plugin: receipt-dependent EIP-7702 setup followed by a transaction loop against the delegated EOA. + ## Loading Plugins ### CLI Usage diff --git a/plugins/_example-delegation/README.md b/plugins/_example-delegation/README.md new file mode 100644 index 0000000..79dc872 --- /dev/null +++ b/plugins/_example-delegation/README.md @@ -0,0 +1,33 @@ +# Example plugin: delegated counter + +Reference for the **plugin** layer of [Contributing Workloads](../../docs/contributing-workloads.md). + +The `delegated-counter` scenario: +1. deploys a counter contract (`push0 sload push1 1 add push0 sstore stop`), +2. delegates an unfunded EOA (the `authority` well-known wallet) to it with an EIP-7702 set-code tx, +3. sends `--count`/`--throughput` txs from child wallets to the authority, each incrementing slot 0 of the authority's storage. + +It is a plugin rather than a spammer config because step 2 depends on the receipt of step 1, and the target of step 3 is the delegated EOA rather than the deployed contract. No generic scenario chains those steps. + +## Run + +```bash +spamoor --plugin ./plugins/_example-delegation delegated-counter \ + -h http://localhost:8545 -p -c 40 -t 20 +``` + +Afterwards, slot 0 of the authority address (logged at startup) equals the number of confirmed txs: + +```bash +cast storage 0 --rpc-url http://localhost:8545 +``` + +## Ship it + +Validate, package, and host the archive anywhere reachable over HTTP (e.g. a GitHub release of your repo): + +```bash +spamoor-utils validate-plugin ./plugins/_example-delegation +make plugins # builds plugins/_example-delegation.tar.gz +spamoor --plugin https://example.com/example-delegation.tar.gz delegated-counter ... +``` diff --git a/plugins/_example-delegation/delegatedcounter/delegatedcounter.go b/plugins/_example-delegation/delegatedcounter/delegatedcounter.go new file mode 100644 index 0000000..c775a41 --- /dev/null +++ b/plugins/_example-delegation/delegatedcounter/delegatedcounter.go @@ -0,0 +1,330 @@ +package delegatedcounter + +import ( + "context" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/holiman/uint256" + "github.com/sirupsen/logrus" + "github.com/spf13/pflag" + + "github.com/ethpandaops/spamoor/scenario" + "github.com/ethpandaops/spamoor/spamoor" + "github.com/ethpandaops/spamoor/txbuilder" + "github.com/ethpandaops/spamoor/txtypes" + "github.com/ethpandaops/spamoor/utils" +) + +// counterInitCode deploys `push0 sload push1 1 add push0 sstore stop`, which increments slot 0 on every call. +var counterInitCode = common.FromHex("0x675f546001015f55005f5260086018f3") + +type ScenarioOptions struct { + TotalCount uint64 `yaml:"total_count"` + Throughput uint64 `yaml:"throughput"` + MaxPending uint64 `yaml:"max_pending"` + MaxWallets uint64 `yaml:"max_wallets"` + Rebroadcast uint64 `yaml:"rebroadcast"` + BaseFee float64 `yaml:"base_fee"` + TipFee float64 `yaml:"tip_fee"` + BaseFeeWei string `yaml:"base_fee_wei"` + TipFeeWei string `yaml:"tip_fee_wei"` + GasLimit uint64 `yaml:"gas_limit"` + Timeout string `yaml:"timeout"` + ClientGroup string `yaml:"client_group"` + LogTxs bool `yaml:"log_txs"` +} + +type Scenario struct { + options ScenarioOptions + logger *logrus.Entry + walletPool *spamoor.WalletPool + + authority common.Address +} + +var ScenarioName = "delegated-counter" +var ScenarioDefaultOptions = ScenarioOptions{ + Throughput: 10, + Rebroadcast: 1, + BaseFee: 20, + TipFee: 2, + GasLimit: 100000, +} + +var ScenarioDescriptor = scenario.Descriptor{ + Name: ScenarioName, + Description: "Delegates an EOA to a counter contract via EIP-7702, then sends txs to the EOA so each one increments its storage", + DefaultOptions: ScenarioDefaultOptions, + NewScenario: newScenario, +} + +func newScenario(logger logrus.FieldLogger) scenario.Scenario { + return &Scenario{ + options: ScenarioDefaultOptions, + logger: logger.WithField("scenario", ScenarioName), + } +} + +func (s *Scenario) Flags(flags *pflag.FlagSet) error { + flags.Uint64VarP(&s.options.TotalCount, "count", "c", ScenarioDefaultOptions.TotalCount, "Total number of counter transactions to send") + flags.Uint64VarP(&s.options.Throughput, "throughput", "t", ScenarioDefaultOptions.Throughput, "Number of counter transactions to send per slot") + flags.Uint64Var(&s.options.MaxPending, "max-pending", ScenarioDefaultOptions.MaxPending, "Maximum number of pending transactions") + flags.Uint64Var(&s.options.MaxWallets, "max-wallets", ScenarioDefaultOptions.MaxWallets, "Maximum number of child wallets to use") + flags.Uint64Var(&s.options.Rebroadcast, "rebroadcast", ScenarioDefaultOptions.Rebroadcast, "Enable reliable rebroadcast system") + flags.Float64Var(&s.options.BaseFee, "basefee", ScenarioDefaultOptions.BaseFee, "Max fee per gas to use in transactions (in gwei)") + flags.Float64Var(&s.options.TipFee, "tipfee", ScenarioDefaultOptions.TipFee, "Max tip per gas to use in transactions (in gwei)") + flags.StringVar(&s.options.BaseFeeWei, "basefee-wei", "", "Max fee per gas in wei (overrides --basefee)") + flags.StringVar(&s.options.TipFeeWei, "tipfee-wei", "", "Max tip per gas in wei (overrides --tipfee)") + flags.Uint64Var(&s.options.GasLimit, "gaslimit", ScenarioDefaultOptions.GasLimit, "Gas limit for counter transactions") + flags.StringVar(&s.options.Timeout, "timeout", ScenarioDefaultOptions.Timeout, "Timeout for the scenario (e.g. '1h', '30m', '5s') - empty means no timeout") + flags.StringVar(&s.options.ClientGroup, "client-group", ScenarioDefaultOptions.ClientGroup, "Client group to use for sending transactions") + flags.BoolVar(&s.options.LogTxs, "log-txs", ScenarioDefaultOptions.LogTxs, "Log all submitted transactions") + return nil +} + +func (s *Scenario) Init(options *scenario.Options) error { + s.walletPool = options.WalletPool + + if options.Config != "" { + if err := scenario.ParseAndValidateConfig(&ScenarioDescriptor, options.Config, &s.options, s.logger); err != nil { + return err + } + } + + if s.options.TotalCount == 0 && s.options.Throughput == 0 { + return fmt.Errorf("neither total count nor throughput limit set, must define at least one of them (see --help for list of all flags)") + } + + walletCount := s.options.MaxWallets + if walletCount == 0 { + walletCount = s.options.Throughput * 10 + if walletCount < 10 { + walletCount = 10 + } else if walletCount > 1000 { + walletCount = 1000 + } + } + s.walletPool.SetWalletCount(walletCount) + + s.walletPool.AddWellKnownWallet(&spamoor.WellKnownWalletConfig{ + Name: "deployer", + RefillAmount: utils.EtherToWei(uint256.NewInt(1)), + RefillBalance: utils.EtherToWei(uint256.NewInt(1)), + }) + // The authority only signs the delegation, so it is never funded: any ETH sent to it would run the counter. + s.walletPool.AddWellKnownWallet(&spamoor.WellKnownWalletConfig{ + Name: "authority", + RefillAmount: uint256.NewInt(0), + RefillBalance: uint256.NewInt(0), + }) + + return nil +} + +func (s *Scenario) Run(ctx context.Context) error { + s.logger.Infof("starting scenario: %s", ScenarioName) + defer s.logger.Infof("scenario %s finished.", ScenarioName) + + // The setup is a receipt-dependent chain (deploy -> delegate to the deployed address), which a + // spammer config cannot express. + counter, err := s.deployCounter(ctx) + if err != nil { + return err + } + if err := s.delegate(ctx, counter); err != nil { + return err + } + s.logger.Infof("authority %s delegated to counter %s", s.authority.Hex(), counter.Hex()) + + maxPending := s.options.MaxPending + if maxPending == 0 { + maxPending = s.walletPool.GetConfiguredWalletCount() * 10 + } + + var timeout time.Duration + if s.options.Timeout != "" { + if timeout, err = time.ParseDuration(s.options.Timeout); err != nil { + return fmt.Errorf("invalid timeout value: %w", err) + } + } + + return scenario.RunTransactionScenario(ctx, scenario.TransactionScenarioOptions{ + TotalCount: s.options.TotalCount, + Throughput: s.options.Throughput, + MaxPending: maxPending, + Timeout: timeout, + WalletPool: s.walletPool, + Logger: s.logger, + ProcessNextTxFn: func(ctx context.Context, params *scenario.ProcessNextTxParams) error { + receiptChan, tx, err := s.sendCounterTx(ctx, params.TxIdx) + + params.NotifySubmitted() + params.OrderedLogCb(func() { + if err != nil { + s.logger.Warnf("could not send transaction: %v", err) + } else if s.options.LogTxs { + s.logger.Infof("sent tx #%6d: %v", params.TxIdx+1, tx.Hash().String()) + } + }) + + if _, waitErr := receiptChan.Wait(ctx); waitErr != nil { + return waitErr + } + return err + }, + }) +} + +func (s *Scenario) setupClient() (*spamoor.Client, *uint256.Int, *uint256.Int, error) { + client := s.walletPool.GetClient( + spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, 0), + spamoor.WithClientGroup(s.options.ClientGroup), + ) + if client == nil { + return nil, nil, nil, scenario.ErrNoClients + } + baseFeeWei, tipFeeWei := spamoor.ResolveFees(s.options.BaseFee, s.options.TipFee, s.options.BaseFeeWei, s.options.TipFeeWei) + feeCap, tipCap, err := s.walletPool.GetSuggestedFees(client, baseFeeWei, tipFeeWei) + if err != nil { + return nil, nil, nil, err + } + return client, uint256.MustFromBig(feeCap), uint256.MustFromBig(tipCap), nil +} + +func (s *Scenario) deployCounter(ctx context.Context) (common.Address, error) { + deployer := s.walletPool.GetWellKnownWallet("deployer") + client, feeCap, tipCap, err := s.setupClient() + if err != nil { + return common.Address{}, err + } + + txData, err := txbuilder.DynFeeTx(&txbuilder.TxMetadata{ + GasFeeCap: feeCap, + GasTipCap: tipCap, + Gas: s.walletPool.EstimateDeployGas(ctx, client, deployer.GetAddress(), nil, counterInitCode), + Value: uint256.NewInt(0), + Data: counterInitCode, + }) + if err != nil { + return common.Address{}, err + } + tx, err := deployer.BuildDynamicFeeTx(txData) + if err != nil { + return common.Address{}, err + } + + receipt, err := s.walletPool.GetTxPool().SendAndAwaitTransaction(ctx, deployer, tx, &spamoor.SendTransactionOptions{ + Client: client, + Rebroadcast: true, + }) + if err != nil { + return common.Address{}, fmt.Errorf("counter deployment failed: %w", err) + } + if receipt.Status != txtypes.ReceiptStatusSuccessful { + return common.Address{}, fmt.Errorf("counter deployment reverted") + } + return receipt.ContractAddress, nil +} + +func (s *Scenario) delegate(ctx context.Context, counter common.Address) error { + deployer := s.walletPool.GetWellKnownWallet("deployer") + authority := s.walletPool.GetWellKnownWallet("authority") + s.authority = authority.GetAddress() + + client, feeCap, tipCap, err := s.setupClient() + if err != nil { + return err + } + + auth, err := txtypes.SignAuthorization(txtypes.SetCodeAuthorization{ + ChainID: *uint256.MustFromBig(s.walletPool.GetChainId()), + Address: counter, + Nonce: authority.GetNextNonce(), + }, authority.GetPrivateKey()) + if err != nil { + return fmt.Errorf("could not sign authorization: %w", err) + } + + // Authorizations apply before execution, so sending this tx to the authority would already bump the counter. + deployerAddr := deployer.GetAddress() + txData, err := txbuilder.SetCodeTx(&txbuilder.TxMetadata{ + GasFeeCap: feeCap, + GasTipCap: tipCap, + Gas: 200000, + To: &deployerAddr, + Value: uint256.NewInt(0), + AuthList: []txtypes.SetCodeAuthorization{auth}, + }) + if err != nil { + return err + } + tx, err := deployer.BuildSetCodeTx(txData) + if err != nil { + return err + } + + receipt, err := s.walletPool.GetTxPool().SendAndAwaitTransaction(ctx, deployer, tx, &spamoor.SendTransactionOptions{ + Client: client, + Rebroadcast: true, + }) + if err != nil { + return fmt.Errorf("delegation failed: %w", err) + } + if receipt.Status != txtypes.ReceiptStatusSuccessful { + return fmt.Errorf("delegation reverted") + } + return nil +} + +func (s *Scenario) sendCounterTx(ctx context.Context, txIdx uint64) (scenario.ReceiptChan, *txtypes.Transaction, error) { + wallet := s.walletPool.GetWallet(spamoor.SelectWalletByIndex, int(txIdx)) + client := s.walletPool.GetClient( + spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, int(txIdx)), + spamoor.WithClientGroup(s.options.ClientGroup), + ) + if wallet == nil { + return nil, nil, scenario.ErrNoWallet + } + if client == nil { + return nil, nil, scenario.ErrNoClients + } + + baseFeeWei, tipFeeWei := spamoor.ResolveFees(s.options.BaseFee, s.options.TipFee, s.options.BaseFeeWei, s.options.TipFeeWei) + feeCap, tipCap, err := s.walletPool.GetSuggestedFees(client, baseFeeWei, tipFeeWei) + if err != nil { + return nil, nil, err + } + + txData, err := txbuilder.DynFeeTx(&txbuilder.TxMetadata{ + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + Gas: s.options.GasLimit, + To: &s.authority, + Value: uint256.NewInt(0), + }) + if err != nil { + return nil, nil, err + } + tx, err := wallet.BuildDynamicFeeTx(txData) + if err != nil { + return nil, nil, err + } + + receiptChan := make(scenario.ReceiptChan, 1) + err = s.walletPool.GetTxPool().SendTransaction(ctx, wallet, tx, &spamoor.SendTransactionOptions{ + Client: client, + ClientGroup: s.options.ClientGroup, + Rebroadcast: s.options.Rebroadcast > 0, + OnComplete: func(tx *txtypes.Transaction, receipt *txtypes.Receipt, err error) { + receiptChan <- receipt + }, + LogFn: spamoor.GetDefaultLogFn(s.logger, "", fmt.Sprintf("%6d", txIdx+1), tx), + }) + if err != nil { + wallet.MarkSkippedNonce(tx.Nonce()) + return nil, nil, err + } + return receiptChan, tx, nil +} diff --git a/plugins/_example-delegation/plugin.go b/plugins/_example-delegation/plugin.go new file mode 100644 index 0000000..7eea116 --- /dev/null +++ b/plugins/_example-delegation/plugin.go @@ -0,0 +1,21 @@ +package plugin + +import ( + "github.com/ethpandaops/spamoor/plugins/_example-delegation/delegatedcounter" + "github.com/ethpandaops/spamoor/scenario" +) + +// PluginDescriptor defines the plugin metadata and scenarios. +var PluginDescriptor = scenario.PluginDescriptor{ + Name: "example-delegation", + Description: "Example plugin: EIP-7702 delegation workload that needs receipt-dependent setup", + Categories: []*scenario.Category{ + { + Name: "Examples", + Description: "Example plugin scenarios", + Descriptors: []*scenario.Descriptor{ + &delegatedcounter.ScenarioDescriptor, + }, + }, + }, +} diff --git a/scenario/placeholders.go b/scenario/placeholders.go new file mode 100644 index 0000000..b9f7b50 --- /dev/null +++ b/scenario/placeholders.go @@ -0,0 +1,34 @@ +package scenario + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/spamoor/spamoor" + "github.com/ethpandaops/spamoor/utils" +) + +// Create2FactoryWalletName is the very well known wallet that deploys the shared CREATE2 factory. +const Create2FactoryWalletName = "create2-factory-deployer" + +// Create2FactoryAddress returns the shared CREATE2 factory deployed by factorydeploytx. +func Create2FactoryAddress(walletPool *spamoor.WalletPool) common.Address { + return crypto.CreateAddress(walletPool.GetVeryWellKnownWalletAddress(Create2FactoryWalletName), 0) +} + +// FactoryPlaceholders returns the placeholders that are fixed for a wallet pool: {factory_address}. +func FactoryPlaceholders(walletPool *spamoor.WalletPool) utils.Placeholders { + return utils.Placeholders{ + "factory_address": func(string) (string, error) { + return Create2FactoryAddress(walletPool).Hex(), nil + }, + } +} + +// TxPlaceholders returns the per-transaction placeholders (see utils.TxPlaceholders) plus {factory_address}. +func TxPlaceholders(walletPool *spamoor.WalletPool, txIdx uint64) utils.Placeholders { + placeholders := utils.TxPlaceholders(txIdx) + for name, fn := range FactoryPlaceholders(walletPool) { + placeholders[name] = fn + } + return placeholders +} diff --git a/scenarios/calltx/README.md b/scenarios/calltx/README.md index b764a18..768081c 100644 --- a/scenarios/calltx/README.md +++ b/scenarios/calltx/README.md @@ -40,6 +40,10 @@ The `--call-args` parameter supports the following placeholders: - `{random}` - Random uint256 value - `{random:N}` - Random number between 0 and N - `{randomaddr}` - Random Ethereum address +- `{factory_address}` - Shared CREATE2 factory deployed by `factorydeploytx` +- `{create2:::}` - CREATE2 address (placeholders nest, e.g. `{create2:{factory_address}:0x...:{txid}}`) + +See [Contributing Workloads](../../docs/contributing-workloads.md#placeholders) for the full placeholder reference. ### Transaction Settings - `--basefee` - Max fee per gas in gwei (default: 20) diff --git a/scenarios/calltx/calltx.go b/scenarios/calltx/calltx.go index 68a9f54..eddad62 100644 --- a/scenarios/calltx/calltx.go +++ b/scenarios/calltx/calltx.go @@ -237,7 +237,10 @@ func (s *Scenario) Init(options *scenario.Options) error { abiContent = s.options.CallABI } - callArgsWithPlaceholders := s.replaceCallDataPlaceholders(s.options.CallArgs) + callArgsWithPlaceholders, err := scenario.FactoryPlaceholders(s.walletPool).Resolve(s.options.CallArgs) + if err != nil { + return fmt.Errorf("failed to resolve call args placeholders: %w", err) + } s.abiCallBuilder, err = utils.NewABICallDataBuilder(abiContent, s.options.CallFnName, s.options.CallFnSig, callArgsWithPlaceholders) if err != nil { return fmt.Errorf("failed to initialize ABI call builder: %w", err) @@ -493,7 +496,10 @@ func (s *Scenario) sendTx(ctx context.Context, txIdx uint64) (scenario.ReceiptCh } } else if s.options.CallData != "" { // Use raw call data with placeholder replacement - callDataWithPlaceholders := s.replaceCallDataPlaceholders(s.options.CallData) + callDataWithPlaceholders, err := scenario.FactoryPlaceholders(s.walletPool).Resolve(s.options.CallData) + if err != nil { + return nil, nil, client, wallet, err + } dataBytes, err := txbuilder.ParseBlobRefsBytes(strings.Split(callDataWithPlaceholders, ","), nil) if err != nil { return nil, nil, client, wallet, err @@ -549,21 +555,6 @@ func (s *Scenario) sendTx(ctx context.Context, txIdx uint64) (scenario.ReceiptCh return receiptChan, tx, client, wallet, nil } -// replaceCallDataPlaceholders replaces placeholders in call data with actual values -func (s *Scenario) replaceCallDataPlaceholders(callData string) string { - // Replace factory address placeholder with well-known CREATE2 factory address - if strings.Contains(callData, "{factory_address}") { - // Use the same well-known CREATE2 factory address as factorydeploytx scenario - // Get the very well known factory deployer wallet address and calculate the factory address - factoryWalletAddr := s.walletPool.GetVeryWellKnownWalletAddress("create2-factory-deployer") - factoryAddr := crypto.CreateAddress(factoryWalletAddr, 0) - result := strings.ReplaceAll(callData, "{factory_address}", factoryAddr.Hex()) - return result - } - - return callData -} - // calculateChildContractAddress calculates the address of a child contract based on the nonce path func (s *Scenario) calculateChildContractAddress(parentAddr common.Address, noncePath string) (common.Address, error) { if noncePath == "" || !strings.HasPrefix(noncePath, ".") { diff --git a/scenarios/eoatx/README.md b/scenarios/eoatx/README.md index fe5fdaa..36002c0 100644 --- a/scenarios/eoatx/README.md +++ b/scenarios/eoatx/README.md @@ -25,7 +25,7 @@ spamoor eoatx [flags] - `--gaslimit` - Gas limit per transaction (default: 21000) - `--amount` - Transfer amount per transaction in gwei (default: 20) - `--random-amount` - Use random amounts (with --amount as limit) -- `--to` - Target address to send transactions to (overrides other target options when specified) +- `--to` - Target address to send transactions to (overrides other target options when specified). Resolved per transaction, so it accepts [placeholders](../../docs/contributing-workloads.md#placeholders) such as `{create2:{factory_address}::{random:1000}}` - `--random-target` - Use random destination addresses - `--self-tx-only` - Use sender wallet as destination address - `--data` - Custom transaction call data to send diff --git a/scenarios/eoatx/eoatx.go b/scenarios/eoatx/eoatx.go index 4f07bf2..d732f76 100644 --- a/scenarios/eoatx/eoatx.go +++ b/scenarios/eoatx/eoatx.go @@ -94,7 +94,7 @@ func (s *Scenario) Flags(flags *pflag.FlagSet) error { flags.Uint64Var(&s.options.GasLimit, "gaslimit", ScenarioDefaultOptions.GasLimit, "Gas limit to use in transactions") flags.Uint64Var(&s.options.Amount, "amount", ScenarioDefaultOptions.Amount, "Transfer amount per transaction (in gwei)") flags.StringVar(&s.options.Data, "data", ScenarioDefaultOptions.Data, "Transaction call data to send") - flags.StringVar(&s.options.To, "to", ScenarioDefaultOptions.To, "Target address to send transactions to (uses existing logic when empty)") + flags.StringVar(&s.options.To, "to", ScenarioDefaultOptions.To, "Target address to send transactions to, supports placeholders like {create2:{factory_address}::{txid}} (uses existing logic when empty)") flags.StringVar(&s.options.Timeout, "timeout", ScenarioDefaultOptions.Timeout, "Timeout for the scenario (e.g. '1h', '30m', '5s') - empty means no timeout") flags.BoolVar(&s.options.RandomAmount, "random-amount", ScenarioDefaultOptions.RandomAmount, "Use random amounts for transactions (with --amount as limit)") flags.BoolVar(&s.options.RandomTarget, "random-target", ScenarioDefaultOptions.RandomTarget, "Use random to addresses for transactions") @@ -138,6 +138,12 @@ func (s *Scenario) Init(options *scenario.Options) error { return fmt.Errorf("neither total count nor throughput limit set, must define at least one of them (see --help for list of all flags)") } + if s.options.To != "" { + if _, err := s.resolveTarget(0); err != nil { + return err + } + } + if blockLimit := s.walletPool.GetTxPool().GetCurrentGasLimit(); blockLimit > 0 && s.options.GasLimit > blockLimit { s.logger.Warnf("Gas limit %d exceeds block gas limit %d and will most likely be dropped by the execution layer client", s.options.GasLimit, blockLimit) } @@ -216,6 +222,18 @@ func (s *Scenario) Run(ctx context.Context) error { return err } +// resolveTarget evaluates the --to placeholders for transaction txIdx. +func (s *Scenario) resolveTarget(txIdx uint64) (common.Address, error) { + to, err := scenario.TxPlaceholders(s.walletPool, txIdx).Resolve(s.options.To) + if err != nil { + return common.Address{}, err + } + if !common.IsHexAddress(to) { + return common.Address{}, fmt.Errorf("invalid target address %q", to) + } + return common.HexToAddress(to), nil +} + func (s *Scenario) sendTx(ctx context.Context, txIdx uint64) (scenario.ReceiptChan, *txtypes.Transaction, *spamoor.Client, *spamoor.Wallet, error) { client := s.walletPool.GetClient( spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, int(txIdx)), @@ -257,7 +275,10 @@ func (s *Scenario) sendTx(ctx context.Context, txIdx uint64) (scenario.ReceiptCh // avoid paying the EIP-2780 state-gas charge on every send. targetIsEmpty := false if s.options.To != "" { - toAddr = common.HexToAddress(s.options.To) + toAddr, err = s.resolveTarget(txIdx) + if err != nil { + return nil, nil, client, wallet, err + } } else { toAddr = s.walletPool.GetWallet(spamoor.SelectWalletByIndex, int(txIdx)+1).GetAddress() if s.options.RandomTarget { diff --git a/scenarios/factorydeploytx/factorydeploytx.go b/scenarios/factorydeploytx/factorydeploytx.go index ac6c269..09e52ce 100644 --- a/scenarios/factorydeploytx/factorydeploytx.go +++ b/scenarios/factorydeploytx/factorydeploytx.go @@ -121,7 +121,7 @@ func (s *Scenario) Init(options *scenario.Options) error { // Set up well-known factory wallet if enabled if s.options.WellKnownFactory { s.walletPool.AddWellKnownWallet(&spamoor.WellKnownWalletConfig{ - Name: "create2-factory-deployer", + Name: scenario.Create2FactoryWalletName, RefillAmount: uint256.NewInt(10000000000000000000), // 10 ETH RefillBalance: uint256.NewInt(1000000000000000000), // 1 ETH VeryWellKnown: true, @@ -258,7 +258,7 @@ func (s *Scenario) deployFactory(ctx context.Context) (common.Address, error) { return common.HexToAddress(s.options.FactoryAddress), nil } - factoryWallet := s.walletPool.GetWellKnownWallet("create2-factory-deployer") + factoryWallet := s.walletPool.GetWellKnownWallet(scenario.Create2FactoryWalletName) if factoryWallet == nil { return common.Address{}, fmt.Errorf("factory deployer wallet not available") } diff --git a/scenarios/geastx/README.md b/scenarios/geastx/README.md index 7709d5a..bc85734 100644 --- a/scenarios/geastx/README.md +++ b/scenarios/geastx/README.md @@ -20,6 +20,7 @@ spamoor geastx [flags] - `--geasfile`  Path to a file containing geas opcodes / assembly - `--geascode`  Inline geas opcodes / assembly string (overrides `--geasfile`) +`{factory_address}` in the code is replaced with the shared CREATE2 factory deployed by `factorydeploytx`. ### Volume control ( **either** `-c` **or** `-t` is required ) - `-c, --count`  Total number of geas transactions to send diff --git a/scenarios/geastx/geastx.go b/scenarios/geastx/geastx.go index eee3f7f..7d8e2cc 100644 --- a/scenarios/geastx/geastx.go +++ b/scenarios/geastx/geastx.go @@ -182,6 +182,11 @@ func (s *Scenario) Run(ctx context.Context) error { return fmt.Errorf("no geas code or file provided") } + geasCode, err := scenario.FactoryPlaceholders(s.walletPool).Resolve(geasCode) + if err != nil { + return err + } + receipt, _, err := s.sendDeploymentTx(ctx, s.trimGeasOpcodes(geasCode)) if err != nil { return err diff --git a/scenarios/taskrunner/README.md b/scenarios/taskrunner/README.md index dc8efe0..aa9f67d 100644 --- a/scenarios/taskrunner/README.md +++ b/scenarios/taskrunner/README.md @@ -177,6 +177,7 @@ TaskRunner supports dynamic placeholders in arguments and bytecode: - `{randomaddr}` - Random Ethereum address - `{txid}` - Current transaction ID - `{stepid}` - Current step index within execution cycle +- `{create2:::}` - CREATE2 address; placeholders nest, e.g. `{create2:{contract:factory}:0x...:{txid}}` #### Contract Address Placeholders - `{contract:name}` - Reference to deployed contract address diff --git a/scenarios/taskrunner/placeholders.go b/scenarios/taskrunner/placeholders.go index e0dfb5c..f6c4c46 100644 --- a/scenarios/taskrunner/placeholders.go +++ b/scenarios/taskrunner/placeholders.go @@ -1,15 +1,13 @@ package taskrunner import ( - "crypto/rand" "fmt" - "math/big" "regexp" "strconv" "strings" - "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" + "github.com/ethpandaops/spamoor/utils" ) // ProcessContractPlaceholders processes {contract:name} and {contract:name:nonce} placeholders @@ -81,103 +79,21 @@ func ProcessContractPlaceholders(str string, registry *ContractRegistry, stripPr return processed, nil } -// ProcessBasicPlaceholders processes {txid}, {stepid}, {random}, {random:N}, {randomaddr} placeholders +// ProcessBasicPlaceholders processes the shared transaction placeholders (see utils.TxPlaceholders) and {stepid}. // If stripPrefix is true, removes 0x prefix from addresses (for use in bytecode/calldata) func ProcessBasicPlaceholders(str string, txIdx uint64, stepIdx int, stripPrefix bool) (string, error) { - processed := str - - // Replace {txid} with transaction index - processed = strings.ReplaceAll(processed, "{txid}", fmt.Sprintf("%d", txIdx)) - - // Replace {stepid} with step index - processed = strings.ReplaceAll(processed, "{stepid}", fmt.Sprintf("%d", stepIdx)) - - // Replace {random} with random uint256 - randomRegex := regexp.MustCompile(`\{random\}`) - processed = randomRegex.ReplaceAllStringFunc(processed, func(match string) string { - randomVal, err := generateRandomUint256() - if err != nil { - return match // Will cause error to be returned later - } - return randomVal - }) - - // Replace {random:N} with random number between 0 and N - randomRangeRegex := regexp.MustCompile(`\{random:(\d+)\}`) - processed = randomRangeRegex.ReplaceAllStringFunc(processed, func(match string) string { - submatch := randomRangeRegex.FindStringSubmatch(match) - if len(submatch) != 2 { - return match - } - maxVal, err := strconv.ParseUint(submatch[1], 10, 64) - if err != nil { - return match // Will cause error to be returned later - } - randomVal, err := generateRandomInRange(maxVal) - if err != nil { - return match // Will cause error to be returned later - } - return fmt.Sprintf("%d", randomVal) - }) - - // Check for any unresolved {random:N} placeholders (indicating errors) - if randomRangeRegex.MatchString(processed) { - matches := randomRangeRegex.FindAllStringSubmatch(processed, -1) - for _, match := range matches { - if len(match) == 2 { - if _, err := strconv.ParseUint(match[1], 10, 64); err != nil { - return "", fmt.Errorf("invalid random range: %s", match[1]) - } - } - } - return "", fmt.Errorf("failed to process some random range placeholders") + placeholders := utils.TxPlaceholders(txIdx) + placeholders["stepid"] = func(string) (string, error) { + return strconv.Itoa(stepIdx), nil } - - // Replace {randomaddr} with random address - randomAddrRegex := regexp.MustCompile(`\{randomaddr\}`) - processed = randomAddrRegex.ReplaceAllStringFunc(processed, func(match string) string { - randomAddr, err := generateRandomAddress() - if err != nil { - return match // Will cause error to be returned later - } - // Strip 0x prefix if requested (for bytecode/calldata usage) - if stripPrefix && strings.HasPrefix(randomAddr, "0x") { - randomAddr = randomAddr[2:] + if stripPrefix { + for _, name := range []string{"randomaddr", "create2"} { + resolve := placeholders[name] + placeholders[name] = func(arg string) (string, error) { + addr, err := resolve(arg) + return strings.TrimPrefix(addr, "0x"), err + } } - return randomAddr - }) - - return processed, nil -} - -// Helper functions for placeholder generation -func generateRandomUint256() (string, error) { - bytes := make([]byte, 32) - _, err := rand.Read(bytes) - if err != nil { - return "", err - } - return new(big.Int).SetBytes(bytes).String(), nil -} - -func generateRandomInRange(max uint64) (uint64, error) { - if max == 0 { - return 0, nil - } - bytes := make([]byte, 8) - _, err := rand.Read(bytes) - if err != nil { - return 0, err - } - randomVal := new(big.Int).SetBytes(bytes).Uint64() - return randomVal % max, nil -} - -func generateRandomAddress() (string, error) { - bytes := make([]byte, 20) - _, err := rand.Read(bytes) - if err != nil { - return "", err } - return common.BytesToAddress(bytes).Hex(), nil + return placeholders.Resolve(str) } diff --git a/scenarios/taskrunner/placeholders_test.go b/scenarios/taskrunner/placeholders_test.go new file mode 100644 index 0000000..1235396 --- /dev/null +++ b/scenarios/taskrunner/placeholders_test.go @@ -0,0 +1,39 @@ +package taskrunner + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +func TestNestedCreate2Placeholder(t *testing.T) { + factory := common.HexToAddress("0x3333333333333333333333333333333333333333") + initCodeHash := crypto.Keccak256Hash([]byte{0x00}) + registry := NewContractRegistry() + registry.Set("factory", factory) + + var salt [32]byte + salt[31] = 3 + want := crypto.CreateAddress2(factory, salt, initCodeHash[:]).Hex() + + for _, stripPrefix := range []bool{false, true} { + in := "{create2:{contract:factory}:" + initCodeHash.Hex() + ":{txid}}-{stepid}" + processed, err := ProcessContractPlaceholders(in, registry, stripPrefix) + if err != nil { + t.Fatal(err) + } + got, err := ProcessBasicPlaceholders(processed, 3, 1, stripPrefix) + if err != nil { + t.Fatal(err) + } + expected := want + "-1" + if stripPrefix { + expected = strings.TrimPrefix(expected, "0x") + } + if got != expected { + t.Fatalf("stripPrefix=%v: got %q, want %q", stripPrefix, got, expected) + } + } +} diff --git a/spammer-configs/_index.yaml b/spammer-configs/_index.yaml index 8145f76..c5ce944 100644 --- a/spammer-configs/_index.yaml +++ b/spammer-configs/_index.yaml @@ -1,8 +1,8 @@ # Auto-generated spammer configuration index -# Generated: 2026-08-29T04:02:57Z +# Generated: 2026-10-02T13:25:01Z # DO NOT EDIT MANUALLY - This file is automatically generated by scripts/generate-spammer-index.go -generated: 2026-08-29T04:02:57.487139314Z +generated: 2026-10-02T13:25:01.020109783Z configs: - file: test-block-history-logs.yaml name: Block history logger (EIP-2935 & EIP-4788) @@ -15,6 +15,19 @@ configs: spammer_count: 1 scenarios: - geastx + - file: create2-receiver-transfers.yaml + name: CREATE2 receiver transfers + description: Deploys 1000 contracts via the shared CREATE2 factory, then sends plain ETH transfers to them so every tx touches an existing contract account + tags: + - performance + - state-access + - create2 + - eoa + spammer_count: 2 + scenarios: + - eoatx + - factorydeploytx + min_version: v1.2.5 - file: deposit-contract-batch.yaml name: Deposit Contract Batch Spammer description: Spams the beacon-chain deposit contract via a tiny geas batching contract that performs N deposits per tx (1 ETH each). Two flavours are provided - top-ups for an existing genesis validator (no signature needed) and full deposits for a fresh validator (with valid BLS signature). Each tx is sized to use ~10M gas to push as many deposits as possible per block. diff --git a/spammer-configs/create2-receiver-transfers.yaml b/spammer-configs/create2-receiver-transfers.yaml new file mode 100644 index 0000000..a7aef89 --- /dev/null +++ b/spammer-configs/create2-receiver-transfers.yaml @@ -0,0 +1,28 @@ +# Name: CREATE2 receiver transfers +# Description: Deploys 1000 contracts via the shared CREATE2 factory, then sends plain ETH transfers to them so every tx touches an existing contract account +# Tags: performance, state-access, create2, eoa +# Min_Version: v1.2.5 +- scenario: factorydeploytx + name: CREATE2 receivers (preparation) + description: Deploys 1000 contracts with distinct 1 KiB runtime code (first byte STOP, so value transfers succeed) via the shared CREATE2 factory + config: + seed: create2-receivers-deploy + total_count: 1000 + throughput: 20 + max_pending: 40 + gas_limit: 3000000 + well_known_factory: true + # geas: gas number xor push 0x20 mstore push 0x400 push0 return + init_code: 5a43186020526104005ff3 + start_salt: 0 +- scenario: eoatx + name: CREATE2 receiver transfers + description: Sends 1 gwei to a random contract from the preparation spammer in every tx + config: + seed: create2-receivers-transfer + throughput: 50 + amount: 1 + gas_limit: 50000 + # {create2:::}; use {txid} instead of {random:1000} + # with total_count 1000 to hit every receiver exactly once. + to: "{create2:{factory_address}:0xd0923e8cfd0a2bfc5da9c3ba0e9c938e9bc40774659544ab488bf241b918a07a:{random:1000}}" diff --git a/utils/abi.go b/utils/abi.go index 43e8992..da72001 100644 --- a/utils/abi.go +++ b/utils/abi.go @@ -1,11 +1,9 @@ package utils import ( - "crypto/rand" "encoding/json" "fmt" "math/big" - "regexp" "strconv" "strings" @@ -254,54 +252,7 @@ func (b *ABICallDataBuilder) processArgs(args string, txIdx uint64) (string, err if args == "" { return "[]", nil } - - // Replace placeholders - processed := args - - // Replace {txid} with transaction index - processed = strings.ReplaceAll(processed, "{txid}", fmt.Sprintf("%d", txIdx)) - - // Replace {random} with random uint256 - randomRegex := regexp.MustCompile(`\{random\}`) - randomMatches := randomRegex.FindAllStringSubmatch(processed, -1) - for _, match := range randomMatches { - randomVal, err := generateRandomUint256() - if err != nil { - return "", fmt.Errorf("failed to generate random value: %w", err) - } - processed = strings.Replace(processed, match[0], randomVal, 1) - } - - // Replace {random:N} with random number between 0 and N - randomRangeRegex := regexp.MustCompile(`\{random:(\d+)\}`) - randomRangeMatches := randomRangeRegex.FindAllStringSubmatch(processed, -1) - for _, match := range randomRangeMatches { - if len(match) != 2 { - continue - } - maxVal, err := strconv.ParseUint(match[1], 10, 64) - if err != nil { - return "", fmt.Errorf("invalid random range: %s", match[1]) - } - randomVal, err := generateRandomInRange(maxVal) - if err != nil { - return "", fmt.Errorf("failed to generate random value in range: %w", err) - } - processed = strings.Replace(processed, match[0], fmt.Sprintf("%d", randomVal), 1) - } - - // Replace {randomaddr} with random address - randomAddrRegex := regexp.MustCompile(`\{randomaddr\}`) - randomAddrMatches := randomAddrRegex.FindAllStringSubmatch(processed, -1) - for _, match := range randomAddrMatches { - randomAddr, err := generateRandomAddress() - if err != nil { - return "", fmt.Errorf("failed to generate random address: %w", err) - } - processed = strings.Replace(processed, match[0], randomAddr, 1) - } - - return processed, nil + return TxPlaceholders(txIdx).Resolve(args) } func (b *ABICallDataBuilder) convertArgs(args []interface{}) ([]interface{}, error) { @@ -460,37 +411,6 @@ func convertToSlice(arg interface{}, expectedType abi.Type) (interface{}, error) return converted, nil } -func generateRandomUint256() (string, error) { - bytes := make([]byte, 32) - _, err := rand.Read(bytes) - if err != nil { - return "", err - } - return new(big.Int).SetBytes(bytes).String(), nil -} - -func generateRandomInRange(max uint64) (uint64, error) { - if max == 0 { - return 0, nil - } - bytes := make([]byte, 8) - _, err := rand.Read(bytes) - if err != nil { - return 0, err - } - randomVal := new(big.Int).SetBytes(bytes).Uint64() - return randomVal % max, nil -} - -func generateRandomAddress() (string, error) { - bytes := make([]byte, 20) - _, err := rand.Read(bytes) - if err != nil { - return "", err - } - return common.BytesToAddress(bytes).Hex(), nil -} - // validateArgs validates that the provided arguments are compatible with the function signature func (b *ABICallDataBuilder) validateArgs(args []interface{}) error { expectedCount := len(b.parsedFunction.Inputs) diff --git a/utils/placeholders.go b/utils/placeholders.go new file mode 100644 index 0000000..7e96547 --- /dev/null +++ b/utils/placeholders.go @@ -0,0 +1,144 @@ +package utils + +import ( + "crypto/rand" + "fmt" + "math/big" + "regexp" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" +) + +// PlaceholderFn resolves a `{name}` or `{name:arg}` placeholder; arg is empty for `{name}`. +type PlaceholderFn func(arg string) (string, error) + +// Placeholders maps placeholder names to their resolvers. +type Placeholders map[string]PlaceholderFn + +var placeholderRegex = regexp.MustCompile(`\{([a-z0-9_]+)(?::([^{}]*))?\}`) + +// Resolve substitutes placeholders innermost-first, so arguments can nest +// (`{create2:{factory_address}:0x..:{txid}}`). Unknown names are left untouched. +func (p Placeholders) Resolve(s string) (string, error) { + for { + var resolveErr error + changed := false + out := placeholderRegex.ReplaceAllStringFunc(s, func(match string) string { + sub := placeholderRegex.FindStringSubmatch(match) + fn, ok := p[sub[1]] + if !ok || resolveErr != nil { + return match + } + value, err := fn(sub[2]) + if err != nil { + resolveErr = fmt.Errorf("placeholder %s: %w", match, err) + return match + } + changed = true + return value + }) + if resolveErr != nil { + return "", resolveErr + } + if !changed { + return out, nil + } + s = out + } +} + +// TxPlaceholders returns the placeholders every scenario supports for transaction txIdx: +// {txid}, {random}, {random:N} (0 <= x < N), {randomaddr} and {create2:factory:initcodehash:salt}. +func TxPlaceholders(txIdx uint64) Placeholders { + return Placeholders{ + "txid": func(string) (string, error) { + return strconv.FormatUint(txIdx, 10), nil + }, + "random": func(arg string) (string, error) { + if arg == "" { + return generateRandomUint256() + } + maxVal, err := strconv.ParseUint(arg, 10, 64) + if err != nil { + return "", fmt.Errorf("invalid random range: %s", arg) + } + value, err := generateRandomInRange(maxVal) + if err != nil { + return "", err + } + return strconv.FormatUint(value, 10), nil + }, + "randomaddr": func(string) (string, error) { + return generateRandomAddress() + }, + "create2": func(arg string) (string, error) { + addr, err := create2Address(arg) + if err != nil { + return "", err + } + return addr.Hex(), nil + }, + } +} + +// create2Address derives a CREATE2 address from "factory:initcodehash:salt"; salt is a +// decimal or 0x-prefixed uint256, matching the salt encoding of factorydeploytx. +func create2Address(arg string) (common.Address, error) { + parts := strings.Split(arg, ":") + if len(parts) != 3 { + return common.Address{}, fmt.Errorf("expected factory:initcodehash:salt") + } + if !common.IsHexAddress(parts[0]) { + return common.Address{}, fmt.Errorf("invalid factory address %q", parts[0]) + } + initCodeHash, err := hexToHash(parts[1]) + if err != nil { + return common.Address{}, err + } + salt, ok := new(big.Int).SetString(parts[2], 0) + if !ok || salt.Sign() < 0 || salt.BitLen() > 256 { + return common.Address{}, fmt.Errorf("invalid salt %q", parts[2]) + } + var saltBytes [32]byte + salt.FillBytes(saltBytes[:]) + return crypto.CreateAddress2(common.HexToAddress(parts[0]), saltBytes, initCodeHash[:]), nil +} + +func hexToHash(s string) (common.Hash, error) { + b, err := hexutil.Decode("0x" + strings.TrimPrefix(s, "0x")) + if err != nil || len(b) != common.HashLength { + return common.Hash{}, fmt.Errorf("invalid init code hash %q", s) + } + return common.BytesToHash(b), nil +} + +func generateRandomUint256() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return new(big.Int).SetBytes(bytes).String(), nil +} + +func generateRandomInRange(max uint64) (uint64, error) { + if max == 0 { + return 0, nil + } + bytes := make([]byte, 8) + if _, err := rand.Read(bytes); err != nil { + return 0, err + } + return new(big.Int).SetBytes(bytes).Uint64() % max, nil +} + +func generateRandomAddress() (string, error) { + bytes := make([]byte, 20) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return common.BytesToAddress(bytes).Hex(), nil +} diff --git a/utils/placeholders_test.go b/utils/placeholders_test.go new file mode 100644 index 0000000..eb33681 --- /dev/null +++ b/utils/placeholders_test.go @@ -0,0 +1,67 @@ +package utils + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +func TestPlaceholdersResolve(t *testing.T) { + factory := common.HexToAddress("0x3333333333333333333333333333333333333333") + initCodeHash := crypto.Keccak256Hash([]byte{0x60, 0x00, 0x60, 0x00, 0xf3}) + var salt [32]byte + salt[31] = 7 + create2 := crypto.CreateAddress2(factory, salt, initCodeHash[:]).Hex() + + placeholders := TxPlaceholders(7) + placeholders["factory_address"] = func(string) (string, error) { return factory.Hex(), nil } + + tests := []struct { + name, in, want string + }{ + {"txid", "tx-{txid}", "tx-7"}, + {"nested create2", "{create2:{factory_address}:" + initCodeHash.Hex() + ":{txid}}", create2}, + {"hex salt without 0x hash prefix", "{create2:" + factory.Hex() + ":" + initCodeHash.Hex()[2:] + ":0x7}", create2}, + {"unknown placeholder kept", "{contract:token} {txid}", "{contract:token} 7"}, + {"json object kept", `[{"a":1},"{txid}"]`, `[{"a":1},"7"]`}, + {"geas block kept", "#define %m() = { push 1 }", "#define %m() = { push 1 }"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := placeholders.Resolve(tt.in) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestPlaceholdersRandom(t *testing.T) { + got, err := TxPlaceholders(0).Resolve("{random:1} {randomaddr} {random}") + if err != nil { + t.Fatal(err) + } + fields := strings.Fields(got) + if fields[0] != "0" || !common.IsHexAddress(fields[1]) || strings.Contains(fields[2], "{") { + t.Fatalf("unexpected resolution %q", got) + } +} + +func TestPlaceholdersErrors(t *testing.T) { + for _, in := range []string{ + "{random:abc}", + "{create2:0x33:0x00:1}", + "{create2:0x3333333333333333333333333333333333333333:0x1234:1}", + "{create2:0x3333333333333333333333333333333333333333:0x1234}", + "{create2:0x3333333333333333333333333333333333333333:" + common.Hash{}.Hex() + ":-1}", + } { + if _, err := TxPlaceholders(0).Resolve(in); err == nil { + t.Errorf("%s: expected error", in) + } + } +}