diff --git a/scenarios/calltx-fuzz/bytecode_generator.go b/scenarios/calltx-fuzz/bytecode_generator.go new file mode 100644 index 0000000..a902193 --- /dev/null +++ b/scenarios/calltx-fuzz/bytecode_generator.go @@ -0,0 +1,1091 @@ +package calltxfuzz + +import ( + "encoding/binary" + "fmt" + "sort" + + "github.com/ethereum/go-ethereum/common" + + evmfuzz "github.com/ethpandaops/spamoor/scenarios/evm-fuzz" +) + +// CallFuzzGenerator generates runtime bytecode for deployed contracts +// with delegation-focused patterns including cross-contract calls, +// storage/transient storage patterns, and identity checks. +// When poolAddresses is non-empty, templates embed actual pool contract +// addresses for cross-contract calls. Otherwise, falls back to +// ADDRESS/CALLER/ORIGIN as call targets. +type CallFuzzGenerator struct { + rng *evmfuzz.DeterministicRNG + transformer *evmfuzz.InputTransformer + stackSize int + bytecode []byte + jumpTargets []int + jumpPlaceholders []int + maxGas uint64 + currentGas uint64 + maxSize int + opcodeCount int + maxOpcodeCount int + opcodeInfos map[uint16]*evmfuzz.OpcodeInfo + validOpcodes []*evmfuzz.OpcodeInfo + invalidOpcodes []byte + stackBuilders []*evmfuzz.OpcodeInfo + txID uint64 + baseSeed string + poolAddresses []common.Address +} + +// NewCallFuzzGenerator creates a new generator for runtime bytecode +// with delegation-focused opcode weights. +func NewCallFuzzGenerator( + txID uint64, + baseSeed string, + maxSize int, + maxGas uint64, + poolAddresses []common.Address, +) *CallFuzzGenerator { + rng := evmfuzz.NewDeterministicRNGWithSeed(txID, baseSeed) + + g := &CallFuzzGenerator{ + rng: rng, + transformer: evmfuzz.NewInputTransformer(rng), + stackSize: 0, + bytecode: make([]byte, 0, maxSize), + jumpTargets: make([]int, 0, 16), + jumpPlaceholders: make([]int, 0, 16), + maxGas: maxGas, + currentGas: 0, + maxSize: maxSize, + opcodeCount: 0, + maxOpcodeCount: maxSize * 10, + opcodeInfos: make(map[uint16]*evmfuzz.OpcodeInfo, 128), + txID: txID, + baseSeed: baseSeed, + poolAddresses: poolAddresses, + } + + g.initializeOpcodes() + g.buildValidOpcodeList() + g.buildInvalidOpcodeList() + g.buildStackBuilderList() + + return g +} + +// initializeOpcodes sets up opcode definitions with delegation-focused weights +func (g *CallFuzzGenerator) initializeOpcodes() { + opcodes := getCalltxFuzzOpcodeDefinitions() + + // Add DUP1-DUP16 and SWAP1-SWAP16 + opcodes = append(opcodes, evmfuzz.GetDupOpcodeDefinitions()...) + opcodes = append(opcodes, evmfuzz.GetSwapOpcodeDefinitions()...) + + // Add generator-specific opcodes + generatorOpcodes := []*evmfuzz.OpcodeInfo{ + // JUMP/JUMPI need generator for target tracking + op("JUMP", 0x56, 0, 0, 8, g.generateJump, 1.0), + op("JUMPI", 0x57, 1, 0, 10, g.generateJumpi, 1.0), + } + opcodes = append(opcodes, generatorOpcodes...) + + // Add PUSH1-PUSH32 + for i := 1; i <= 32; i++ { + pushOpcode := uint16(0x5f + i) + pushSize := i + opcodes = append(opcodes, op( + fmt.Sprintf("PUSH%d", i), pushOpcode, 0, 1, 3, + g.makePushTemplate(pushOpcode, pushSize), 1.0, + )) + } + + // Store in map + for _, op := range opcodes { + // Skip opcodes with nil templates (precompile placeholders) + if op.Template == nil { + continue + } + g.opcodeInfos[op.Opcode] = op + } +} + +// buildValidOpcodeList builds a sorted list of usable opcodes +func (g *CallFuzzGenerator) buildValidOpcodeList() { + g.validOpcodes = g.validOpcodes[:0] + for _, op := range g.opcodeInfos { + g.validOpcodes = append(g.validOpcodes, op) + } + sort.Slice(g.validOpcodes, func(i, j int) bool { + return g.validOpcodes[i].Opcode < g.validOpcodes[j].Opcode + }) +} + +// buildInvalidOpcodeList finds opcodes not in the valid set +func (g *CallFuzzGenerator) buildInvalidOpcodeList() { + validSet := make(map[byte]bool, len(g.opcodeInfos)) + for _, op := range g.opcodeInfos { + if op.Opcode <= 0xFF { + validSet[byte(op.Opcode)] = true + } + } + g.invalidOpcodes = g.invalidOpcodes[:0] + for opcode := 0; opcode <= 0xFF; opcode++ { + if !validSet[byte(opcode)] { + g.invalidOpcodes = append(g.invalidOpcodes, byte(opcode)) + } + } +} + +// buildStackBuilderList caches opcodes that push one item without consuming any +func (g *CallFuzzGenerator) buildStackBuilderList() { + g.stackBuilders = g.stackBuilders[:0] + for _, op := range g.opcodeInfos { + if op.StackInput != 0 || op.StackOutput != 1 { + continue + } + if op.Opcode >= 0x100 { // skip precompiles + continue + } + if op.Opcode >= 0x60 && op.Opcode <= 0x7f { // skip PUSH1-PUSH32 + continue + } + g.stackBuilders = append(g.stackBuilders, op) + } + sort.Slice(g.stackBuilders, func(i, j int) bool { + return g.stackBuilders[i].Opcode < g.stackBuilders[j].Opcode + }) +} + +// Generate produces runtime bytecode (not init code). +// The output is meant to be deployed via an init code wrapper and +// then called through Type 2/4/6 transactions. +func (g *CallFuzzGenerator) Generate() []byte { + g.bytecode = g.bytecode[:0] + g.stackSize = 0 + g.jumpTargets = g.jumpTargets[:0] + g.jumpPlaceholders = g.jumpPlaceholders[:0] + g.currentGas = 0 + g.opcodeCount = 0 + + // Push seed and txID for deterministic initial stack state + g.pushSeedAndTxID() + g.opcodeCount += 2 + + // 95% chance: emit an initial gas bailout that returns calldata. + // When a contract is entered with low gas (e.g. deep in a call chain), + // this immediately copies calldata to memory and RETURNs it, + // preventing an out-of-gas revert. + if g.rng.Float64() < 0.95 { + g.emitCalldataBailout() + } + + // Main generation loop + instrSinceBailout := 0 + for len(g.bytecode) < g.maxSize-32 && + g.currentGas < g.maxGas-1000 && + g.opcodeCount < g.maxOpcodeCount-10 { + + // 80% chance to inject a gas bailout check every ~8-15 instructions. + // If GAS < 15000, store top-of-stack to memory and RETURN it. + // This prevents deep call chains from running out of gas. + instrSinceBailout++ + if instrSinceBailout > 8 && g.rng.Float64() < 0.80 { + g.emitGasBailout() + instrSinceBailout = 0 + } + + // 20% chance to place JUMPDESTs when we have few targets + if len(g.jumpTargets) < 10 && g.rng.Float64() < 0.2 { + pc := len(g.bytecode) + g.bytecode = append(g.bytecode, 0x5b) // JUMPDEST + g.jumpTargets = append(g.jumpTargets, pc) + g.currentGas++ + g.opcodeCount++ + // Reset stack assumption — jumps may arrive with any stack depth. + // Conservative reset ensures addStackItems fills as needed. + g.stackSize = 0 + continue + } + + // 40% chance for delegation-specific template + if g.rng.Float64() < 0.40 { + if g.generateDelegationTemplate() { + continue + } + } + + if !g.generateNextInstruction() { + break + } + } + + // Final gas bailout before STOP + g.emitGasBailout() + + // Terminate safely + if len(g.bytecode) < g.maxSize-1 && g.opcodeCount < g.maxOpcodeCount { + g.bytecode = append(g.bytecode, 0x00) // STOP + g.opcodeCount++ + } + + g.fixJumpTargets() + return g.bytecode +} + +// generateDelegationTemplate injects a delegation-specific bytecode pattern. +// When poolAddresses is non-empty, templates embed actual pool contract +// addresses via PUSH20 for cross-contract calls. Otherwise, falls back +// to ADDRESS/CALLER/ORIGIN as call targets. +func (g *CallFuzzGenerator) generateDelegationTemplate() bool { + // Weighted distribution: CALL and DELEGATECALL are preferred over + // STATICCALL to reduce write-protection errors. + choice := g.rng.Intn(12) + switch choice { + case 0, 1: + return g.generatePoolCall() + case 2, 3: + return g.generatePoolDelegateCall() + case 4: + return g.generatePoolStaticCall() + case 5: + return g.generateCrossCallSequence() + case 6: + return g.generateStorageCallStorage() + case 7: + return g.generateTransientCallPattern() + case 8: + return g.generateCallWithReturnData() + case 9: + return g.generateDelegateCallChain() + case 10: + return g.generateStoragePattern() + case 11: + return g.generateIdentityCheck() + } + return false +} + +// randomSubcallGas returns PUSH3 bytecode for a random gas limit (200000-800000). +// Minimum 200k ensures enough gas for the reentrancy sentry (2300 gas) +// plus the callee's initial gas bailout check and some useful execution. +func (g *CallFuzzGenerator) randomSubcallGas() []byte { + gas := uint32(200000 + g.rng.Intn(600000)) + return []byte{0x62, byte(gas >> 16), byte(gas >> 8), byte(gas)} +} + +// pushCallTarget emits bytecode to push a call target address onto the stack. +// When pool addresses are available, embeds a random peer address via PUSH20. +// Otherwise, falls back to ADDRESS (0x30), CALLER (0x33), or ORIGIN (0x32). +func (g *CallFuzzGenerator) pushCallTarget() []byte { + if len(g.poolAddresses) > 0 { + addr := g.poolAddresses[g.rng.Intn(len(g.poolAddresses))] + bc := make([]byte, 21) + bc[0] = 0x73 // PUSH20 + copy(bc[1:], addr.Bytes()) + return bc + } + // Fallback: use dynamic address opcode + targets := []byte{0x30, 0x33, 0x32} // ADDRESS, CALLER, ORIGIN + return []byte{targets[g.rng.Intn(len(targets))]} +} + +// pushDistinctTargets returns bytecode snippets for n distinct call targets. +// Uses Fisher-Yates shuffle on pool addresses when available. +func (g *CallFuzzGenerator) pushDistinctTargets(n int) [][]byte { + if len(g.poolAddresses) == 0 || n <= 0 { + result := make([][]byte, n) + for i := 0; i < n; i++ { + result[i] = g.pushCallTarget() + } + return result + } + + // Fisher-Yates partial shuffle for n distinct addresses + count := n + if count > len(g.poolAddresses) { + count = len(g.poolAddresses) + } + + indices := make([]int, len(g.poolAddresses)) + for i := range indices { + indices[i] = i + } + for i := 0; i < count; i++ { + j := i + g.rng.Intn(len(indices)-i) + indices[i], indices[j] = indices[j], indices[i] + } + + result := make([][]byte, n) + for i := 0; i < n; i++ { + idx := indices[i%count] + addr := g.poolAddresses[idx] + bc := make([]byte, 21) + bc[0] = 0x73 // PUSH20 + copy(bc[1:], addr.Bytes()) + result[i] = bc + } + return result +} + +// generatePoolCall emits CALL to a pool contract address. +// Cross-contract call with limited gas to prevent deep recursion. +// Stack: +1 (CALL result) +func (g *CallFuzzGenerator) generatePoolCall() bool { + gasBytes := g.randomSubcallGas() + targetBytes := g.pushCallTarget() + + bc := []byte{ + 0x60, 0x20, // PUSH1 retLength=32 + 0x5f, // PUSH0 retOffset=0 + 0x5f, // PUSH0 argsLength=0 + 0x5f, // PUSH0 argsOffset=0 + 0x5f, // PUSH0 value=0 + } + bc = append(bc, targetBytes...) // PUSH20 or ADDRESS/CALLER/ORIGIN + bc = append(bc, gasBytes...) // PUSH2 gas + bc = append(bc, 0xf1) // CALL + + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return false + } + g.bytecode = append(g.bytecode, bc...) + g.stackSize++ + g.currentGas += 100 + g.opcodeCount += countOpcodesInBytecode(bc) + return true +} + +// generatePoolDelegateCall emits DELEGATECALL to a pool contract. +// Tests storage delegation where the callee executes in our storage context. +// Stack: +1 (DELEGATECALL result) +func (g *CallFuzzGenerator) generatePoolDelegateCall() bool { + gasBytes := g.randomSubcallGas() + targetBytes := g.pushCallTarget() + + // DELEGATECALL: 6 args (gas, addr, argsOff, argsLen, retOff, retLen) + bc := []byte{ + 0x60, 0x20, // PUSH1 retLength=32 + 0x5f, // PUSH0 retOffset=0 + 0x5f, // PUSH0 argsLength=0 + 0x5f, // PUSH0 argsOffset=0 + } + bc = append(bc, targetBytes...) // PUSH20 or ADDRESS/CALLER/ORIGIN + bc = append(bc, gasBytes...) // PUSH2 gas + bc = append(bc, 0xf4) // DELEGATECALL + + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return false + } + g.bytecode = append(g.bytecode, bc...) + g.stackSize++ + g.currentGas += 100 + g.opcodeCount += countOpcodesInBytecode(bc) + return true +} + +// generatePoolStaticCall emits STATICCALL to a pool contract. +// Tests read-only cross-contract calls in delegation context. +// Stack: +1 (STATICCALL result) +func (g *CallFuzzGenerator) generatePoolStaticCall() bool { + gasBytes := g.randomSubcallGas() + targetBytes := g.pushCallTarget() + + // STATICCALL: 6 args (gas, addr, argsOff, argsLen, retOff, retLen) + bc := []byte{ + 0x60, 0x20, // PUSH1 retLength=32 + 0x5f, // PUSH0 retOffset=0 + 0x5f, // PUSH0 argsLength=0 + 0x5f, // PUSH0 argsOffset=0 + } + bc = append(bc, targetBytes...) // PUSH20 or ADDRESS/CALLER/ORIGIN + bc = append(bc, gasBytes...) // PUSH2 gas + bc = append(bc, 0xfa) // STATICCALL + + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return false + } + g.bytecode = append(g.bytecode, bc...) + g.stackSize++ + g.currentGas += 100 + g.opcodeCount += countOpcodesInBytecode(bc) + return true +} + +// generateCrossCallSequence emits 2-3 calls to distinct pool contracts +// in sequence. Each call targets a different contract using CALL, +// STATICCALL, or DELEGATECALL. No self-calls, breaking recursion loops. +// Stack: +numCalls (one result per call) +func (g *CallFuzzGenerator) generateCrossCallSequence() bool { + numCalls := 2 + g.rng.Intn(2) // 2 or 3 + targets := g.pushDistinctTargets(numCalls) + + // Call opcodes: prefer CALL/DELEGATECALL over STATICCALL to reduce + // write-protection errors (callees often execute SSTORE/TSTORE/LOG). + callOps := []byte{0xf1, 0xf4, 0xf1, 0xf4, 0xfa} + + var bc []byte + for i := 0; i < numCalls; i++ { + callOp := callOps[g.rng.Intn(len(callOps))] + gasBytes := g.randomSubcallGas() + + if callOp == 0xf1 { // CALL (7 args) + bc = append(bc, + 0x5f, // PUSH0 retLength + 0x5f, // PUSH0 retOffset + 0x5f, // PUSH0 argsLength + 0x5f, // PUSH0 argsOffset + 0x5f, // PUSH0 value + ) + bc = append(bc, targets[i]...) + bc = append(bc, gasBytes...) + bc = append(bc, callOp) + } else { // DELEGATECALL/STATICCALL (6 args) + bc = append(bc, + 0x5f, // PUSH0 retLength + 0x5f, // PUSH0 retOffset + 0x5f, // PUSH0 argsLength + 0x5f, // PUSH0 argsOffset + ) + bc = append(bc, targets[i]...) + bc = append(bc, gasBytes...) + bc = append(bc, callOp) + } + } + + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return false + } + g.bytecode = append(g.bytecode, bc...) + g.stackSize += numCalls + g.currentGas += uint64(numCalls) * 100 + g.opcodeCount += countOpcodesInBytecode(bc) + return true +} + +// generateStorageCallStorage emits SSTORE, CALL to a pool contract, +// then SLOAD with the same key. Tests whether cross-contract calls +// affect storage in the calling contract's context. +// Stack: +1 (SLOAD result) +func (g *CallFuzzGenerator) generateStorageCallStorage() bool { + key := byte(g.rng.Intn(32)) + val := byte(g.rng.Intn(256)) + gasBytes := g.randomSubcallGas() + targetBytes := g.pushCallTarget() + + bc := []byte{ + // SSTORE: store value at key + 0x60, val, // PUSH1 value + 0x60, key, // PUSH1 key + 0x55, // SSTORE (consumes 2) + // CALL to pool contract + 0x5f, // PUSH0 retLength + 0x5f, // PUSH0 retOffset + 0x5f, // PUSH0 argsLength + 0x5f, // PUSH0 argsOffset + 0x5f, // PUSH0 value + } + bc = append(bc, targetBytes...) // PUSH20 + bc = append(bc, gasBytes...) // PUSH2 gas + bc = append(bc, + 0xf1, // CALL (consumes 7, pushes 1) + 0x50, // POP (discard call result) + 0x60, key, // PUSH1 same key + 0x54, // SLOAD (consumes 1, pushes 1) + ) + + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return false + } + g.bytecode = append(g.bytecode, bc...) + g.stackSize++ // net: +1 + g.currentGas += 306 + g.opcodeCount += countOpcodesInBytecode(bc) + return true +} + +// generateTransientCallPattern emits TSTORE, CALL to a pool contract, +// then TLOAD with the same key. Tests transient storage isolation +// across cross-contract calls within the same transaction. +// Stack: +1 (TLOAD result) +func (g *CallFuzzGenerator) generateTransientCallPattern() bool { + key := byte(g.rng.Intn(32)) + val := byte(g.rng.Intn(256)) + gasBytes := g.randomSubcallGas() + targetBytes := g.pushCallTarget() + + bc := []byte{ + // TSTORE: store value at key + 0x60, val, // PUSH1 value + 0x60, key, // PUSH1 key + 0x5d, // TSTORE (consumes 2) + // CALL to pool contract + 0x5f, // PUSH0 retLength + 0x5f, // PUSH0 retOffset + 0x5f, // PUSH0 argsLength + 0x5f, // PUSH0 argsOffset + 0x5f, // PUSH0 value + } + bc = append(bc, targetBytes...) // PUSH20 + bc = append(bc, gasBytes...) // PUSH2 gas + bc = append(bc, + 0xf1, // CALL (consumes 7, pushes 1) + 0x50, // POP (discard call result) + 0x60, key, // PUSH1 same key + 0x5c, // TLOAD (consumes 1, pushes 1) + ) + + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return false + } + g.bytecode = append(g.bytecode, bc...) + g.stackSize++ // net: +1 + g.currentGas += 306 + g.opcodeCount += countOpcodesInBytecode(bc) + return true +} + +// generateCallWithReturnData emits CALL to a pool contract, then +// copies and loads the return data. Tests RETURNDATASIZE/RETURNDATACOPY +// across cross-contract boundaries. +// Stack: +1 (MLOAD result from return data) +func (g *CallFuzzGenerator) generateCallWithReturnData() bool { + gasBytes := g.randomSubcallGas() + targetBytes := g.pushCallTarget() + + bc := []byte{ + // CALL to pool contract with return buffer + 0x60, 0x20, // PUSH1 retLength=32 + 0x5f, // PUSH0 retOffset=0 + 0x5f, // PUSH0 argsLength=0 + 0x5f, // PUSH0 argsOffset=0 + 0x5f, // PUSH0 value=0 + } + bc = append(bc, targetBytes...) // PUSH20 + bc = append(bc, gasBytes...) // PUSH2 gas + bc = append(bc, + 0xf1, // CALL (consumes 7, pushes 1) + 0x50, // POP (discard success flag) + // Copy return data to memory + 0x3d, // RETURNDATASIZE (push size) + 0x5f, // PUSH0 offset=0 + 0x5f, // PUSH0 destOffset=0 + 0x3e, // RETURNDATACOPY (consumes 3) + // Load from memory + 0x5f, // PUSH0 offset=0 + 0x51, // MLOAD (consumes 1, pushes 1) + ) + + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return false + } + g.bytecode = append(g.bytecode, bc...) + g.stackSize++ // net: +1 + g.currentGas += 112 + g.opcodeCount += countOpcodesInBytecode(bc) + return true +} + +// generateDelegateCallChain emits DELEGATECALL or STATICCALL to a +// pool contract. Tests delegation chains where the callee executes +// in the caller's storage context. +// Stack: +1 (DELEGATECALL/STATICCALL result) +func (g *CallFuzzGenerator) generateDelegateCallChain() bool { + gasBytes := g.randomSubcallGas() + targetBytes := g.pushCallTarget() + + // Strongly prefer DELEGATECALL over STATICCALL to reduce + // write-protection errors (callees execute SSTORE/TSTORE/LOG). + useDelegatecall := g.rng.Float64() < 0.9 + + // Both use 6 args: gas, addr, argsOff, argsLen, retOff, retLen + bc := []byte{ + 0x60, 0x20, // PUSH1 retLength=32 + 0x5f, // PUSH0 retOffset=0 + 0x5f, // PUSH0 argsLength=0 + 0x5f, // PUSH0 argsOffset=0 + } + bc = append(bc, targetBytes...) // PUSH20 + bc = append(bc, gasBytes...) // PUSH2 gas + if useDelegatecall { + bc = append(bc, 0xf4) // DELEGATECALL + } else { + bc = append(bc, 0xfa) // STATICCALL + } + + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return false + } + g.bytecode = append(g.bytecode, bc...) + g.stackSize++ + g.currentGas += 100 + g.opcodeCount += countOpcodesInBytecode(bc) + return true +} + +// generateStoragePattern emits SSTORE key, SLOAD key +// using deterministic keys for cross-delegation verification. +// Stack: +1 (SLOAD result) +func (g *CallFuzzGenerator) generateStoragePattern() bool { + key := byte(g.rng.Intn(32)) + val := byte(g.rng.Intn(256)) + bc := []byte{ + 0x60, val, // PUSH1 value + 0x60, key, // PUSH1 key + 0x55, // SSTORE + 0x60, key, // PUSH1 key + 0x54, // SLOAD + } + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return false + } + g.bytecode = append(g.bytecode, bc...) + g.stackSize++ // net: 2-2 + 1-1+1 = +1 + g.currentGas += 206 + g.opcodeCount += 5 + return true +} + +// generateIdentityCheck emits ADDRESS, CALLER, EQ for delegation context +// inspection. In delegated calls, ADDRESS != CALLER reveals the delegation. +// Stack: +1 (EQ result) +func (g *CallFuzzGenerator) generateIdentityCheck() bool { + bc := []byte{ + 0x30, // ADDRESS + 0x33, // CALLER + 0x14, // EQ + } + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return false + } + g.bytecode = append(g.bytecode, bc...) + g.stackSize++ // net: +2 -2 +1 = +1 + g.currentGas += 7 + g.opcodeCount += 3 + return true +} + +// emitCalldataBailout injects a gas check at the start of contract execution: +// if GAS < 15000, copy the full calldata to memory and RETURN it. +// This is placed near the bytecode start so contracts entered with very +// little gas (e.g. deep in a call chain) immediately return calldata +// rather than reverting with out-of-gas. +// +// Bytecode emitted (17 bytes): +// +// GAS ; push remaining gas +// PUSH2 0x3a98 ; push 15000 +// LT ; 15000 < gas → 1 when gas is sufficient +// PUSH2 ; jump target past RETURN +// JUMPI ; skip if gas >= 15000 +// --- bailout path --- +// CALLDATASIZE ; push calldata length +// PUSH0 ; source offset (0) +// PUSH0 ; dest offset (0) +// CALLDATACOPY ; copy calldata to memory[0..] +// CALLDATASIZE ; push calldata length again +// PUSH0 ; memory offset (0) +// RETURN ; return calldata +// JUMPDEST ; landing +func (g *CallFuzzGenerator) emitCalldataBailout() { + skipPC := len(g.bytecode) + 16 // offset of JUMPDEST after RETURN + + bc := []byte{ + 0x5a, // GAS + 0x61, 0x3a, 0x98, // PUSH2 15000 + 0x10, // LT (15000 < gas → 1 when gas high) + 0x61, byte(skipPC >> 8), byte(skipPC), // PUSH2 + 0x57, // JUMPI (jump if gas >= 15000) + 0x36, // CALLDATASIZE + 0x5f, // PUSH0 (srcOffset=0) + 0x5f, // PUSH0 (destOffset=0) + 0x37, // CALLDATACOPY + 0x36, // CALLDATASIZE + 0x5f, // PUSH0 (memOffset=0) + 0xf3, // RETURN + 0x5b, // JUMPDEST + } + + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return + } + + g.bytecode = append(g.bytecode, bc...) + // Stack effect: GAS+PUSH2+LT+ISZERO+PUSH2+JUMPI all consumed. + // On the non-bailout path, stack is unchanged. + g.currentGas += 20 + g.opcodeCount += countOpcodesInBytecode(bc) +} + +// emitGasBailout injects a gas check: if GAS < 15000, store the current +// top-of-stack value to memory and RETURN it. This prevents execution from +// running out of gas in deep call chains by gracefully returning early. +// +// Bytecode emitted (16 bytes): +// +// GAS ; push remaining gas +// PUSH2 0x3a98 ; push 15000 +// LT ; 15000 < gas → 1 when gas is sufficient +// PUSH2 ; jump target past RETURN +// JUMPI ; skip if gas >= 15000 +// --- bailout path --- +// PUSH0 ; memory offset +// MSTORE ; store TOS at memory[0] (consumes 1 stack item) +// PUSH1 0x20 ; return 32 bytes +// PUSH0 ; from memory offset 0 +// RETURN +// JUMPDEST ; landing +func (g *CallFuzzGenerator) emitGasBailout() { + // Need at least 1 stack item for MSTORE; ensure it. + if g.stackSize < 1 { + g.bytecode = append(g.bytecode, 0x5f) // PUSH0 + g.stackSize++ + g.currentGas += 2 + g.opcodeCount++ + } + + skipPC := len(g.bytecode) + 15 // offset of JUMPDEST after RETURN + + bc := []byte{ + 0x5a, // GAS + 0x61, 0x3a, 0x98, // PUSH2 15000 + 0x10, // LT (15000 < gas → 1 when gas high) + 0x61, byte(skipPC >> 8), byte(skipPC), // PUSH2 + 0x57, // JUMPI (jump if gas >= 15000) + 0x5f, // PUSH0 (memory offset) + 0x52, // MSTORE (store TOS to memory[0]) + 0x60, 0x20, // PUSH1 32 + 0x5f, // PUSH0 + 0xf3, // RETURN + 0x5b, // JUMPDEST + } + + if len(g.bytecode)+len(bc) > g.maxSize-32 { + return + } + + g.bytecode = append(g.bytecode, bc...) + // Stack effect: GAS+PUSH2+LT+ISZERO+PUSH2+JUMPI = net 0 (all consumed by JUMPI) + // On the non-bailout path, stack is unchanged. + // On the bailout path, MSTORE consumes 1 item (memory offset was pushed by us). + g.currentGas += 20 + g.opcodeCount += countOpcodesInBytecode(bc) +} + +// --- Stack management --- + +func (g *CallFuzzGenerator) addStackItems(n int) bool { + for i := 0; i < n; i++ { + if len(g.bytecode)+2 > g.maxSize-32 || g.opcodeCount+1 > g.maxOpcodeCount-10 { + return false + } + if g.rng.Float64() < 0.7 { + // PUSH with random data + pushSize := g.selectPushSize() + if len(g.bytecode)+1+pushSize > g.maxSize-32 { + if len(g.bytecode)+1 > g.maxSize-32 { + return false + } + g.bytecode = append(g.bytecode, 0x5f) + g.stackSize++ + g.currentGas += 2 + g.opcodeCount++ + continue + } + pushBytes := make([]byte, 1+pushSize) + pushBytes[0] = byte(0x5f + pushSize) + copy(pushBytes[1:], g.rng.Bytes(pushSize)) + g.bytecode = append(g.bytecode, pushBytes...) + g.stackSize++ + g.currentGas += 3 + g.opcodeCount++ + } else { + if len(g.stackBuilders) == 0 { + g.bytecode = append(g.bytecode, 0x5f) + g.stackSize++ + g.currentGas += 2 + g.opcodeCount++ + continue + } + op := g.stackBuilders[g.rng.Intn(len(g.stackBuilders))] + if g.currentGas+op.GasCost > g.maxGas { + g.bytecode = append(g.bytecode, 0x5f) + g.stackSize++ + g.currentGas += 2 + g.opcodeCount++ + continue + } + seq := op.Template() + if len(g.bytecode)+len(seq) > g.maxSize-32 { + g.bytecode = append(g.bytecode, 0x5f) + g.stackSize++ + g.currentGas += 2 + g.opcodeCount++ + continue + } + g.bytecode = append(g.bytecode, seq...) + g.stackSize++ + g.currentGas += op.GasCost + g.opcodeCount++ + } + } + return true +} + +func (g *CallFuzzGenerator) selectPushSize() int { + choice := g.rng.Float64() + switch { + case choice < 0.40: + return 1 + case choice < 0.60: + return 2 + case choice < 0.75: + return 3 + case choice < 0.90: + return 4 + g.rng.Intn(5) + default: + return 9 + g.rng.Intn(24) + } +} + +func (g *CallFuzzGenerator) removeStackItems(n int) bool { + for n > 0 { + if len(g.bytecode)+1 > g.maxSize-32 || g.opcodeCount+1 > g.maxOpcodeCount-10 { + return false + } + if g.currentGas+2 > g.maxGas { + return false + } + g.bytecode = append(g.bytecode, 0x50) // POP + g.stackSize-- + g.currentGas += 2 + g.opcodeCount++ + n-- + } + return true +} + +// --- Instruction generation --- + +func (g *CallFuzzGenerator) generateNextInstruction() bool { + choice := g.rng.Float64() + + // Much lower error rates than evm-fuzz for better valid execution coverage + if choice < 0.0001 { // 0.01% invalid opcode + if len(g.invalidOpcodes) > 0 { + g.bytecode = append(g.bytecode, g.invalidOpcodes[g.rng.Intn(len(g.invalidOpcodes))]) + g.currentGas += 3 + g.opcodeCount++ + return true + } + } + if choice < 0.0004 { // 0.03% random byte + g.bytecode = append(g.bytecode, byte(g.rng.Intn(256))) + g.currentGas += 3 + g.opcodeCount++ + return true + } + + return g.generateValidInstruction() +} + +func (g *CallFuzzGenerator) generateValidInstruction() bool { + var candidates []*evmfuzz.OpcodeInfo + for _, op := range g.validOpcodes { + if g.currentGas+op.GasCost <= g.maxGas { + candidates = append(candidates, op) + } + } + if len(candidates) == 0 { + return false + } + + op := g.selectWeightedOpcode(candidates) + + // Fulfill stack requirements + needed := op.StackInput - g.stackSize + if needed > 0 { + if !g.addStackItems(needed) { + return g.generateFallbackInstruction() + } + } + + // Prevent stack overflow + result := g.stackSize - op.StackInput + op.StackOutput + if result > 1024 { + toRemove := result - 1024 + if !g.removeStackItems(toRemove) { + return g.generateFallbackInstruction() + } + } + + seq := op.Template() + seqOps := countOpcodesInBytecode(seq) + + if len(g.bytecode)+len(seq) > g.maxSize || g.opcodeCount+seqOps > g.maxOpcodeCount { + return g.generateFallbackInstruction() + } + + g.bytecode = append(g.bytecode, seq...) + g.currentGas += op.GasCost + g.opcodeCount += seqOps + + // Update stack + if g.stackSize >= op.StackInput { + g.stackSize -= op.StackInput + } else { + g.stackSize = 0 + } + g.stackSize += op.StackOutput + + return true +} + +func (g *CallFuzzGenerator) generateFallbackInstruction() bool { + var candidates []*evmfuzz.OpcodeInfo + for _, op := range g.validOpcodes { + if g.stackSize >= op.StackInput && + g.stackSize-op.StackInput+op.StackOutput <= 1024 && + g.currentGas+op.GasCost <= g.maxGas { + candidates = append(candidates, op) + } + } + if len(candidates) == 0 { + if g.stackSize < 1020 { + g.bytecode = append(g.bytecode, 0x60, byte(g.rng.Intn(256))) + g.stackSize++ + g.currentGas += 3 + g.opcodeCount++ + return true + } + return false + } + + op := candidates[g.rng.Intn(len(candidates))] + seq := op.Template() + seqOps := countOpcodesInBytecode(seq) + if len(g.bytecode)+len(seq) > g.maxSize || g.opcodeCount+seqOps > g.maxOpcodeCount { + return false + } + g.bytecode = append(g.bytecode, seq...) + g.currentGas += op.GasCost + g.opcodeCount += seqOps + + if g.stackSize >= op.StackInput { + g.stackSize -= op.StackInput + } else { + g.stackSize = 0 + } + g.stackSize += op.StackOutput + + return true +} + +func (g *CallFuzzGenerator) selectWeightedOpcode(candidates []*evmfuzz.OpcodeInfo) *evmfuzz.OpcodeInfo { + totalWeight := 0.0 + for _, op := range candidates { + totalWeight += op.Probability + } + if totalWeight == 0 { + return candidates[g.rng.Intn(len(candidates))] + } + rv := g.rng.Float64() * totalWeight + cw := 0.0 + for _, op := range candidates { + cw += op.Probability + if rv <= cw { + return op + } + } + return candidates[len(candidates)-1] +} + +// --- PUSH, JUMP templates --- + +func (g *CallFuzzGenerator) makePushTemplate(opcode uint16, size int) func() []byte { + return func() []byte { + result := make([]byte, 1+size) + result[0] = byte(opcode) + copy(result[1:], g.rng.Bytes(size)) + return result + } +} + +func (g *CallFuzzGenerator) generateJump() []byte { + g.jumpPlaceholders = append(g.jumpPlaceholders, len(g.bytecode)+1) + return []byte{0x61, 0x00, 0x00, 0x56} // PUSH2 0x0000 JUMP +} + +func (g *CallFuzzGenerator) generateJumpi() []byte { + g.jumpPlaceholders = append(g.jumpPlaceholders, len(g.bytecode)+1) + return []byte{0x61, 0x00, 0x00, 0x57} // PUSH2 0x0000 JUMPI +} + +func (g *CallFuzzGenerator) fixJumpTargets() { + for _, pos := range g.jumpPlaceholders { + var target int + // 2% chance of invalid jump (lower than evm-fuzz's 10%) + if g.rng.Float64() < 0.02 { + target = g.rng.Intn(max(len(g.bytecode), 1)) + } else if len(g.jumpTargets) > 0 { + target = g.jumpTargets[g.rng.Intn(len(g.jumpTargets))] + } else { + target = g.rng.Intn(max(len(g.bytecode), 1)) + } + if pos+1 < len(g.bytecode) { + g.bytecode[pos] = byte(target >> 8) + g.bytecode[pos+1] = byte(target) + } + } +} + +func (g *CallFuzzGenerator) pushSeedAndTxID() { + seedBytes := make([]byte, 32) + if g.baseSeed != "" { + if parsed, err := evmfuzz.ParseHexSeed(g.baseSeed); err == nil { + if len(parsed) >= 32 { + copy(seedBytes, parsed[:32]) + } else { + copy(seedBytes[32-len(parsed):], parsed) + } + } else { + s := []byte(g.baseSeed) + if len(s) >= 32 { + copy(seedBytes, s[:32]) + } else { + copy(seedBytes[32-len(s):], s) + } + } + } + seedPush := make([]byte, 33) + seedPush[0] = 0x7f // PUSH32 + copy(seedPush[1:], seedBytes) + g.bytecode = append(g.bytecode, seedPush...) + g.stackSize++ + g.currentGas += 3 + + txIDBytes := make([]byte, 32) + binary.BigEndian.PutUint64(txIDBytes[24:], g.txID) + txIDPush := make([]byte, 33) + txIDPush[0] = 0x7f // PUSH32 + copy(txIDPush[1:], txIDBytes) + g.bytecode = append(g.bytecode, txIDPush...) + g.stackSize++ + g.currentGas += 3 +} + +// countOpcodesInBytecode counts opcodes properly handling PUSH data bytes +func countOpcodesInBytecode(bytecode []byte) int { + count := 0 + pc := 0 + for pc < len(bytecode) { + op := bytecode[pc] + count++ + if op >= 0x60 && op <= 0x7f { + pc += int(op-0x5f) + 1 + } else { + pc++ + } + } + return count +} diff --git a/scenarios/calltx-fuzz/calldata_generator.go b/scenarios/calltx-fuzz/calldata_generator.go new file mode 100644 index 0000000..a12f925 --- /dev/null +++ b/scenarios/calltx-fuzz/calldata_generator.go @@ -0,0 +1,152 @@ +package calltxfuzz + +import ( + evmfuzz "github.com/ethpandaops/spamoor/scenarios/evm-fuzz" +) + +// CalldataGenerator generates fuzzed calldata for calling deployed contracts. +type CalldataGenerator struct { + rng *evmfuzz.DeterministicRNG + maxSize uint64 +} + +// NewCalldataGenerator creates a new calldata generator. +func NewCalldataGenerator(rng *evmfuzz.DeterministicRNG, maxSize uint64) *CalldataGenerator { + return &CalldataGenerator{ + rng: rng, + maxSize: maxSize, + } +} + +// Generate produces fuzzed calldata with various patterns. +func (g *CalldataGenerator) Generate() []byte { + pattern := g.rng.Intn(100) + + switch { + case pattern < 30: + return g.generateRandomBytes() + case pattern < 55: + return g.generateABILike() + case pattern < 70: + return g.generateEdgeCase() + case pattern < 85: + return g.generateStorageKeyPattern() + default: + return g.generateRepeatedPattern() + } +} + +// generateRandomBytes produces uniform random calldata. +func (g *CalldataGenerator) generateRandomBytes() []byte { + size := g.rng.Intn(int(g.maxSize) + 1) + if size == 0 { + return nil + } + + return g.rng.Bytes(size) +} + +// generateABILike produces calldata that looks like an ABI-encoded call: +// 4-byte function selector + 32-byte-aligned parameters. +func (g *CalldataGenerator) generateABILike() []byte { + // Function selector (4 bytes) + selector := g.rng.Bytes(4) + + // 1-8 parameters, each 32 bytes + paramCount := g.rng.Intn(8) + 1 + size := 4 + paramCount*32 + if uint64(size) > g.maxSize { + paramCount = int(g.maxSize-4) / 32 + if paramCount < 1 { + return selector + } + size = 4 + paramCount*32 + } + + data := make([]byte, size) + copy(data, selector) + + for i := 0; i < paramCount; i++ { + offset := 4 + i*32 + // 50% chance of random data, 50% chance of structured values + if g.rng.Float64() < 0.5 { + copy(data[offset:offset+32], g.rng.Bytes(32)) + } else { + // Structured value: small integer in last 8 bytes + val := g.rng.Uint64() + for j := 0; j < 8; j++ { + data[offset+24+j] = byte(val >> (56 - j*8)) + } + } + } + + return data +} + +// generateEdgeCase produces edge-case calldata sizes. +func (g *CalldataGenerator) generateEdgeCase() []byte { + choice := g.rng.Intn(5) + switch choice { + case 0: // Empty + return nil + case 1: // Single byte + return g.rng.Bytes(1) + case 2: // Exactly 4 bytes (just a selector) + return g.rng.Bytes(4) + case 3: // 32 bytes (one word) + return g.rng.Bytes(32) + default: // Max size + if g.maxSize == 0 { + return nil + } + return g.rng.Bytes(int(g.maxSize)) + } +} + +// generateStorageKeyPattern produces calldata encoding storage keys +// matching the generator's deterministic key patterns (keys 0-31). +func (g *CalldataGenerator) generateStorageKeyPattern() []byte { + // ABI-like: selector + key as uint256 + data := make([]byte, 36) + copy(data[:4], g.rng.Bytes(4)) + + // Storage key in the low byte (matches generateStoragePattern) + data[35] = byte(g.rng.Intn(32)) + + return data +} + +// generateRepeatedPattern produces calldata with repeated byte patterns. +func (g *CalldataGenerator) generateRepeatedPattern() []byte { + size := g.rng.Intn(int(g.maxSize) + 1) + if size == 0 { + return nil + } + + data := make([]byte, size) + choice := g.rng.Intn(3) + + switch choice { + case 0: // Same byte repeated + b := byte(g.rng.Intn(256)) + for i := range data { + data[i] = b + } + case 1: // Alternating pattern + a := byte(g.rng.Intn(256)) + b := byte(g.rng.Intn(256)) + for i := range data { + if i%2 == 0 { + data[i] = a + } else { + data[i] = b + } + } + default: // Incrementing + for i := range data { + data[i] = byte(i % 256) + } + } + + return data +} diff --git a/scenarios/calltx-fuzz/calltx_fuzz.go b/scenarios/calltx-fuzz/calltx_fuzz.go new file mode 100644 index 0000000..89ad49e --- /dev/null +++ b/scenarios/calltx-fuzz/calltx_fuzz.go @@ -0,0 +1,438 @@ +package calltxfuzz + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/core/types" + "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/utils" +) + +// ScenarioOptions configures the calltx-fuzz scenario. +type ScenarioOptions struct { + // Standard options + 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"` + + // Bytecode generation + MaxCodeSize uint64 `yaml:"max_code_size"` + MinCodeSize uint64 `yaml:"min_code_size"` + PayloadSeed string `yaml:"payload_seed"` + TxIdOffset uint64 `yaml:"tx_id_offset"` + + // Mode selection + FuzzType string `yaml:"fuzz_type"` + ContractPoolSize uint64 `yaml:"contract_pool_size"` + DeployRatio float64 `yaml:"deploy_ratio"` + CalldataMaxSize uint64 `yaml:"calldata_max_size"` + + // SetCode-specific + SetCodeFuzzMode string `yaml:"setcode_fuzz_mode"` + MinAuthorizations uint64 `yaml:"min_authorizations"` + MaxAuthorizations uint64 `yaml:"max_authorizations"` + MaxDelegators uint64 `yaml:"max_delegators"` + InvalidAuthRatio float64 `yaml:"invalid_auth_ratio"` + + // Frame-specific (stub) + FrameFuzzMode string `yaml:"frame_fuzz_mode"` + MaxFrames uint64 `yaml:"max_frames"` +} + +// Scenario implements the calltx-fuzz transaction scenario. +type Scenario struct { + options ScenarioOptions + logger *logrus.Entry + walletPool *spamoor.WalletPool + seed string + contractPool *ContractPool + + delegatorSeed []byte + delegatorMu sync.Mutex + delegators []*spamoor.Wallet +} + +// ScenarioName is the registered name for this scenario. +var ScenarioName = "calltx-fuzz" + +// ScenarioDefaultOptions contains sensible defaults for the scenario. +var ScenarioDefaultOptions = ScenarioOptions{ + TotalCount: 0, + Throughput: 50, + MaxPending: 100, + MaxWallets: 0, + Rebroadcast: 30, + BaseFee: 20, + TipFee: 2, + GasLimit: 5000000, + MaxCodeSize: 1024, + MinCodeSize: 200, + PayloadSeed: "", + TxIdOffset: 0, + FuzzType: "setcode", + ContractPoolSize: 50, + DeployRatio: 0.1, + CalldataMaxSize: 256, + SetCodeFuzzMode: "mixed", + MinAuthorizations: 1, + MaxAuthorizations: 5, + MaxDelegators: 100, + InvalidAuthRatio: 0.1, + FrameFuzzMode: "mixed", + MaxFrames: 10, +} + +// ScenarioDescriptor is the registration entry for this scenario. +var ScenarioDescriptor = scenario.Descriptor{ + Name: ScenarioName, + Description: "Deploy fuzzed contracts and call them via " + + "Type 2 (call), Type 4 (setcode delegation), " + + "or Type 6 (frame tx stub) transactions", + DefaultOptions: ScenarioDefaultOptions, + NewScenario: newScenario, +} + +func newScenario(logger logrus.FieldLogger) scenario.Scenario { + return &Scenario{ + options: ScenarioDefaultOptions, + logger: logger.WithField("scenario", ScenarioName), + } +} + +// Flags registers CLI flags for the scenario. +func (s *Scenario) Flags(flags *pflag.FlagSet) error { + flags.Uint64VarP(&s.options.TotalCount, "count", "c", ScenarioDefaultOptions.TotalCount, "Total number of transactions to send (0 = unlimited)") + flags.Uint64VarP(&s.options.Throughput, "throughput", "t", ScenarioDefaultOptions.Throughput, "Number of transactions 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") + flags.Uint64Var(&s.options.Rebroadcast, "rebroadcast", ScenarioDefaultOptions.Rebroadcast, "Enable reliable rebroadcast with exponential backoff") + flags.Float64Var(&s.options.BaseFee, "basefee", ScenarioDefaultOptions.BaseFee, "Max fee per gas in gwei") + flags.Float64Var(&s.options.TipFee, "tipfee", ScenarioDefaultOptions.TipFee, "Max tip per gas 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 per transaction") + flags.StringVar(&s.options.Timeout, "timeout", ScenarioDefaultOptions.Timeout, "Scenario timeout (e.g. '1h', '30m')") + flags.StringVar(&s.options.ClientGroup, "client-group", ScenarioDefaultOptions.ClientGroup, "Client group for sending transactions") + flags.BoolVar(&s.options.LogTxs, "log-txs", ScenarioDefaultOptions.LogTxs, "Log all submitted transactions") + + // Bytecode generation + flags.Uint64Var(&s.options.MaxCodeSize, "max-code-size", ScenarioDefaultOptions.MaxCodeSize, "Maximum runtime bytecode size") + flags.Uint64Var(&s.options.MinCodeSize, "min-code-size", ScenarioDefaultOptions.MinCodeSize, "Minimum runtime bytecode size") + flags.StringVar(&s.options.PayloadSeed, "payload-seed", ScenarioDefaultOptions.PayloadSeed, "Hex seed for reproducible fuzzing (e.g. 0x1234)") + flags.Uint64Var(&s.options.TxIdOffset, "tx-id-offset", ScenarioDefaultOptions.TxIdOffset, "Start from a specific transaction ID") + + // Mode selection + flags.StringVar(&s.options.FuzzType, "fuzz-type", ScenarioDefaultOptions.FuzzType, "Transaction type: 'call', 'setcode', or 'frame'") + flags.Uint64Var(&s.options.ContractPoolSize, "contract-pool-size", ScenarioDefaultOptions.ContractPoolSize, "Number of contracts in the pool") + flags.Float64Var(&s.options.DeployRatio, "deploy-ratio", ScenarioDefaultOptions.DeployRatio, "Fraction of txs that deploy new contracts (0.0-1.0)") + flags.Uint64Var(&s.options.CalldataMaxSize, "calldata-max-size", ScenarioDefaultOptions.CalldataMaxSize, "Maximum calldata size in bytes") + + // SetCode-specific + flags.StringVar(&s.options.SetCodeFuzzMode, "setcode-fuzz-mode", ScenarioDefaultOptions.SetCodeFuzzMode, "SetCode sub-mode: 'delegation', 'execution', 'storage', 'mixed'") + flags.Uint64Var(&s.options.MinAuthorizations, "min-authorizations", ScenarioDefaultOptions.MinAuthorizations, "Minimum authorizations per setcode tx") + flags.Uint64Var(&s.options.MaxAuthorizations, "max-authorizations", ScenarioDefaultOptions.MaxAuthorizations, "Maximum authorizations per setcode tx") + flags.Uint64Var(&s.options.MaxDelegators, "max-delegators", ScenarioDefaultOptions.MaxDelegators, "Maximum delegator wallets to reuse (0 = unlimited)") + flags.Float64Var(&s.options.InvalidAuthRatio, "invalid-auth-ratio", ScenarioDefaultOptions.InvalidAuthRatio, "Fraction of deliberately invalid authorizations") + + // Frame-specific + flags.StringVar(&s.options.FrameFuzzMode, "frame-fuzz-mode", ScenarioDefaultOptions.FrameFuzzMode, "Frame sub-mode: 'approval', 'isolation', 'ordering', 'mixed'") + flags.Uint64Var(&s.options.MaxFrames, "max-frames", ScenarioDefaultOptions.MaxFrames, "Maximum frames per frame tx") + + return nil +} + +// Init validates configuration and sets up the scenario. +func (s *Scenario) Init(options *scenario.Options) error { + s.walletPool = options.WalletPool + + if options.Config != "" { + err := scenario.ParseAndValidateConfig( + &ScenarioDescriptor, options.Config, &s.options, s.logger, + ) + if err != nil { + return err + } + } + + // Configure wallet count + if s.options.MaxWallets > 0 { + s.walletPool.SetWalletCount(s.options.MaxWallets) + } else if s.options.TotalCount > 0 { + maxWallets := s.options.TotalCount / 50 + if maxWallets < 10 { + maxWallets = 10 + } else if maxWallets > 1000 { + maxWallets = 1000 + } + s.walletPool.SetWalletCount(maxWallets) + } else { + walletCount := s.options.Throughput * 10 + if walletCount > 1000 { + walletCount = 1000 + } + s.walletPool.SetWalletCount(walletCount) + } + + // Configure deployer wallet + s.walletPool.SetRefillAmount(utils.EtherToWei(uint256.NewInt(5))) + s.walletPool.SetRefillBalance(utils.EtherToWei(uint256.NewInt(1))) + s.walletPool.AddWellKnownWallet(&spamoor.WellKnownWalletConfig{ + Name: "deployer", + RefillAmount: utils.EtherToWei(uint256.NewInt(50)), + RefillBalance: utils.EtherToWei(uint256.NewInt(10)), + VeryWellKnown: false, + }) + + // Validate options + if s.options.TotalCount == 0 && s.options.Throughput == 0 { + return fmt.Errorf("neither total count nor throughput set, must define at least one") + } + + if s.options.MinCodeSize > s.options.MaxCodeSize { + return fmt.Errorf("min code size cannot be larger than max code size") + } + + if s.options.GasLimit > utils.MaxGasLimitPerTx { + s.logger.Warnf("Gas limit %d exceeds %d and will likely be dropped", + s.options.GasLimit, utils.MaxGasLimitPerTx) + } + + // Validate seed + if s.options.PayloadSeed != "" { + if err := validateSeed(s.options.PayloadSeed); err != nil { + return fmt.Errorf("invalid payload seed: %w", err) + } + } + + // Validate fuzz type + validTypes := map[string]bool{"call": true, "setcode": true, "frame": true} + if !validTypes[s.options.FuzzType] { + return fmt.Errorf("invalid fuzz-type %q, must be 'call', 'setcode', or 'frame'", s.options.FuzzType) + } + + // Validate deploy ratio + if s.options.DeployRatio < 0 || s.options.DeployRatio > 1 { + return fmt.Errorf("deploy-ratio must be between 0.0 and 1.0") + } + + // Initialize delegator seed for setcode mode + s.delegatorSeed = make([]byte, 32) + rand.Read(s.delegatorSeed) + + if s.options.MaxDelegators > 0 { + s.delegators = make([]*spamoor.Wallet, 0, s.options.MaxDelegators) + } + + return nil +} + +// Run executes the calltx-fuzz scenario. +func (s *Scenario) Run(ctx context.Context) error { + s.logger.Infof("starting scenario: %s (fuzz-type=%s)", ScenarioName, s.options.FuzzType) + defer s.logger.Infof("scenario %s finished.", ScenarioName) + + // Generate seed + s.seed = s.options.PayloadSeed + if s.seed == "" { + randomBytes := make([]byte, 32) + rand.Read(randomBytes) + s.seed = hex.EncodeToString(randomBytes) + s.logger.Infof("Generated random seed: 0x%s", s.seed) + } else { + s.logger.Infof("Using provided seed: %s", s.seed) + } + + // Initialize contract pool + s.contractPool = NewContractPool( + s.options.ContractPoolSize, + s.seed, + s.options.MaxCodeSize, + s.options.MinCodeSize, + s.options.GasLimit, + s.options.DeployRatio, + s.logger, + ) + + // Deploy initial pool + err := s.contractPool.InitPool( + ctx, + s.walletPool, + s.options.TxIdOffset, + s.options.BaseFee, s.options.TipFee, + s.options.BaseFeeWei, s.options.TipFeeWei, + s.options.GasLimit, + s.options.ClientGroup, + ) + if err != nil { + return fmt.Errorf("contract pool initialization failed: %w", err) + } + + if s.contractPool.Size() == 0 { + return fmt.Errorf("no contracts deployed in pool, cannot proceed") + } + + // Frame mode warning + if s.options.FuzzType == "frame" { + s.logger.Warn("Frame transaction mode generates tx data only " + + "— signing and submission require client support for EIP-8141") + } + + // Configure max pending + maxPending := s.options.MaxPending + if maxPending == 0 { + maxPending = s.options.Throughput * 10 + if maxPending == 0 { + maxPending = 4000 + } + if maxPending > s.walletPool.GetConfiguredWalletCount()*10 { + maxPending = s.walletPool.GetConfiguredWalletCount() * 10 + } + } + + // Parse timeout + var timeout time.Duration + if s.options.Timeout != "" { + timeout, err = time.ParseDuration(s.options.Timeout) + if err != nil { + return fmt.Errorf("invalid timeout: %w", err) + } + s.logger.Infof("Timeout set to %v", timeout) + } + + s.logger.WithFields(logrus.Fields{ + "total": s.options.TotalCount, + "throughput": s.options.Throughput, + "maxPending": maxPending, + "poolSize": s.contractPool.Size(), + "deployInterval": s.contractPool.GetDeployInterval(), + "codeSize": fmt.Sprintf("%d-%d", s.options.MinCodeSize, s.options.MaxCodeSize), + "fuzzType": s.options.FuzzType, + }).Info("starting transaction fuzzing") + + err = 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 { + return s.processNextTx(ctx, params) + }, + }) + + return err +} + +// processNextTx dispatches to the appropriate mode handler or deploys a new contract. +func (s *Scenario) processNextTx(ctx context.Context, params *scenario.ProcessNextTxParams) error { + logger := s.logger + + // Frame mode: generate data only, no actual transaction + if s.options.FuzzType == "frame" { + ftx := s.generateFrameTx(params.TxIdx) + params.NotifySubmitted() + params.OrderedLogCb(func() { + logger.Debugf("frame tx #%6d generated: %d frames", + params.TxIdx+1, len(ftx.Frames)) + }) + return nil + } + + // Deterministic deployment check based on effective tx ID + effectiveTxID := params.TxIdx + s.options.TxIdOffset + shouldDeploy := s.contractPool.ShouldDeploy(effectiveTxID) + + var ( + receiptChan scenario.ReceiptChan + tx *types.Transaction + client *spamoor.Client + wallet *spamoor.Wallet + err error + ) + + if shouldDeploy { + receiptChan, tx, client, wallet, err = s.contractPool.DeployNew( + ctx, s.walletPool, effectiveTxID, + s.options.BaseFee, s.options.TipFee, + s.options.BaseFeeWei, s.options.TipFeeWei, + s.options.GasLimit, + s.options.ClientGroup, + params.TxIdx, + ) + } else { + switch s.options.FuzzType { + case "call": + receiptChan, tx, client, wallet, err = s.sendCallTx(ctx, params.TxIdx) + case "setcode": + receiptChan, tx, client, wallet, err = s.sendSetCodeTx(ctx, params.TxIdx) + default: + return fmt.Errorf("unsupported fuzz type: %s", s.options.FuzzType) + } + } + + if client != nil { + logger = logger.WithField("rpc", client.GetName()) + } + if tx != nil { + logger = logger.WithField("nonce", tx.Nonce()) + } + if wallet != nil { + logger = logger.WithField("wallet", s.walletPool.GetWalletName(wallet.GetAddress())) + } + + params.NotifySubmitted() + params.OrderedLogCb(func() { + action := s.options.FuzzType + if shouldDeploy { + action = "deploy" + } + if err != nil { + logger.Warnf("%s tx #%6d failed: %v", action, params.TxIdx+1, err) + } else if s.options.LogTxs { + logger.Infof("%s tx #%6d sent: %v", action, params.TxIdx+1, tx.Hash().String()) + } else { + logger.Debugf("%s tx #%6d sent: %v", action, params.TxIdx+1, tx.Hash().String()) + } + }) + + // Wait for receipt + if receiptChan != nil { + if _, waitErr := receiptChan.Wait(ctx); waitErr != nil { + return waitErr + } + } + + return err +} + +// validateSeed validates a hex seed string. +func validateSeed(seed string) error { + clean := strings.TrimPrefix(seed, "0x") + if _, err := hex.DecodeString(clean); err != nil { + return fmt.Errorf("seed must be valid hex: %w", err) + } + + return nil +} diff --git a/scenarios/calltx-fuzz/contract_pool.go b/scenarios/calltx-fuzz/contract_pool.go new file mode 100644 index 0000000..c7709b0 --- /dev/null +++ b/scenarios/calltx-fuzz/contract_pool.go @@ -0,0 +1,542 @@ +package calltxfuzz + +import ( + "context" + "fmt" + "math" + "sort" + "sync" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/holiman/uint256" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/spamoor/scenario" + "github.com/ethpandaops/spamoor/spamoor" + "github.com/ethpandaops/spamoor/txbuilder" +) + +// ContractPool manages a deterministic pool of deployed fuzz contracts. +// Contracts are generated from a seed so the pool is reproducible. +// It operates as a ring buffer: new deployments rotate out the oldest contract. +// +// Deployment schedule (by effectiveTxID): +// - 0 to poolSize-1: initial pool fill (deploy index = effectiveTxID) +// - poolSize onwards: deploy every deployInterval txs +// (deploy index = poolSize + (effectiveTxID - poolSize) / deployInterval) +// +// This deterministic schedule allows reconstructing the pool state +// at any txIdOffset without replaying the full history. +type ContractPool struct { + mu sync.RWMutex + contracts []common.Address // Deployed contract addresses + bytecodes [][]byte // Corresponding runtime bytecodes + poolSize uint64 + deployInterval uint64 // Post-init deploy interval (0 = no post-init deploys) + seed string + maxCodeSize uint64 + minCodeSize uint64 + gasLimit uint64 + logger *logrus.Entry +} + +// NewContractPool creates a new deterministic contract pool. +// deployRatio determines how often post-init deployments occur (0 = never). +// For example, deployRatio=0.1 means every 10th tx after the initial fill +// deploys a new contract that rotates out the oldest. +func NewContractPool( + poolSize uint64, + seed string, + maxCodeSize, minCodeSize, gasLimit uint64, + deployRatio float64, + logger *logrus.Entry, +) *ContractPool { + var deployInterval uint64 + if deployRatio > 0 && deployRatio <= 1 { + deployInterval = uint64(math.Round(1.0 / deployRatio)) + if deployInterval == 0 { + deployInterval = 1 + } + } + + return &ContractPool{ + contracts: make([]common.Address, 0, poolSize), + bytecodes: make([][]byte, 0, poolSize), + poolSize: poolSize, + deployInterval: deployInterval, + seed: seed, + maxCodeSize: maxCodeSize, + minCodeSize: minCodeSize, + gasLimit: gasLimit, + logger: logger, + } +} + +// GetAddresses returns a snapshot of all contract addresses currently in the pool. +func (p *ContractPool) GetAddresses() []common.Address { + p.mu.RLock() + defer p.mu.RUnlock() + + result := make([]common.Address, len(p.contracts)) + copy(result, p.contracts) + + return result +} + +// GetRandomContract returns a random contract address from the pool. +func (p *ContractPool) GetRandomContract(rng interface{ Intn(int) int }) common.Address { + p.mu.RLock() + defer p.mu.RUnlock() + + if len(p.contracts) == 0 { + return common.Address{} + } + + return p.contracts[rng.Intn(len(p.contracts))] +} + +// Size returns the current number of contracts in the pool. +func (p *ContractPool) Size() int { + p.mu.RLock() + defer p.mu.RUnlock() + + return len(p.contracts) +} + +// GetDeployInterval returns the post-init deploy interval. +func (p *ContractPool) GetDeployInterval() uint64 { + return p.deployInterval +} + +// ShouldDeploy returns true if the given effectiveTxID should be a deployment +// in the main transaction loop. Initial pool fill (txIDs 0 to poolSize-1) is +// handled by InitPool, so this only returns true for post-init rotations. +func (p *ContractPool) ShouldDeploy(effectiveTxID uint64) bool { + if effectiveTxID < p.poolSize { + return false // Handled by InitPool + } + if p.deployInterval == 0 { + return false + } + + return (effectiveTxID-p.poolSize)%p.deployInterval == 0 +} + +// DeployIndexForTx returns the contract deploy index for a given effectiveTxID. +// Only meaningful when ShouldDeploy returns true or for initial pool fill. +func (p *ContractPool) DeployIndexForTx(effectiveTxID uint64) uint64 { + if effectiveTxID < p.poolSize { + return effectiveTxID + } + if p.deployInterval == 0 { + return p.poolSize - 1 + } + + return p.poolSize + (effectiveTxID-p.poolSize)/p.deployInterval +} + +// countDeploysBefore returns the total number of deployments with +// effectiveTxID strictly less than the given value. +func (p *ContractPool) countDeploysBefore(effectiveTxID uint64) uint64 { + if effectiveTxID == 0 { + return 0 + } + if effectiveTxID <= p.poolSize { + return effectiveTxID + } + + initialDeploys := p.poolSize + if p.deployInterval == 0 { + return initialDeploys + } + + // Post-init deploys at: poolSize, poolSize+N, poolSize+2N, ... + // Count those with txID < effectiveTxID. + postInitDeploys := (effectiveTxID - p.poolSize - 1) / p.deployInterval + postInitDeploys++ // +1 for the deploy at poolSize itself + + return initialDeploys + postInitDeploys +} + +// generateRuntimeBytecode generates runtime bytecode for a deploy index. +// peerAddresses are addresses of other pool contracts that the generated +// bytecode can cross-call. When nil, templates fall back to ADDRESS/CALLER. +func (p *ContractPool) generateRuntimeBytecode( + deployIdx uint64, + peerAddresses []common.Address, +) []byte { + codeSize := p.minCodeSize + if p.maxCodeSize > p.minCodeSize { + rng := newQuickRNG(deployIdx, p.seed) + codeSize = p.minCodeSize + uint64(rng.Intn(int(p.maxCodeSize-p.minCodeSize))) + } + + gen := NewCallFuzzGenerator( + deployIdx, p.seed, int(codeSize), p.gasLimit, peerAddresses, + ) + + return gen.Generate() +} + +// wrapInInitCode wraps runtime bytecode in init code that deploys it. +// Init code: PUSH2 DUP1 PUSH2 PUSH0 CODECOPY PUSH0 RETURN +func wrapInInitCode(runtime []byte) []byte { + runtimeLen := len(runtime) + + // Init code: PUSH2 DUP1 PUSH2 PUSH0 CODECOPY PUSH0 RETURN + // That's: 3 + 1 + 3 + 1 + 1 + 1 + 1 = 11 bytes of init code + const initCodeLen = 11 + + initCode := make([]byte, 0, initCodeLen+runtimeLen) + + // PUSH2 + initCode = append(initCode, 0x61, byte(runtimeLen>>8), byte(runtimeLen)) + // DUP1 + initCode = append(initCode, 0x80) + // PUSH2 (offset where runtime begins) + initCode = append(initCode, 0x61, byte(initCodeLen>>8), byte(initCodeLen)) + // PUSH0 (destination offset in memory) + initCode = append(initCode, 0x5f) + // CODECOPY + initCode = append(initCode, 0x39) + // PUSH0 (memory offset for RETURN) + initCode = append(initCode, 0x5f) + // RETURN + initCode = append(initCode, 0xf3) + + // Append runtime bytecode + initCode = append(initCode, runtime...) + + return initCode +} + +// InitPool deploys the initial pool of contracts using multi-wallet batch +// submission. Distributes deployments across child wallets to avoid +// per-wallet pending limits that would throttle a single deployer. +func (p *ContractPool) InitPool( + ctx context.Context, + walletPool *spamoor.WalletPool, + txIdOffset uint64, + baseFee, tipFee float64, + baseFeeWei, tipFeeWei string, + deployGasLimit uint64, + clientGroup string, +) error { + // Determine which deploy indices should be in the pool + totalDeploys := p.countDeploysBefore(txIdOffset) + + // If starting within the initial pool fill phase, deploy the full initial pool + if txIdOffset <= p.poolSize { + totalDeploys = p.poolSize + } + + if totalDeploys == 0 { + return nil + } + + startIdx := uint64(0) + if totalDeploys > p.poolSize { + startIdx = totalDeploys - p.poolSize + } + + contractCount := totalDeploys - startIdx + + client := walletPool.GetClient( + spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, 0), + spamoor.WithClientGroup(clientGroup), + ) + if client == nil { + return fmt.Errorf("no client available for deployment") + } + + // Collect deploy wallets: spread across child wallets to avoid + // per-wallet pending limits throttling the deployment. + const maxDeployWallets = 20 + walletCount := int(contractCount) + if walletCount > maxDeployWallets { + walletCount = maxDeployWallets + } + + wallets := make([]*spamoor.Wallet, walletCount) + for i := range walletCount { + w := walletPool.GetWallet(spamoor.SelectWalletByIndex, i) + if w == nil { + return fmt.Errorf("deployer wallet %d not found", i) + } + if err := w.ResetNoncesIfNeeded(ctx, client); err != nil { + return fmt.Errorf("deployer wallet %d nonce reset failed: %w", i, err) + } + wallets[i] = w + } + + p.logger.Infof("deploying initial contract pool (%d contracts across %d wallets, deploy indices %d-%d)", + contractCount, walletCount, startIdx, totalDeploys-1) + + feeCapWei, tipCapWei := spamoor.ResolveFees(baseFee, tipFee, baseFeeWei, tipFeeWei) + feeCap, tipCap, err := walletPool.GetTxPool().GetSuggestedFees(client, feeCapWei, tipCapWei) + if err != nil { + return fmt.Errorf("fee suggestion failed: %w", err) + } + + // Predict deployment addresses per wallet so bytecodes can cross-reference. + // Assign contracts round-robin across wallets. + type deployEntry struct { + walletIdx int + nonce uint64 + } + deployPlan := make([]deployEntry, contractCount) + walletNonces := make([]uint64, walletCount) + for i := range walletCount { + walletNonces[i] = wallets[i].GetNonce() + } + for i := uint64(0); i < contractCount; i++ { + wIdx := int(i) % walletCount + deployPlan[i] = deployEntry{walletIdx: wIdx, nonce: walletNonces[wIdx]} + walletNonces[wIdx]++ + } + + predictedAddrs := make([]common.Address, contractCount) + for i := uint64(0); i < contractCount; i++ { + e := deployPlan[i] + predictedAddrs[i] = crypto.CreateAddress(wallets[e.walletIdx].GetAddress(), e.nonce) + } + + // Build all deploy transactions, grouped by wallet + walletTxs := make(map[*spamoor.Wallet][]*types.Transaction, walletCount) + runtimes := make([][]byte, contractCount) + + // Track which contract index each wallet's tx corresponds to + type txMapping struct { + contractIdx int + runtime []byte + } + walletMappings := make(map[*spamoor.Wallet][]txMapping, walletCount) + + for i := startIdx; i < totalDeploys; i++ { + localIdx := i - startIdx + peerAddrs := make([]common.Address, 0, contractCount-1) + for j := uint64(0); j < contractCount; j++ { + if j != localIdx { + peerAddrs = append(peerAddrs, predictedAddrs[j]) + } + } + + runtime := p.generateRuntimeBytecode(i, peerAddrs) + initCode := wrapInInitCode(runtime) + runtimes[localIdx] = runtime + + e := deployPlan[localIdx] + w := wallets[e.walletIdx] + + txData, buildErr := txbuilder.DynFeeTx(&txbuilder.TxMetadata{ + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + Gas: deployGasLimit, + To: nil, // Contract creation + Data: initCode, + }) + if buildErr != nil { + return fmt.Errorf("build deploy tx %d failed: %w", i, buildErr) + } + + tx, signErr := w.BuildDynamicFeeTx(txData) + if signErr != nil { + return fmt.Errorf("sign deploy tx %d failed: %w", i, signErr) + } + + walletTxs[w] = append(walletTxs[w], tx) + walletMappings[w] = append(walletMappings[w], txMapping{ + contractIdx: int(localIdx), + runtime: runtime, + }) + } + + // Multi-wallet batch send and wait for all receipts + allReceipts, err := walletPool.GetTxPool().SendMultiTransactionBatch(ctx, walletTxs, &spamoor.BatchOptions{ + SendTransactionOptions: spamoor.SendTransactionOptions{ + Client: client, + ClientGroup: clientGroup, + Rebroadcast: true, + }, + MaxRetries: 3, + LogFn: func(confirmed, total int) { + p.logger.Infof("pool deployment progress: %d/%d confirmed", confirmed, total) + }, + LogInterval: 10, + }) + if err != nil { + return fmt.Errorf("batch deploy failed: %w", err) + } + + // Process receipts and populate the pool in deploy order + type poolEntry struct { + addr common.Address + runtime []byte + idx int + } + entries := make([]poolEntry, 0, contractCount) + + for w, receipts := range allReceipts { + mappings := walletMappings[w] + for i, receipt := range receipts { + if i >= len(mappings) { + break + } + m := mappings[i] + if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful { + entries = append(entries, poolEntry{ + addr: receipt.ContractAddress, + runtime: m.runtime, + idx: m.contractIdx, + }) + p.logger.Debugf("deployed contract %d at %s (%d bytes)", + startIdx+uint64(m.contractIdx), receipt.ContractAddress.Hex(), len(m.runtime)) + } else { + p.logger.Warnf("deploy tx %d failed (receipt: %v)", + startIdx+uint64(m.contractIdx), receipt) + } + } + } + + // Sort by original deploy order for deterministic pool state + sort.Slice(entries, func(i, j int) bool { + return entries[i].idx < entries[j].idx + }) + + p.mu.Lock() + for _, e := range entries { + p.contracts = append(p.contracts, e.addr) + p.bytecodes = append(p.bytecodes, e.runtime) + } + p.mu.Unlock() + + p.logger.Infof("contract pool initialized with %d contracts", len(p.contracts)) + + return nil +} + +// DeployNew deploys a new fuzz contract and adds it to the pool, +// rotating out the oldest contract if the pool is full. +// The deploy index is computed from the effectiveTxID. +func (p *ContractPool) DeployNew( + ctx context.Context, + walletPool *spamoor.WalletPool, + effectiveTxID uint64, + baseFee, tipFee float64, + baseFeeWei, tipFeeWei string, + deployGasLimit uint64, + clientGroup string, + txIdx uint64, +) (scenario.ReceiptChan, *types.Transaction, *spamoor.Client, *spamoor.Wallet, error) { + deployIdx := p.DeployIndexForTx(effectiveTxID) + peerAddrs := p.GetAddresses() // current pool contracts as peers + runtime := p.generateRuntimeBytecode(deployIdx, peerAddrs) + initCode := wrapInInitCode(runtime) + + wallet := walletPool.GetWallet(spamoor.SelectWalletByPendingTxCount, int(txIdx)) + if wallet == nil { + return nil, nil, nil, nil, fmt.Errorf("no wallet available") + } + + client := walletPool.GetClient( + spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, int(txIdx)), + spamoor.WithClientGroup(clientGroup), + ) + if client == nil { + return nil, nil, nil, wallet, fmt.Errorf("no client available") + } + + if err := wallet.ResetNoncesIfNeeded(ctx, client); err != nil { + return nil, nil, client, wallet, err + } + + feeCapWei, tipCapWei := spamoor.ResolveFees(baseFee, tipFee, baseFeeWei, tipFeeWei) + feeCap, tipCap, err := walletPool.GetTxPool().GetSuggestedFees(client, feeCapWei, tipCapWei) + if err != nil { + return nil, nil, client, wallet, err + } + + txData, err := txbuilder.DynFeeTx(&txbuilder.TxMetadata{ + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + Gas: deployGasLimit, + To: nil, + Data: initCode, + }) + if err != nil { + return nil, nil, client, wallet, err + } + + tx, err := wallet.BuildDynamicFeeTx(txData) + if err != nil { + return nil, nil, client, wallet, err + } + + receiptChan := make(scenario.ReceiptChan, 1) + + err = walletPool.GetTxPool().SendTransaction(ctx, wallet, tx, &spamoor.SendTransactionOptions{ + Client: client, + ClientGroup: clientGroup, + Rebroadcast: true, + OnComplete: func(tx *types.Transaction, receipt *types.Receipt, err error) { + if err == nil && receipt != nil && receipt.Status == types.ReceiptStatusSuccessful { + p.addContract(receipt.ContractAddress, runtime) + } + receiptChan <- receipt + }, + }) + if err != nil { + wallet.MarkSkippedNonce(tx.Nonce()) + return nil, nil, client, wallet, err + } + + return receiptChan, tx, client, wallet, nil +} + +// addContract adds a contract to the pool, rotating out the oldest if full. +func (p *ContractPool) addContract(addr common.Address, runtime []byte) { + p.mu.Lock() + defer p.mu.Unlock() + + if uint64(len(p.contracts)) >= p.poolSize { + // Ring buffer: rotate out the oldest + p.contracts = append(p.contracts[1:], addr) + p.bytecodes = append(p.bytecodes[1:], runtime) + } else { + p.contracts = append(p.contracts, addr) + p.bytecodes = append(p.bytecodes, runtime) + } +} + +// quickRNG is a minimal RNG for non-crypto purposes (pool size selection). +type quickRNG struct { + state uint64 +} + +func newQuickRNG(idx uint64, seed string) *quickRNG { + h := uint64(0x9e3779b97f4a7c15) + for _, b := range []byte(seed) { + h ^= uint64(b) + h *= 0x2545f4914f6cdd1d + } + h ^= idx + h *= 0x2545f4914f6cdd1d + if h == 0 { + h = 1 + } + + return &quickRNG{state: h} +} + +func (r *quickRNG) Intn(n int) int { + if n <= 0 { + return 0 + } + r.state ^= r.state >> 12 + r.state ^= r.state << 25 + r.state ^= r.state >> 27 + + return int((r.state * 0x2545f4914f6cdd1d) % uint64(n)) +} diff --git a/scenarios/calltx-fuzz/mode_call.go b/scenarios/calltx-fuzz/mode_call.go new file mode 100644 index 0000000..44f8079 --- /dev/null +++ b/scenarios/calltx-fuzz/mode_call.go @@ -0,0 +1,101 @@ +package calltxfuzz + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/holiman/uint256" + + "github.com/ethpandaops/spamoor/scenario" + evmfuzz "github.com/ethpandaops/spamoor/scenarios/evm-fuzz" + "github.com/ethpandaops/spamoor/spamoor" + "github.com/ethpandaops/spamoor/txbuilder" +) + +// sendCallTx sends a Type 2 DynFeeTx that calls a deployed fuzz contract. +func (s *Scenario) sendCallTx( + ctx context.Context, + txIdx uint64, +) (scenario.ReceiptChan, *types.Transaction, *spamoor.Client, *spamoor.Wallet, error) { + if s.contractPool.Size() == 0 { + return nil, nil, nil, nil, fmt.Errorf("contract pool is empty") + } + + effectiveTxID := txIdx + s.options.TxIdOffset + rng := evmfuzz.NewDeterministicRNGWithSeed(effectiveTxID, s.seed) + calldataGen := NewCalldataGenerator(rng, s.options.CalldataMaxSize) + + // Pick target contract from pool + contractAddr := s.contractPool.GetRandomContract(rng) + calldata := calldataGen.Generate() + + // Select wallet and client + wallet := s.walletPool.GetWallet(spamoor.SelectWalletByPendingTxCount, int(txIdx)) + if wallet == nil { + return nil, nil, nil, nil, fmt.Errorf("no wallet available") + } + + client := s.walletPool.GetClient( + spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, int(txIdx)), + spamoor.WithClientGroup(s.options.ClientGroup), + ) + if client == nil { + return nil, nil, nil, wallet, fmt.Errorf("no client available") + } + + if err := wallet.ResetNoncesIfNeeded(ctx, client); err != nil { + return nil, nil, client, wallet, err + } + + baseFeeWei, tipFeeWei := spamoor.ResolveFees( + s.options.BaseFee, s.options.TipFee, + s.options.BaseFeeWei, s.options.TipFeeWei, + ) + feeCap, tipCap, err := s.walletPool.GetTxPool().GetSuggestedFees(client, baseFeeWei, tipFeeWei) + if err != nil { + return nil, nil, client, wallet, err + } + + // 75% chance: include a small random ETH value so called contracts + // have balance for internal value transfers (CALL with value > 0). + // 25%: zero value, which keeps "insufficient balance" errors possible. + value := uint256.NewInt(0) + if rng.Float64() < 0.75 { + value = uint256.NewInt(uint64(0xa000 + rng.Intn(0x6000))) + } + + txData, err := txbuilder.DynFeeTx(&txbuilder.TxMetadata{ + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + Gas: s.options.GasLimit, + To: &contractAddr, + Value: value, + Data: calldata, + }) + if err != nil { + return nil, nil, client, wallet, err + } + + tx, err := wallet.BuildDynamicFeeTx(txData) + if err != nil { + return nil, nil, client, wallet, 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 *types.Transaction, receipt *types.Receipt, err error) { + receiptChan <- receipt + }, + }) + if err != nil { + wallet.MarkSkippedNonce(tx.Nonce()) + return nil, nil, client, wallet, err + } + + return receiptChan, tx, client, wallet, nil +} diff --git a/scenarios/calltx-fuzz/mode_frame.go b/scenarios/calltx-fuzz/mode_frame.go new file mode 100644 index 0000000..a3ec7e3 --- /dev/null +++ b/scenarios/calltx-fuzz/mode_frame.go @@ -0,0 +1,137 @@ +package calltxfuzz + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/holiman/uint256" + + evmfuzz "github.com/ethpandaops/spamoor/scenarios/evm-fuzz" + "github.com/ethpandaops/spamoor/spamoor" +) + +// FrameMode constants for EIP-8141 frame transaction modes. +const ( + FrameModeDefault = 0 // Caller is ENTRY_POINT + FrameModeVerify = 1 // STATICCALL semantics, must call APPROVE + FrameModeSender = 2 // Caller is tx sender +) + +// Frame represents a single execution frame in a Type 6 transaction. +type Frame struct { + Mode uint8 + Target *common.Address // nil for contract creation + GasLimit uint64 + Data []byte +} + +// FrameTxData represents the complete Type 6 transaction data (pre-signing). +// This is a stub — no signing or submission is implemented. +type FrameTxData struct { + ChainID uint64 + Nonce uint64 + Sender common.Address + Frames []Frame + MaxPriorityFeePerGas *uint256.Int + MaxFeePerGas *uint256.Int + MaxFeePerBlobGas *uint256.Int + BlobVersionedHashes []common.Hash +} + +// generateFrameTx generates frame transaction data but does NOT sign or submit it. +// This is a stub for future EIP-8141 support. +func (s *Scenario) generateFrameTx(txIdx uint64) *FrameTxData { + effectiveTxID := txIdx + s.options.TxIdOffset + rng := evmfuzz.NewDeterministicRNGWithSeed(effectiveTxID, s.seed) + calldataGen := NewCalldataGenerator(rng, s.options.CalldataMaxSize) + + maxFrames := s.options.MaxFrames + if maxFrames == 0 { + maxFrames = 10 + } + + frameCount := rng.Intn(int(maxFrames)) + 1 + totalGas := s.options.GasLimit + frames := make([]Frame, 0, frameCount) + + // Determine frame ordering pattern + ordering := rng.Intn(4) + + for i := 0; i < frameCount; i++ { + // Allocate gas (random distribution across frames) + var frameGas uint64 + if i == frameCount-1 { + frameGas = totalGas // Give remaining gas to last frame + } else { + frameGas = uint64(rng.Intn(int(totalGas/(uint64(frameCount-i))))) + 1 + totalGas -= frameGas + } + + var mode uint8 + switch ordering { + case 0: // VERIFY first, then DEFAULT/SENDER + if i == 0 { + mode = FrameModeVerify + } else if rng.Float64() < 0.5 { + mode = FrameModeSender + } else { + mode = FrameModeDefault + } + case 1: // VERIFY last + if i == frameCount-1 { + mode = FrameModeVerify + } else if rng.Float64() < 0.5 { + mode = FrameModeSender + } else { + mode = FrameModeDefault + } + case 2: // No VERIFY (invalid — should fail) + if rng.Float64() < 0.5 { + mode = FrameModeSender + } else { + mode = FrameModeDefault + } + default: // Multiple VERIFY (second should fail) + if i < 2 { + mode = FrameModeVerify + } else if rng.Float64() < 0.5 { + mode = FrameModeSender + } else { + mode = FrameModeDefault + } + } + + var target *common.Address + if s.contractPool.Size() > 0 { + addr := s.contractPool.GetRandomContract(rng) + target = &addr + } + + frames = append(frames, Frame{ + Mode: mode, + Target: target, + GasLimit: frameGas, + Data: calldataGen.Generate(), + }) + } + + wallet := s.walletPool.GetWallet(spamoor.SelectWalletByIndex, int(txIdx)) + var sender common.Address + if wallet != nil { + sender = wallet.GetAddress() + } + + ftx := &FrameTxData{ + ChainID: s.walletPool.GetChainId().Uint64(), + Nonce: txIdx, + Sender: sender, + Frames: frames, + MaxPriorityFeePerGas: uint256.NewInt(2000000000), // 2 gwei + MaxFeePerGas: uint256.NewInt(20000000000), // 20 gwei + } + + s.logger.WithField("txIdx", txIdx).Debugf( + "generated frame tx: %d frames, ordering=%d", + len(frames), ordering, + ) + + return ftx +} diff --git a/scenarios/calltx-fuzz/mode_setcode.go b/scenarios/calltx-fuzz/mode_setcode.go new file mode 100644 index 0000000..868538d --- /dev/null +++ b/scenarios/calltx-fuzz/mode_setcode.go @@ -0,0 +1,341 @@ +package calltxfuzz + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/holiman/uint256" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/spamoor/scenario" + evmfuzz "github.com/ethpandaops/spamoor/scenarios/evm-fuzz" + "github.com/ethpandaops/spamoor/spamoor" + "github.com/ethpandaops/spamoor/txbuilder" +) + +// sendSetCodeTx sends a Type 4 SetCodeTx with fuzzed authorization lists +// and calldata targeting delegated fuzz contracts. +func (s *Scenario) sendSetCodeTx( + ctx context.Context, + txIdx uint64, +) (scenario.ReceiptChan, *types.Transaction, *spamoor.Client, *spamoor.Wallet, error) { + if s.contractPool.Size() == 0 { + return nil, nil, nil, nil, fmt.Errorf("contract pool is empty") + } + + effectiveTxID := txIdx + s.options.TxIdOffset + rng := evmfuzz.NewDeterministicRNGWithSeed(effectiveTxID, s.seed) + calldataGen := NewCalldataGenerator(rng, s.options.CalldataMaxSize) + + // Build authorization list + authList, delegators := s.buildFuzzedAuthList(rng, txIdx) + + // EIP-7702 requires at least one authorization; fall back to call tx if empty + if len(authList) == 0 { + return s.sendCallTx(ctx, txIdx) + } + + // Pick a delegated EOA as the To address + var toAddr common.Address + if len(delegators) > 0 { + toAddr = delegators[rng.Intn(len(delegators))].GetAddress() + } else { + // Fallback to a pool contract + toAddr = s.contractPool.GetRandomContract(rng) + } + + calldata := calldataGen.Generate() + + // Select sender wallet and client + wallet := s.walletPool.GetWallet(spamoor.SelectWalletByPendingTxCount, int(txIdx)) + if wallet == nil { + return nil, nil, nil, nil, fmt.Errorf("no wallet available") + } + + client := s.walletPool.GetClient( + spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, int(txIdx)), + spamoor.WithClientGroup(s.options.ClientGroup), + ) + if client == nil { + return nil, nil, nil, wallet, fmt.Errorf("no client available") + } + + if err := wallet.ResetNoncesIfNeeded(ctx, client); err != nil { + return nil, nil, client, wallet, err + } + + baseFeeWei, tipFeeWei := spamoor.ResolveFees( + s.options.BaseFee, s.options.TipFee, + s.options.BaseFeeWei, s.options.TipFeeWei, + ) + feeCap, tipCap, err := s.walletPool.GetTxPool().GetSuggestedFees(client, baseFeeWei, tipFeeWei) + if err != nil { + return nil, nil, client, wallet, err + } + + // 75% chance: include a small random ETH value so delegated contracts + // have balance for internal value transfers (CALL with value > 0). + // 25%: zero value, which keeps "insufficient balance" errors possible. + value := uint256.NewInt(0) + if rng.Float64() < 0.75 { + value = uint256.NewInt(uint64(0xa000 + rng.Intn(0x6000))) + } + + txData, err := txbuilder.SetCodeTx(&txbuilder.TxMetadata{ + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + Gas: s.options.GasLimit, + To: &toAddr, + Value: value, + Data: calldata, + AuthList: authList, + }) + if err != nil { + return nil, nil, client, wallet, err + } + + tx, err := wallet.BuildSetCodeTx(txData) + if err != nil { + return nil, nil, client, wallet, 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 *types.Transaction, receipt *types.Receipt, err error) { + receiptChan <- receipt + }, + }) + if err != nil { + wallet.MarkSkippedNonce(tx.Nonce()) + return nil, nil, client, wallet, err + } + + return receiptChan, tx, client, wallet, nil +} + +// buildFuzzedAuthList constructs a fuzzed authorization list for SetCodeTx. +// Returns the authorization list and the delegator wallets used. +func (s *Scenario) buildFuzzedAuthList( + rng *evmfuzz.DeterministicRNG, + txIdx uint64, +) ([]types.SetCodeAuthorization, []*spamoor.Wallet) { + minAuth := s.options.MinAuthorizations + maxAuth := s.options.MaxAuthorizations + if maxAuth == 0 { + return nil, nil + } + if minAuth > maxAuth { + minAuth = maxAuth + } + + n := big.NewInt(int64(maxAuth - minAuth + 1)) + authCount := int(minAuth) + rng.Intn(int(n.Int64())) + + authorizations := make([]types.SetCodeAuthorization, 0, authCount) + delegators := make([]*spamoor.Wallet, 0, authCount) + chainID := s.walletPool.GetChainId().Uint64() + + for i := 0; i < authCount; i++ { + delegatorIdx := (txIdx * maxAuth) + uint64(i) + if s.options.MaxDelegators > 0 { + delegatorIdx = delegatorIdx % s.options.MaxDelegators + } + + delegator := s.getOrCreateDelegator(delegatorIdx) + if delegator == nil { + continue + } + delegators = append(delegators, delegator) + + // Decide if this authorization should be deliberately invalid + if rng.Float64() < s.options.InvalidAuthRatio { + auth := s.buildInvalidAuth(rng, delegator, chainID) + authorizations = append(authorizations, auth) + continue + } + + // Valid authorization: delegate to a pool contract + codeAddr := s.contractPool.GetRandomContract(rng) + auth := types.SetCodeAuthorization{ + ChainID: *uint256.NewInt(chainID), + Address: codeAddr, + Nonce: delegator.GetNextNonce(), + } + + signedAuth, err := types.SignSetCode(delegator.GetPrivateKey(), auth) + if err != nil { + s.logger.WithFields(logrus.Fields{ + "delegator": delegator.GetAddress().Hex(), + }).Warnf("failed to sign auth: %v", err) + continue + } + authorizations = append(authorizations, signedAuth) + } + + return authorizations, delegators +} + +// buildInvalidAuth constructs a deliberately invalid authorization for fuzzing. +func (s *Scenario) buildInvalidAuth( + rng *evmfuzz.DeterministicRNG, + delegator *spamoor.Wallet, + chainID uint64, +) types.SetCodeAuthorization { + invalidType := rng.Intn(7) + + switch invalidType { + case 0: // Wrong chain_id + codeAddr := s.contractPool.GetRandomContract(rng) + auth := types.SetCodeAuthorization{ + ChainID: *uint256.NewInt(uint64(rng.Intn(1000000) + 1)), + Address: codeAddr, + Nonce: delegator.GetNextNonce(), + } + signedAuth, err := types.SignSetCode(delegator.GetPrivateKey(), auth) + if err != nil { + return auth + } + return signedAuth + + case 1: // Chain_id = 0 (wildcard) + codeAddr := s.contractPool.GetRandomContract(rng) + auth := types.SetCodeAuthorization{ + ChainID: *uint256.NewInt(0), + Address: codeAddr, + Nonce: delegator.GetNextNonce(), + } + signedAuth, err := types.SignSetCode(delegator.GetPrivateKey(), auth) + if err != nil { + return auth + } + return signedAuth + + case 2: // Invalid signature (corrupted R/S) + codeAddr := s.contractPool.GetRandomContract(rng) + auth := types.SetCodeAuthorization{ + ChainID: *uint256.NewInt(chainID), + Address: codeAddr, + Nonce: delegator.GetNextNonce(), + } + signedAuth, err := types.SignSetCode(delegator.GetPrivateKey(), auth) + if err != nil { + return auth + } + // Corrupt the signature + signedAuth.R.SetUint64(rng.Uint64()) + return signedAuth + + case 3: // Nonce mismatch + codeAddr := s.contractPool.GetRandomContract(rng) + auth := types.SetCodeAuthorization{ + ChainID: *uint256.NewInt(chainID), + Address: codeAddr, + Nonce: delegator.GetNextNonce() + uint64(rng.Intn(100)+1), + } + signedAuth, err := types.SignSetCode(delegator.GetPrivateKey(), auth) + if err != nil { + return auth + } + return signedAuth + + case 4: // Delegation to address(0) (clear delegation) + auth := types.SetCodeAuthorization{ + ChainID: *uint256.NewInt(chainID), + Address: common.Address{}, + Nonce: delegator.GetNextNonce(), + } + signedAuth, err := types.SignSetCode(delegator.GetPrivateKey(), auth) + if err != nil { + return auth + } + return signedAuth + + case 5: // Delegation to precompile address + precompileAddr := common.BigToAddress(big.NewInt(int64(rng.Intn(9) + 1))) + auth := types.SetCodeAuthorization{ + ChainID: *uint256.NewInt(chainID), + Address: precompileAddr, + Nonce: delegator.GetNextNonce(), + } + signedAuth, err := types.SignSetCode(delegator.GetPrivateKey(), auth) + if err != nil { + return auth + } + return signedAuth + + default: // Delegation to another delegator EOA (chain test) + var targetAddr common.Address + otherIdx := uint64(rng.Intn(int(s.options.MaxDelegators) + 1)) + otherDelegator := s.getOrCreateDelegator(otherIdx) + if otherDelegator != nil { + targetAddr = otherDelegator.GetAddress() + } else { + targetAddr = common.BigToAddress(big.NewInt(int64(rng.Uint64()))) + } + auth := types.SetCodeAuthorization{ + ChainID: *uint256.NewInt(chainID), + Address: targetAddr, + Nonce: delegator.GetNextNonce(), + } + signedAuth, err := types.SignSetCode(delegator.GetPrivateKey(), auth) + if err != nil { + return auth + } + return signedAuth + } +} + +// getOrCreateDelegator returns or creates a delegator wallet at the given index. +func (s *Scenario) getOrCreateDelegator(idx uint64) *spamoor.Wallet { + s.delegatorMu.Lock() + defer s.delegatorMu.Unlock() + + if s.options.MaxDelegators > 0 && len(s.delegators) > int(idx) && s.delegators[idx] != nil { + return s.delegators[idx] + } + + delegator, err := s.prepareDelegator(idx) + if err != nil { + s.logger.Errorf("could not prepare delegator %d: %v", idx, err) + return nil + } + + if s.options.MaxDelegators > 0 { + // Grow slice if needed + for len(s.delegators) <= int(idx) { + s.delegators = append(s.delegators, nil) + } + s.delegators[idx] = delegator + } + + return delegator +} + +// prepareDelegator derives a deterministic delegator wallet. +func (s *Scenario) prepareDelegator(idx uint64) (*spamoor.Wallet, error) { + idxBytes := make([]byte, 8) + binary.BigEndian.PutUint64(idxBytes, idx) + + if s.options.MaxDelegators > 0 { + idxBytes = append(idxBytes, s.delegatorSeed...) + } + + rootAddr := s.walletPool.GetRootWallet().GetWallet().GetAddress() + childKey := sha256.Sum256(append(common.FromHex(rootAddr.Hex()), idxBytes...)) + + privateKey, address, err := spamoor.LoadPrivateKey(fmt.Sprintf("%x", childKey)) + if err != nil { + return nil, err + } + + return spamoor.NewWallet(privateKey, address), nil +} diff --git a/scenarios/calltx-fuzz/opcodes.go b/scenarios/calltx-fuzz/opcodes.go new file mode 100644 index 0000000..3d7901e --- /dev/null +++ b/scenarios/calltx-fuzz/opcodes.go @@ -0,0 +1,143 @@ +package calltxfuzz + +import ( + evmfuzz "github.com/ethpandaops/spamoor/scenarios/evm-fuzz" +) + +// op is a shorthand constructor avoiding verbose keyed-field literals +// for every opcode definition. +func op(name string, opcode uint16, stackIn, stackOut int, gas uint64, tmpl func() []byte, prob float64) *evmfuzz.OpcodeInfo { + return &evmfuzz.OpcodeInfo{ + Name: name, + Opcode: opcode, + StackInput: stackIn, + StackOutput: stackOut, + GasCost: gas, + Template: tmpl, + Probability: prob, + } +} + +// getCalltxFuzzOpcodeDefinitions returns opcode definitions with weights tuned +// for delegation-aware fuzzing. Storage, transient storage, cross-contract calls, +// and identity opcodes are weighted higher than in evm-fuzz to exercise +// delegation-relevant EVM behavior. +func getCalltxFuzzOpcodeDefinitions() []*evmfuzz.OpcodeInfo { + s := evmfuzz.SimpleOpcode + san := evmfuzz.SanitizeInput + + return []*evmfuzz.OpcodeInfo{ + op("STOP", 0x00, 0, 0, 0, s(0x00), 0), + + // Arithmetic operations (same weights as evm-fuzz) + op("ADD", 0x01, 2, 1, 3, s(0x01), 1.0), + op("MUL", 0x02, 2, 1, 5, s(0x02), 1.0), + op("SUB", 0x03, 2, 1, 3, s(0x03), 1.0), + op("DIV", 0x04, 2, 1, 5, s(0x04), 1.0), + op("SDIV", 0x05, 2, 1, 5, s(0x05), 1.0), + op("MOD", 0x06, 2, 1, 5, s(0x06), 1.0), + op("SMOD", 0x07, 2, 1, 5, s(0x07), 1.0), + op("ADDMOD", 0x08, 3, 1, 8, s(0x08), 1.0), + op("MULMOD", 0x09, 3, 1, 8, s(0x09), 1.0), + op("EXP", 0x0a, 2, 1, 10, s(0x0a), 1.0), + op("SIGNEXTEND", 0x0b, 2, 1, 5, s(0x0b), 1.0), + + // Comparison operations + op("LT", 0x10, 2, 1, 3, s(0x10), 1.0), + op("GT", 0x11, 2, 1, 3, s(0x11), 1.0), + op("SLT", 0x12, 2, 1, 3, s(0x12), 1.0), + op("SGT", 0x13, 2, 1, 3, s(0x13), 1.0), + op("EQ", 0x14, 2, 1, 3, s(0x14), 1.0), + op("ISZERO", 0x15, 1, 1, 3, s(0x15), 1.0), + op("AND", 0x16, 2, 1, 3, s(0x16), 1.0), + op("OR", 0x17, 2, 1, 3, s(0x17), 1.0), + op("XOR", 0x18, 2, 1, 3, s(0x18), 1.0), + op("NOT", 0x19, 1, 1, 3, s(0x19), 1.0), + op("BYTE", 0x1a, 2, 1, 3, san(s(0x1a), []uint64{0x3f}), 1.0), + op("SHL", 0x1b, 2, 1, 3, s(0x1b), 1.0), + op("SHR", 0x1c, 2, 1, 3, s(0x1c), 1.0), + op("SAR", 0x1d, 2, 1, 3, s(0x1d), 1.0), + op("CLZ", 0x1e, 1, 1, 3, s(0x1e), 1.5), + + // Crypto + op("KECCAK256", 0x20, 2, 1, 30, san(s(0x20), []uint64{0x2ffff, 0xffff}), 1.5), + + // Environmental information — higher weights for identity-related opcodes + op("ADDRESS", 0x30, 0, 1, 2, s(0x30), 4.0), + op("BALANCE", 0x31, 1, 1, 100, s(0x31), 4.0), + op("ORIGIN", 0x32, 0, 1, 2, s(0x32), 4.0), + op("CALLER", 0x33, 0, 1, 2, s(0x33), 4.0), + op("CALLVALUE", 0x34, 0, 1, 2, s(0x34), 2.0), + op("CALLDATALOAD", 0x35, 1, 1, 3, s(0x35), 2.0), + op("CALLDATASIZE", 0x36, 0, 1, 2, s(0x36), 2.0), + op("CALLDATACOPY", 0x37, 3, 0, 3, san(s(0x37), []uint64{0x2ffff, 0x2ffff, 0xffff}), 2.0), + op("CODESIZE", 0x38, 0, 1, 2, s(0x38), 2.0), + op("CODECOPY", 0x39, 3, 0, 3, san(s(0x39), []uint64{0x2ffff, 0x2ffff, 0xffff}), 2.0), + op("GASPRICE", 0x3a, 0, 1, 2, s(0x3a), 2.0), + op("EXTCODESIZE", 0x3b, 1, 1, 100, s(0x3b), 3.5), + op("EXTCODECOPY", 0x3c, 4, 0, 100, san(s(0x3c), []uint64{0, 0x2ffff, 0x2ffff, 0xffff}), 3.5), + op("RETURNDATASIZE", 0x3d, 0, 1, 2, s(0x3d), 2.0), + op("RETURNDATACOPY", 0x3e, 3, 0, 3, san(s(0x3e), []uint64{0x2ffff, 0xff, 0xff}), 2.0), + op("EXTCODEHASH", 0x3f, 1, 1, 100, s(0x3f), 3.5), + + // Block information + op("BLOCKHASH", 0x40, 1, 1, 20, s(0x40), 2.0), + op("COINBASE", 0x41, 0, 1, 2, s(0x41), 2.0), + op("TIMESTAMP", 0x42, 0, 1, 2, s(0x42), 2.0), + op("NUMBER", 0x43, 0, 1, 2, s(0x43), 2.0), + op("DIFFICULTY", 0x44, 0, 1, 2, s(0x44), 2.0), + op("GASLIMIT", 0x45, 0, 1, 2, s(0x45), 2.0), + op("CHAINID", 0x46, 0, 1, 2, s(0x46), 3.0), + op("SELFBALANCE", 0x47, 0, 1, 5, s(0x47), 4.0), + op("BASEFEE", 0x48, 0, 1, 2, s(0x48), 2.0), + op("BLOBHASH", 0x49, 1, 1, 3, s(0x49), 2.0), + op("BLOBBASEFEE", 0x4a, 0, 1, 2, s(0x4a), 2.0), + + // Stack/memory/storage — higher weights for storage and transient storage + op("POP", 0x50, 1, 0, 2, s(0x50), 1.0), + op("MLOAD", 0x51, 1, 1, 3, san(s(0x51), []uint64{0x2ffff}), 1.0), + op("MSTORE", 0x52, 2, 0, 3, san(s(0x52), []uint64{0x2ffff}), 1.0), + op("MSTORE8", 0x53, 2, 0, 3, san(s(0x53), []uint64{0x2ffff}), 1.0), + op("SLOAD", 0x54, 1, 1, 100, san(s(0x54), []uint64{0x2ffff}), 4.0), + op("SSTORE", 0x55, 2, 0, 100, san(s(0x55), []uint64{0x2ffff}), 4.0), + op("PC", 0x58, 0, 1, 2, s(0x58), 1.0), + op("MSIZE", 0x59, 0, 1, 2, s(0x59), 1.0), + op("GAS", 0x5a, 0, 1, 2, s(0x5a), 1.0), + op("JUMPDEST", 0x5b, 0, 0, 1, s(0x5b), 1.0), + op("TLOAD", 0x5c, 1, 1, 100, san(s(0x5c), []uint64{0x2ffff}), 5.0), + op("TSTORE", 0x5d, 2, 0, 100, san(s(0x5d), []uint64{0x2ffff}), 5.0), + op("MCOPY", 0x5e, 3, 0, 3, san(s(0x5e), []uint64{0x2ffff, 0x2ffff, 0xffff}), 2.0), + + // Push operations + op("PUSH0", 0x5f, 0, 1, 2, s(0x5f), 1.0), + + // Log operations + op("LOG0", 0xa0, 2, 0, 375, san(s(0xa0), []uint64{0x2ffff, 0xffff}), 1.0), + op("LOG1", 0xa1, 3, 0, 750, san(s(0xa1), []uint64{0x2ffff, 0xffff}), 1.0), + op("LOG2", 0xa2, 4, 0, 1125, san(s(0xa2), []uint64{0x2ffff, 0xffff}), 1.0), + op("LOG3", 0xa3, 5, 0, 1500, san(s(0xa3), []uint64{0x2ffff, 0xffff}), 1.0), + op("LOG4", 0xa4, 6, 0, 1875, san(s(0xa4), []uint64{0x2ffff, 0xffff}), 1.0), + + // Contract call operations — much higher weights for delegation testing + // Gas masks (0x4fffff ~5M) prevent uint64 overflow on the gas argument. + // CALL/CALLCODE take 7 stack inputs (gas,addr,value,argsOff,argsLen,retOff,retLen). + // Value mask 0xffff allows small value transfers; most will succeed since + // the transaction itself sends ETH. "Insufficient balance" still occurs + // when contracts have zero balance (25% of txs send zero value). + op("CALL", 0xf1, 7, 1, 100, san(s(0xf1), []uint64{0x4fffff, 0xffff, 0xffff, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 5.0), + op("CALLCODE", 0xf2, 7, 1, 100, san(s(0xf2), []uint64{0x4fffff, 0xffff, 0xffff, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 2.0), + op("RETURN", 0xf3, 2, 0, 0, san(s(0xf3), []uint64{0x2ffff, 0xffff}), 0.1), + // DELEGATECALL/STATICCALL take 6 stack inputs (gas,addr,argsOff,argsLen,retOff,retLen). + op("DELEGATECALL", 0xf4, 6, 1, 100, san(s(0xf4), []uint64{0x4fffff, 0, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 5.0), + // Lower STATICCALL weight: callees often write (SSTORE/TSTORE/LOG), + // causing write-protection errors. Keep it available but less frequent. + op("STATICCALL", 0xfa, 6, 1, 100, san(s(0xfa), []uint64{0x4fffff, 0, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 2.0), + op("REVERT", 0xfd, 2, 0, 0, san(s(0xfd), []uint64{0x2ffff, 0xffff}), 0.1), + op("SELFDESTRUCT", 0xff, 1, 0, 5000, s(0xff), 1.1), + + // CREATE/CREATE2 — slightly higher for delegation context. + // Small value mask (0xff) allows occasional value transfers. + op("CREATE", 0xf0, 3, 1, 32000, san(s(0xf0), []uint64{0xff, 0x2ffff, 0xffff}), 2.5), + op("CREATE2", 0xf5, 4, 1, 32000, san(s(0xf5), []uint64{0xff, 0x2ffff, 0xffff}), 2.5), + } +} diff --git a/scenarios/evm-fuzz/opcode_generator.go b/scenarios/evm-fuzz/opcode_generator.go index 84edc6e..c8e901a 100644 --- a/scenarios/evm-fuzz/opcode_generator.go +++ b/scenarios/evm-fuzz/opcode_generator.go @@ -21,8 +21,8 @@ type DeterministicRNG struct { counter uint64 } -// parseHexSeed parses a hex string seed, supporting 0x prefix -func parseHexSeed(seed string) ([]byte, error) { +// ParseHexSeed parses a hex string seed, supporting 0x prefix +func ParseHexSeed(seed string) ([]byte, error) { // Remove 0x prefix if present seed = strings.TrimPrefix(seed, "0x") @@ -41,7 +41,7 @@ func NewDeterministicRNGWithSeed(txID uint64, baseSeed string) *DeterministicRNG // If custom seed provided, use it; otherwise use a fixed fallback if baseSeed != "" { // Expect hex seed, decode it - seedBytes, err := parseHexSeed(baseSeed) + seedBytes, err := ParseHexSeed(baseSeed) if err != nil { // Fallback to seed as bytes if not valid hex h.Write([]byte(baseSeed)) @@ -156,11 +156,11 @@ type OpcodeGenerator struct { // initializeOpcodes sets up the opcode definitions with sanitization func (g *OpcodeGenerator) initializeOpcodes() { // Start with base opcode definitions from opcodes.go - opcodes := getBaseOpcodeDefinitions() + opcodes := GetBaseOpcodeDefinitions() // Add DUP1-DUP16 and SWAP1-SWAP16 from opcodes.go - opcodes = append(opcodes, getDupOpcodeDefinitions()...) - opcodes = append(opcodes, getSwapOpcodeDefinitions()...) + opcodes = append(opcodes, GetDupOpcodeDefinitions()...) + opcodes = append(opcodes, GetSwapOpcodeDefinitions()...) // Add generator-specific opcodes that need generator reference generatorOpcodes := []*OpcodeInfo{ @@ -926,7 +926,7 @@ func (g *OpcodeGenerator) pushSeedAndTxID() { seedBytes := make([]byte, 32) if g.baseSeed != "" { // Parse hex seed and use exact bytes - if parsedSeed, err := parseHexSeed(g.baseSeed); err == nil { + if parsedSeed, err := ParseHexSeed(g.baseSeed); err == nil { // If seed is longer than 32 bytes, take first 32 if len(parsedSeed) >= 32 { copy(seedBytes, parsedSeed[:32]) diff --git a/scenarios/evm-fuzz/opcodes.go b/scenarios/evm-fuzz/opcodes.go index 521cc1c..3a50796 100644 --- a/scenarios/evm-fuzz/opcodes.go +++ b/scenarios/evm-fuzz/opcodes.go @@ -13,14 +13,14 @@ type OpcodeInfo struct { Probability float64 // Relative probability weight for selection (1.0 = normal) } -// simpleOpcode creates a template function for a single-byte opcode -func simpleOpcode(opcode byte) func() []byte { +// SimpleOpcode creates a template function for a single-byte opcode +func SimpleOpcode(opcode byte) func() []byte { return func() []byte { return []byte{opcode} } } -// sanitizeInput is a generic sanitization wrapper that applies masks to stack positions +// SanitizeInput is a generic sanitization wrapper that applies masks to stack positions // before executing base template. masks[i] = mask for stack position i (0 = no sanitization) -func sanitizeInput(baseTemplate func() []byte, masks []uint64) func() []byte { +func SanitizeInput(baseTemplate func() []byte, masks []uint64) func() []byte { return func() []byte { var bytecode []byte @@ -67,117 +67,117 @@ func sanitizeInput(baseTemplate func() []byte, masks []uint64) func() []byte { } } -// getBaseOpcodeDefinitions returns the static opcode definitions that don't require generator reference -func getBaseOpcodeDefinitions() []*OpcodeInfo { +// GetBaseOpcodeDefinitions returns the static opcode definitions that don't require generator reference +func GetBaseOpcodeDefinitions() []*OpcodeInfo { return []*OpcodeInfo{ - {"STOP", 0x00, 0, 0, 0, simpleOpcode(0x00), 0}, + {"STOP", 0x00, 0, 0, 0, SimpleOpcode(0x00), 0}, // Arithmetic operations - {"ADD", 0x01, 2, 1, 3, simpleOpcode(0x01), 1.0}, - {"MUL", 0x02, 2, 1, 5, simpleOpcode(0x02), 1.0}, - {"SUB", 0x03, 2, 1, 3, simpleOpcode(0x03), 1.0}, - {"DIV", 0x04, 2, 1, 5, simpleOpcode(0x04), 1.0}, - {"SDIV", 0x05, 2, 1, 5, simpleOpcode(0x05), 1.0}, - {"MOD", 0x06, 2, 1, 5, simpleOpcode(0x06), 1.0}, - {"SMOD", 0x07, 2, 1, 5, simpleOpcode(0x07), 1.0}, - {"ADDMOD", 0x08, 3, 1, 8, simpleOpcode(0x08), 1.0}, - {"MULMOD", 0x09, 3, 1, 8, simpleOpcode(0x09), 1.0}, - {"EXP", 0x0a, 2, 1, 10, simpleOpcode(0x0a), 1.0}, - {"SIGNEXTEND", 0x0b, 2, 1, 5, simpleOpcode(0x0b), 1.0}, + {"ADD", 0x01, 2, 1, 3, SimpleOpcode(0x01), 1.0}, + {"MUL", 0x02, 2, 1, 5, SimpleOpcode(0x02), 1.0}, + {"SUB", 0x03, 2, 1, 3, SimpleOpcode(0x03), 1.0}, + {"DIV", 0x04, 2, 1, 5, SimpleOpcode(0x04), 1.0}, + {"SDIV", 0x05, 2, 1, 5, SimpleOpcode(0x05), 1.0}, + {"MOD", 0x06, 2, 1, 5, SimpleOpcode(0x06), 1.0}, + {"SMOD", 0x07, 2, 1, 5, SimpleOpcode(0x07), 1.0}, + {"ADDMOD", 0x08, 3, 1, 8, SimpleOpcode(0x08), 1.0}, + {"MULMOD", 0x09, 3, 1, 8, SimpleOpcode(0x09), 1.0}, + {"EXP", 0x0a, 2, 1, 10, SimpleOpcode(0x0a), 1.0}, + {"SIGNEXTEND", 0x0b, 2, 1, 5, SimpleOpcode(0x0b), 1.0}, // Comparison operations - {"LT", 0x10, 2, 1, 3, simpleOpcode(0x10), 1.0}, - {"GT", 0x11, 2, 1, 3, simpleOpcode(0x11), 1.0}, - {"SLT", 0x12, 2, 1, 3, simpleOpcode(0x12), 1.0}, - {"SGT", 0x13, 2, 1, 3, simpleOpcode(0x13), 1.0}, - {"EQ", 0x14, 2, 1, 3, simpleOpcode(0x14), 1.0}, - {"ISZERO", 0x15, 1, 1, 3, simpleOpcode(0x15), 1.0}, - {"AND", 0x16, 2, 1, 3, simpleOpcode(0x16), 1.0}, - {"OR", 0x17, 2, 1, 3, simpleOpcode(0x17), 1.0}, - {"XOR", 0x18, 2, 1, 3, simpleOpcode(0x18), 1.0}, - {"NOT", 0x19, 1, 1, 3, simpleOpcode(0x19), 1.0}, - {"BYTE", 0x1a, 2, 1, 3, sanitizeInput(simpleOpcode(0x1a), []uint64{0x3f}), 1.0}, - {"SHL", 0x1b, 2, 1, 3, simpleOpcode(0x1b), 1.0}, - {"SHR", 0x1c, 2, 1, 3, simpleOpcode(0x1c), 1.0}, - {"SAR", 0x1d, 2, 1, 3, simpleOpcode(0x1d), 1.0}, - {"CLZ", 0x1e, 1, 1, 3, simpleOpcode(0x1e), 1.5}, + {"LT", 0x10, 2, 1, 3, SimpleOpcode(0x10), 1.0}, + {"GT", 0x11, 2, 1, 3, SimpleOpcode(0x11), 1.0}, + {"SLT", 0x12, 2, 1, 3, SimpleOpcode(0x12), 1.0}, + {"SGT", 0x13, 2, 1, 3, SimpleOpcode(0x13), 1.0}, + {"EQ", 0x14, 2, 1, 3, SimpleOpcode(0x14), 1.0}, + {"ISZERO", 0x15, 1, 1, 3, SimpleOpcode(0x15), 1.0}, + {"AND", 0x16, 2, 1, 3, SimpleOpcode(0x16), 1.0}, + {"OR", 0x17, 2, 1, 3, SimpleOpcode(0x17), 1.0}, + {"XOR", 0x18, 2, 1, 3, SimpleOpcode(0x18), 1.0}, + {"NOT", 0x19, 1, 1, 3, SimpleOpcode(0x19), 1.0}, + {"BYTE", 0x1a, 2, 1, 3, SanitizeInput(SimpleOpcode(0x1a), []uint64{0x3f}), 1.0}, + {"SHL", 0x1b, 2, 1, 3, SimpleOpcode(0x1b), 1.0}, + {"SHR", 0x1c, 2, 1, 3, SimpleOpcode(0x1c), 1.0}, + {"SAR", 0x1d, 2, 1, 3, SimpleOpcode(0x1d), 1.0}, + {"CLZ", 0x1e, 1, 1, 3, SimpleOpcode(0x1e), 1.5}, // Crypto operations - {"KECCAK256", 0x20, 2, 1, 30, sanitizeInput(simpleOpcode(0x20), []uint64{0x2ffff, 0xffff}), 1.5}, + {"KECCAK256", 0x20, 2, 1, 30, SanitizeInput(SimpleOpcode(0x20), []uint64{0x2ffff, 0xffff}), 1.5}, // Environmental information - {"ADDRESS", 0x30, 0, 1, 2, simpleOpcode(0x30), 2.0}, - {"BALANCE", 0x31, 1, 1, 100, simpleOpcode(0x31), 2.2}, - {"ORIGIN", 0x32, 0, 1, 2, simpleOpcode(0x32), 2.0}, - {"CALLER", 0x33, 0, 1, 2, simpleOpcode(0x33), 2.0}, - {"CALLVALUE", 0x34, 0, 1, 2, simpleOpcode(0x34), 2.0}, - {"CALLDATALOAD", 0x35, 1, 1, 3, simpleOpcode(0x35), 2.0}, - {"CALLDATASIZE", 0x36, 0, 1, 2, simpleOpcode(0x36), 2.0}, - {"CALLDATACOPY", 0x37, 3, 0, 3, sanitizeInput(simpleOpcode(0x37), []uint64{0x2ffff, 0x2ffff, 0xffff}), 2.0}, - {"CODESIZE", 0x38, 0, 1, 2, simpleOpcode(0x38), 2.0}, - {"CODECOPY", 0x39, 3, 0, 3, sanitizeInput(simpleOpcode(0x39), []uint64{0x2ffff, 0x2ffff, 0xffff}), 2.0}, - {"GASPRICE", 0x3a, 0, 1, 2, simpleOpcode(0x3a), 2.0}, - {"EXTCODESIZE", 0x3b, 1, 1, 100, simpleOpcode(0x3b), 2.0}, - {"EXTCODECOPY", 0x3c, 4, 0, 100, sanitizeInput(simpleOpcode(0x3c), []uint64{0, 0x2ffff, 0x2ffff, 0xffff}), 2.0}, - {"RETURNDATASIZE", 0x3d, 0, 1, 2, simpleOpcode(0x3d), 2.0}, - {"RETURNDATACOPY", 0x3e, 3, 0, 3, sanitizeInput(simpleOpcode(0x3e), []uint64{0x2ffff, 0x2ffff, 0xffff}), 2.0}, - {"EXTCODEHASH", 0x3f, 1, 1, 100, simpleOpcode(0x3f), 2.0}, + {"ADDRESS", 0x30, 0, 1, 2, SimpleOpcode(0x30), 2.0}, + {"BALANCE", 0x31, 1, 1, 100, SimpleOpcode(0x31), 2.2}, + {"ORIGIN", 0x32, 0, 1, 2, SimpleOpcode(0x32), 2.0}, + {"CALLER", 0x33, 0, 1, 2, SimpleOpcode(0x33), 2.0}, + {"CALLVALUE", 0x34, 0, 1, 2, SimpleOpcode(0x34), 2.0}, + {"CALLDATALOAD", 0x35, 1, 1, 3, SimpleOpcode(0x35), 2.0}, + {"CALLDATASIZE", 0x36, 0, 1, 2, SimpleOpcode(0x36), 2.0}, + {"CALLDATACOPY", 0x37, 3, 0, 3, SanitizeInput(SimpleOpcode(0x37), []uint64{0x2ffff, 0x2ffff, 0xffff}), 2.0}, + {"CODESIZE", 0x38, 0, 1, 2, SimpleOpcode(0x38), 2.0}, + {"CODECOPY", 0x39, 3, 0, 3, SanitizeInput(SimpleOpcode(0x39), []uint64{0x2ffff, 0x2ffff, 0xffff}), 2.0}, + {"GASPRICE", 0x3a, 0, 1, 2, SimpleOpcode(0x3a), 2.0}, + {"EXTCODESIZE", 0x3b, 1, 1, 100, SimpleOpcode(0x3b), 2.0}, + {"EXTCODECOPY", 0x3c, 4, 0, 100, SanitizeInput(SimpleOpcode(0x3c), []uint64{0, 0x2ffff, 0x2ffff, 0xffff}), 2.0}, + {"RETURNDATASIZE", 0x3d, 0, 1, 2, SimpleOpcode(0x3d), 2.0}, + {"RETURNDATACOPY", 0x3e, 3, 0, 3, SanitizeInput(SimpleOpcode(0x3e), []uint64{0x2ffff, 0x2ffff, 0xffff}), 2.0}, + {"EXTCODEHASH", 0x3f, 1, 1, 100, SimpleOpcode(0x3f), 2.0}, // Block information - {"BLOCKHASH", 0x40, 1, 1, 20, simpleOpcode(0x40), 2.0}, - {"COINBASE", 0x41, 0, 1, 2, simpleOpcode(0x41), 2.0}, - {"TIMESTAMP", 0x42, 0, 1, 2, simpleOpcode(0x42), 2.0}, - {"NUMBER", 0x43, 0, 1, 2, simpleOpcode(0x43), 2.0}, - {"DIFFICULTY", 0x44, 0, 1, 2, simpleOpcode(0x44), 2.0}, - {"GASLIMIT", 0x45, 0, 1, 2, simpleOpcode(0x45), 2.0}, - {"CHAINID", 0x46, 0, 1, 2, simpleOpcode(0x46), 2.5}, - {"SELFBALANCE", 0x47, 0, 1, 5, simpleOpcode(0x47), 2.5}, - {"BASEFEE", 0x48, 0, 1, 2, simpleOpcode(0x48), 2.5}, - {"BLOBHASH", 0x49, 1, 1, 3, simpleOpcode(0x49), 2.0}, - {"BLOBBASEFEE", 0x4a, 0, 1, 2, simpleOpcode(0x4a), 2.0}, - {"SLOTNUM", 0x4b, 0, 1, 2, simpleOpcode(0x4b), 2.0}, + {"BLOCKHASH", 0x40, 1, 1, 20, SimpleOpcode(0x40), 2.0}, + {"COINBASE", 0x41, 0, 1, 2, SimpleOpcode(0x41), 2.0}, + {"TIMESTAMP", 0x42, 0, 1, 2, SimpleOpcode(0x42), 2.0}, + {"NUMBER", 0x43, 0, 1, 2, SimpleOpcode(0x43), 2.0}, + {"DIFFICULTY", 0x44, 0, 1, 2, SimpleOpcode(0x44), 2.0}, + {"GASLIMIT", 0x45, 0, 1, 2, SimpleOpcode(0x45), 2.0}, + {"CHAINID", 0x46, 0, 1, 2, SimpleOpcode(0x46), 2.5}, + {"SELFBALANCE", 0x47, 0, 1, 5, SimpleOpcode(0x47), 2.5}, + {"BASEFEE", 0x48, 0, 1, 2, SimpleOpcode(0x48), 2.5}, + {"BLOBHASH", 0x49, 1, 1, 3, SimpleOpcode(0x49), 2.0}, + {"BLOBBASEFEE", 0x4a, 0, 1, 2, SimpleOpcode(0x4a), 2.0}, + {"SLOTNUM", 0x4b, 0, 1, 2, SimpleOpcode(0x4b), 2.0}, // Stack operations with memory/storage sanitization - {"POP", 0x50, 1, 0, 2, simpleOpcode(0x50), 1.0}, - {"MLOAD", 0x51, 1, 1, 3, sanitizeInput(simpleOpcode(0x51), []uint64{0x2ffff}), 1.0}, - {"MSTORE", 0x52, 2, 0, 3, sanitizeInput(simpleOpcode(0x52), []uint64{0x2ffff}), 1.0}, - {"MSTORE8", 0x53, 2, 0, 3, sanitizeInput(simpleOpcode(0x53), []uint64{0x2ffff}), 1.0}, - {"SLOAD", 0x54, 1, 1, 100, sanitizeInput(simpleOpcode(0x54), []uint64{0x2ffff}), 1.2}, - {"SSTORE", 0x55, 2, 0, 100, sanitizeInput(simpleOpcode(0x55), []uint64{0x2ffff}), 1.2}, + {"POP", 0x50, 1, 0, 2, SimpleOpcode(0x50), 1.0}, + {"MLOAD", 0x51, 1, 1, 3, SanitizeInput(SimpleOpcode(0x51), []uint64{0x2ffff}), 1.0}, + {"MSTORE", 0x52, 2, 0, 3, SanitizeInput(SimpleOpcode(0x52), []uint64{0x2ffff}), 1.0}, + {"MSTORE8", 0x53, 2, 0, 3, SanitizeInput(SimpleOpcode(0x53), []uint64{0x2ffff}), 1.0}, + {"SLOAD", 0x54, 1, 1, 100, SanitizeInput(SimpleOpcode(0x54), []uint64{0x2ffff}), 1.2}, + {"SSTORE", 0x55, 2, 0, 100, SanitizeInput(SimpleOpcode(0x55), []uint64{0x2ffff}), 1.2}, // JUMP and JUMPI are added by generator (need generator reference) - {"PC", 0x58, 0, 1, 2, simpleOpcode(0x58), 1.0}, - {"MSIZE", 0x59, 0, 1, 2, simpleOpcode(0x59), 1.0}, - {"GAS", 0x5a, 0, 1, 2, simpleOpcode(0x5a), 1.0}, - {"JUMPDEST", 0x5b, 0, 0, 1, simpleOpcode(0x5b), 1.0}, - {"TLOAD", 0x5c, 1, 1, 100, sanitizeInput(simpleOpcode(0x5c), []uint64{0x2ffff}), 2.0}, - {"TSTORE", 0x5d, 2, 0, 100, sanitizeInput(simpleOpcode(0x5d), []uint64{0x2ffff}), 2.0}, - {"MCOPY", 0x5e, 3, 0, 3, sanitizeInput(simpleOpcode(0x5e), []uint64{0x2ffff, 0x2ffff, 0xffff}), 2.0}, + {"PC", 0x58, 0, 1, 2, SimpleOpcode(0x58), 1.0}, + {"MSIZE", 0x59, 0, 1, 2, SimpleOpcode(0x59), 1.0}, + {"GAS", 0x5a, 0, 1, 2, SimpleOpcode(0x5a), 1.0}, + {"JUMPDEST", 0x5b, 0, 0, 1, SimpleOpcode(0x5b), 1.0}, + {"TLOAD", 0x5c, 1, 1, 100, SanitizeInput(SimpleOpcode(0x5c), []uint64{0x2ffff}), 2.0}, + {"TSTORE", 0x5d, 2, 0, 100, SanitizeInput(SimpleOpcode(0x5d), []uint64{0x2ffff}), 2.0}, + {"MCOPY", 0x5e, 3, 0, 3, SanitizeInput(SimpleOpcode(0x5e), []uint64{0x2ffff, 0x2ffff, 0xffff}), 2.0}, // Push operations - {"PUSH0", 0x5f, 0, 1, 2, simpleOpcode(0x5f), 1.0}, + {"PUSH0", 0x5f, 0, 1, 2, SimpleOpcode(0x5f), 1.0}, // PUSH1-PUSH32 are added dynamically by generator // Log operations - {"LOG0", 0xa0, 2, 0, 375, sanitizeInput(simpleOpcode(0xa0), []uint64{0x2ffff, 0xffff}), 1.0}, - {"LOG1", 0xa1, 3, 0, 750, sanitizeInput(simpleOpcode(0xa1), []uint64{0x2ffff, 0xffff}), 1.0}, - {"LOG2", 0xa2, 4, 0, 1125, sanitizeInput(simpleOpcode(0xa2), []uint64{0x2ffff, 0xffff}), 1.0}, - {"LOG3", 0xa3, 5, 0, 1500, sanitizeInput(simpleOpcode(0xa3), []uint64{0x2ffff, 0xffff}), 1.0}, - {"LOG4", 0xa4, 6, 0, 1875, sanitizeInput(simpleOpcode(0xa4), []uint64{0x2ffff, 0xffff}), 1.0}, + {"LOG0", 0xa0, 2, 0, 375, SanitizeInput(SimpleOpcode(0xa0), []uint64{0x2ffff, 0xffff}), 1.0}, + {"LOG1", 0xa1, 3, 0, 750, SanitizeInput(SimpleOpcode(0xa1), []uint64{0x2ffff, 0xffff}), 1.0}, + {"LOG2", 0xa2, 4, 0, 1125, SanitizeInput(SimpleOpcode(0xa2), []uint64{0x2ffff, 0xffff}), 1.0}, + {"LOG3", 0xa3, 5, 0, 1500, SanitizeInput(SimpleOpcode(0xa3), []uint64{0x2ffff, 0xffff}), 1.0}, + {"LOG4", 0xa4, 6, 0, 1875, SanitizeInput(SimpleOpcode(0xa4), []uint64{0x2ffff, 0xffff}), 1.0}, // Contract operations (static ones) - {"CALL", 0xf1, 6, 1, 100, sanitizeInput(simpleOpcode(0xf1), []uint64{0, 0xffff, 0, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 1.3}, - {"CALLCODE", 0xf2, 6, 1, 100, sanitizeInput(simpleOpcode(0xf2), []uint64{0, 0xffff, 0, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 1.0}, - {"RETURN", 0xf3, 2, 0, 0, sanitizeInput(simpleOpcode(0xf3), []uint64{0x2ffff, 0xffff}), 0.1}, - {"DELEGATECALL", 0xf4, 6, 1, 100, sanitizeInput(simpleOpcode(0xf4), []uint64{0, 0, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 1.3}, + {"CALL", 0xf1, 6, 1, 100, SanitizeInput(SimpleOpcode(0xf1), []uint64{0, 0xffff, 0, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 1.3}, + {"CALLCODE", 0xf2, 6, 1, 100, SanitizeInput(SimpleOpcode(0xf2), []uint64{0, 0xffff, 0, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 1.0}, + {"RETURN", 0xf3, 2, 0, 0, SanitizeInput(SimpleOpcode(0xf3), []uint64{0x2ffff, 0xffff}), 0.1}, + {"DELEGATECALL", 0xf4, 6, 1, 100, SanitizeInput(SimpleOpcode(0xf4), []uint64{0, 0, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 1.3}, // CREATE and CREATE2 are added by generator (need generator reference) - {"STATICCALL", 0xfa, 6, 1, 100, sanitizeInput(simpleOpcode(0xfa), []uint64{0, 0, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 1.3}, - {"REVERT", 0xfd, 2, 0, 0, sanitizeInput(simpleOpcode(0xfd), []uint64{0x2ffff, 0xffff}), 0.1}, - {"SELFDESTRUCT", 0xff, 1, 0, 5000, simpleOpcode(0xff), 1.1}, + {"STATICCALL", 0xfa, 6, 1, 100, SanitizeInput(SimpleOpcode(0xfa), []uint64{0, 0, 0x2ffff, 0xffff, 0x2ffff, 0xffff}), 1.3}, + {"REVERT", 0xfd, 2, 0, 0, SanitizeInput(SimpleOpcode(0xfd), []uint64{0x2ffff, 0xffff}), 0.1}, + {"SELFDESTRUCT", 0xff, 1, 0, 5000, SimpleOpcode(0xff), 1.1}, } } -// getDupOpcodeDefinitions returns DUP1-DUP16 opcode definitions -func getDupOpcodeDefinitions() []*OpcodeInfo { +// GetDupOpcodeDefinitions returns DUP1-DUP16 opcode definitions +func GetDupOpcodeDefinitions() []*OpcodeInfo { opcodes := make([]*OpcodeInfo, 0, 16) for i := 1; i <= 16; i++ { dupOpcode := uint16(0x7f + i) @@ -188,15 +188,15 @@ func getDupOpcodeDefinitions() []*OpcodeInfo { StackInput: dupDepth, StackOutput: dupDepth + 1, GasCost: 3, - Template: simpleOpcode(byte(dupOpcode)), + Template: SimpleOpcode(byte(dupOpcode)), Probability: 1.0, }) } return opcodes } -// getSwapOpcodeDefinitions returns SWAP1-SWAP16 opcode definitions -func getSwapOpcodeDefinitions() []*OpcodeInfo { +// GetSwapOpcodeDefinitions returns SWAP1-SWAP16 opcode definitions +func GetSwapOpcodeDefinitions() []*OpcodeInfo { opcodes := make([]*OpcodeInfo, 0, 16) for i := 1; i <= 16; i++ { swapOpcode := uint16(0x8f + i) @@ -207,7 +207,7 @@ func getSwapOpcodeDefinitions() []*OpcodeInfo { StackInput: swapDepth, StackOutput: swapDepth, GasCost: 3, - Template: simpleOpcode(byte(swapOpcode)), + Template: SimpleOpcode(byte(swapOpcode)), Probability: 1.0, }) } diff --git a/scenarios/scenarios.go b/scenarios/scenarios.go index 5a05488..d0d64b0 100644 --- a/scenarios/scenarios.go +++ b/scenarios/scenarios.go @@ -14,6 +14,7 @@ import ( blobreplacements "github.com/ethpandaops/spamoor/scenarios/blob-replacements" "github.com/ethpandaops/spamoor/scenarios/blobs" "github.com/ethpandaops/spamoor/scenarios/calltx" + calltxfuzz "github.com/ethpandaops/spamoor/scenarios/calltx-fuzz" deploydestruct "github.com/ethpandaops/spamoor/scenarios/deploy-destruct" "github.com/ethpandaops/spamoor/scenarios/deploytx" "github.com/ethpandaops/spamoor/scenarios/eoatx" @@ -53,6 +54,7 @@ var nativeScenarioCategories = []*scenario.Category{ &erc20tx.ScenarioDescriptor, &erc721tx.ScenarioDescriptor, &erc1155tx.ScenarioDescriptor, + &calltxfuzz.ScenarioDescriptor, &evmfuzz.ScenarioDescriptor, &gasburnertx.ScenarioDescriptor, &setcodetx.ScenarioDescriptor,