From a1c8594c40db4d9080c986f4b69c1c01cdea785c Mon Sep 17 00:00:00 2001 From: Kolby Moroz Liebl <31669092+KolbyML@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:21:59 -0700 Subject: [PATCH 1/7] Implement Stylus Contract limit increase --- arbcompress/native.go | 2 +- arbos/arbosState/arbosstate.go | 5 +- arbos/programs/native.go | 8 +- arbos/programs/params.go | 47 +- arbos/programs/programs.go | 101 ++- arbos/programs/wasm.go | 2 +- arbos/programs/wasmstorehelper.go | 2 +- ...yml-increase-stylus-smart-contract-size.md | 2 + contracts-local/src/precompiles | 2 +- go-ethereum | 2 +- precompiles/ArbOwner.go | 9 + precompiles/ArbOwnerPublic.go | 8 + precompiles/precompile.go | 2 + precompiles/precompile_test.go | 2 +- system_tests/archival_path_scheme_test.go | 2 - system_tests/common_test.go | 30 +- .../stylus_contract_limit_increase_test.go | 722 ++++++++++++++++++ 17 files changed, 900 insertions(+), 48 deletions(-) create mode 100644 changelog/kolbyml-increase-stylus-smart-contract-size.md create mode 100644 system_tests/stylus_contract_limit_increase_test.go diff --git a/arbcompress/native.go b/arbcompress/native.go index 6538bfa1cc..0cfa878565 100644 --- a/arbcompress/native.go +++ b/arbcompress/native.go @@ -41,7 +41,7 @@ func Compress(input []byte, level uint32, dictionary Dictionary) ([]byte, error) status := C.brotli_compress(inbuf, outbuf, C.Dictionary(dictionary), u32(level)) if status != C.BrotliStatus_Success { - return nil, fmt.Errorf("failed decompression: %d", status) + return nil, fmt.Errorf("failed compression: %d", status) } output = output[:*outbuf.len] return output, nil diff --git a/arbos/arbosState/arbosstate.go b/arbos/arbosState/arbosstate.go index fbe5697551..0a6d6c361a 100644 --- a/arbos/arbosState/arbosstate.go +++ b/arbos/arbosState/arbosstate.go @@ -446,7 +446,10 @@ func (state *ArbosState) UpgradeArbosVersion( // these versions are left to Orbit chains for custom upgrades. case params.ArbosVersion_60: - // no change state needed + p, err := state.Programs().Params() + ensure(err) + ensure(p.UpgradeToArbosVersion(nextArbosVersion)) + ensure(p.Save()) default: return fmt.Errorf( "the chain is upgrading to unsupported ArbOS version %v, %w", diff --git a/arbos/programs/native.go b/arbos/programs/native.go index 7c6ce72526..9af7f65b4a 100644 --- a/arbos/programs/native.go +++ b/arbos/programs/native.go @@ -262,7 +262,7 @@ func activateProgramInternal( } // getCompiledProgram gets compiled wasm for all targets and recompiles missing ones. -func getCompiledProgram(statedb vm.StateDB, moduleHash common.Hash, addressForLogging common.Address, code []byte, codehash common.Hash, maxWasmSize uint32, pagelimit uint16, time uint64, debugMode bool, program Program, runCtx *core.MessageRunContext) (map[rawdb.WasmTarget][]byte, error) { +func getCompiledProgram(statedb vm.StateDB, moduleHash common.Hash, addressForLogging common.Address, code []byte, codehash common.Hash, params *StylusParams, time uint64, debugMode bool, program Program, runCtx *core.MessageRunContext) (map[rawdb.WasmTarget][]byte, error) { targets := runCtx.WasmTargets() // even though we need only asm for local target, make sure that all configured targets are available as they are needed during multi-target recording of a program call asmMap, missingTargets, err := statedb.ActivatedAsmMap(targets, moduleHash) @@ -277,7 +277,7 @@ func getCompiledProgram(statedb vm.StateDB, moduleHash common.Hash, addressForLo } // addressForLogging may be empty or may not correspond to the code, so we need to be careful to use the code passed in separately - wasm, err := getWasmFromContractCode(code, maxWasmSize) + wasm, err := getWasmFromContractCode(statedb, code, params, false) if err != nil { log.Error("Failed to reactivate program: getWasm", "address", addressForLogging, "expected moduleHash", moduleHash, "err", err) return nil, fmt.Errorf("failed to reactivate program address: %v err: %w", addressForLogging, err) @@ -290,7 +290,7 @@ func getCompiledProgram(statedb vm.StateDB, moduleHash common.Hash, addressForLo // we know program is activated, so it must be in correct version and not use too much memory moduleActivationMandatory := false // compile only missing targets - info, newlyBuilt, err := activateProgramInternal(addressForLogging, codehash, wasm, pagelimit, program.version, zeroArbosVersion, debugMode, &zeroGas, missingTargets, moduleActivationMandatory) + info, newlyBuilt, err := activateProgramInternal(addressForLogging, codehash, wasm, params.PageLimit, program.version, zeroArbosVersion, debugMode, &zeroGas, missingTargets, moduleActivationMandatory) if err != nil { log.Error("failed to reactivate program", "address", addressForLogging, "expected moduleHash", moduleHash, "err", err) return nil, fmt.Errorf("failed to reactivate program address: %v err: %w", addressForLogging, err) @@ -399,7 +399,7 @@ func handleReqImpl(apiId usize, req_type u32, data *rustSlice, costPtr *u64, out func cacheProgram(db vm.StateDB, module common.Hash, program Program, addressForLogging common.Address, code []byte, codehash common.Hash, params *StylusParams, debug bool, time uint64, runCtx *core.MessageRunContext) { if runCtx.IsCommitMode() { // address is only used for logging - asmMap, err := getCompiledProgram(db, module, addressForLogging, code, codehash, params.MaxWasmSize, params.PageLimit, time, debug, program, runCtx) + asmMap, err := getCompiledProgram(db, module, addressForLogging, code, codehash, params, time, debug, program, runCtx) var ok bool var localAsm []byte if asmMap != nil { diff --git a/arbos/programs/params.go b/arbos/programs/params.go index 533b119e4d..2fe638f791 100644 --- a/arbos/programs/params.go +++ b/arbos/programs/params.go @@ -24,13 +24,14 @@ const InitialPageGas = 1000 // linear cost per allocation. const initialPageRamp = 620674314 // targets 8MB costing 32 million gas, minus the linear term. const initialPageLimit = 128 // reject wasms with memories larger than 8MB. const initialInkPrice = 10000 // 1 evm gas buys 10k ink. -const initialMinInitGas = 72 // charge 72 * 128 = 9216 gas. -const initialMinCachedGas = 11 // charge 11 * 32 = 352 gas. -const initialInitCostScalar = 50 // scale costs 1:1 (100%) -const initialCachedCostScalar = 50 // scale costs 1:1 (100%) -const initialExpiryDays = 365 // deactivate after 1 year. -const initialKeepaliveDays = 31 // wait a month before allowing reactivation. -const initialRecentCacheSize = 32 // cache the 32 most recent programs. +const initialMaxFragmentCount = 2 +const initialMinInitGas = 72 // charge 72 * 128 = 9216 gas. +const initialMinCachedGas = 11 // charge 11 * 32 = 352 gas. +const initialInitCostScalar = 50 // scale costs 1:1 (100%) +const initialCachedCostScalar = 50 // scale costs 1:1 (100%) +const initialExpiryDays = 365 // deactivate after 1 year. +const initialKeepaliveDays = 31 // wait a month before allowing reactivation. +const initialRecentCacheSize = 32 // cache the 32 most recent programs. const v2MinInitGas = 69 // charge 69 * 128 = 8832 gas (minCachedGas will also be charged in v2). @@ -60,6 +61,7 @@ type StylusParams struct { KeepaliveDays uint16 BlockCacheSize uint16 MaxWasmSize uint32 + MaxFragmentCount uint16 } // Provides a view of the Stylus parameters. Call Save() to persist. @@ -110,6 +112,11 @@ func (p Programs) Params() (*StylusParams, error) { } else { stylusParams.MaxWasmSize = initialMaxWasmSize } + if p.ArbosVersion >= params.ArbosVersion_StylusContractLimit { + stylusParams.MaxFragmentCount = arbmath.BytesToUint16(take(2)) + } else { + stylusParams.MaxFragmentCount = 0 + } return stylusParams, nil } @@ -139,6 +146,9 @@ func (p *StylusParams) Save() error { if p.arbosVersion >= params.ArbosVersion_40 { data = append(data, arbmath.Uint32ToBytes(p.MaxWasmSize)...) } + if p.arbosVersion >= params.ArbosVersion_StylusContractLimit { + data = append(data, arbmath.Uint16ToBytes(p.MaxFragmentCount)...) + } slot := uint64(0) for len(data) != 0 { @@ -171,23 +181,24 @@ func (p *StylusParams) UpgradeToVersion(version uint16) error { } func (p *StylusParams) UpgradeToArbosVersion(newArbosVersion uint64) error { - if newArbosVersion == params.ArbosVersion_50 { - if p.arbosVersion >= params.ArbosVersion_50 { - return fmt.Errorf("unexpected arbosVersion upgrade to %d from %d", newArbosVersion, p.arbosVersion) - } + if p.arbosVersion >= newArbosVersion { + return fmt.Errorf("unexpected arbosVersion upgrade to %d from %d", newArbosVersion, p.arbosVersion) + } + + switch newArbosVersion { + case params.ArbosVersion_50: if p.MaxStackDepth > arbOS50MaxWasmSize { p.MaxStackDepth = arbOS50MaxWasmSize } - } - if newArbosVersion == params.ArbosVersion_40 { - if p.arbosVersion >= params.ArbosVersion_40 { - return fmt.Errorf("unexpected arbosVersion upgrade to %d from %d", newArbosVersion, p.arbosVersion) - } + case params.ArbosVersion_40: if p.Version != 2 { return fmt.Errorf("unexpected arbosVersion upgrade to %d while stylus version %d", newArbosVersion, p.Version) } p.MaxWasmSize = initialMaxWasmSize + case params.ArbosVersion_StylusContractLimit: + p.MaxFragmentCount = initialMaxFragmentCount } + p.arbosVersion = newArbosVersion return nil } @@ -214,5 +225,9 @@ func initStylusParams(arbosVersion uint64, sto *storage.Storage) { if arbosVersion >= params.ArbosVersion_40 { stylusParams.MaxWasmSize = initialMaxWasmSize } + if arbosVersion >= params.ArbosVersion_StylusContractLimit { + stylusParams.MaxFragmentCount = initialMaxFragmentCount + } + _ = stylusParams.Save() } diff --git a/arbos/programs/programs.go b/arbos/programs/programs.go index 5cee5ae65c..1f9aac6739 100644 --- a/arbos/programs/programs.go +++ b/arbos/programs/programs.go @@ -114,7 +114,7 @@ func (p Programs) ActivateProgram(evm *vm.EVM, address common.Address, runCtx *c // already activated and up to date return 0, codeHash, common.Hash{}, nil, false, ProgramUpToDateError() } - wasm, err := getWasm(statedb, address, params.MaxWasmSize) + wasm, err := getWasm(statedb, address, params) if err != nil { return 0, codeHash, common.Hash{}, nil, false, err } @@ -225,7 +225,7 @@ func (p Programs) CallProgram( statedb.AddStylusPages(program.footprint) defer statedb.SetStylusPagesOpen(open) - asmMap, err := getCompiledProgram(statedb, moduleHash, contract.Address(), contract.Code, contract.CodeHash, params.MaxWasmSize, params.PageLimit, evm.Context.Time, debugMode, program, runCtx) + asmMap, err := getCompiledProgram(statedb, moduleHash, contract.Address(), contract.Code, contract.CodeHash, params, evm.Context.Time, debugMode, program, runCtx) var ok bool var localAsm []byte if asmMap != nil { @@ -307,30 +307,105 @@ func evmMemoryCost(size uint64) uint64 { return linearCost + squareCost } -func getWasm(statedb vm.StateDB, program common.Address, maxWasmSize uint32) ([]byte, error) { +func getWasm(statedb vm.StateDB, program common.Address, params *StylusParams) ([]byte, error) { prefixedWasm := statedb.GetCode(program) - return getWasmFromContractCode(prefixedWasm, maxWasmSize) + return getWasmFromContractCode(statedb, prefixedWasm, params, true) } -func getWasmFromContractCode(prefixedWasm []byte, maxWasmSize uint32) ([]byte, error) { - if prefixedWasm == nil { +func getWasmFromContractCode(statedb vm.StateDB, prefixedWasm []byte, params *StylusParams, isActivation bool) ([]byte, error) { + if len(prefixedWasm) == 0 { return nil, ProgramNotWasmError() } - wasm, dictByte, err := state.StripStylusPrefix(prefixedWasm) + + if state.IsStylusProgramClassic(prefixedWasm) { + return handleClassicStylus(prefixedWasm, params.MaxWasmSize) + } + + if params.arbosVersion >= gethParams.ArbosVersion_StylusContractLimit { + if state.IsStylusProgramRoot(prefixedWasm) { + return handleRootStylus(statedb, prefixedWasm, params.MaxWasmSize, params.MaxFragmentCount, isActivation) + } + + if state.IsStylusProgramFragment(prefixedWasm) { + return nil, errors.New("fragmented stylus programs cannot be activated directly; activate the root program instead") + } + } + + return nil, ProgramNotWasmError() +} + +func handleClassicStylus(data []byte, maxSize uint32) ([]byte, error) { + wasm, dictByte, err := state.StripStylusPrefix(data) + if err != nil { + return nil, err + } + + dict, err := getStylusCompressionDict(dictByte) + if err != nil { + return nil, err + } + + return arbcompress.DecompressWithDictionary(wasm, int(maxSize), dict) +} + +func handleRootStylus(statedb vm.StateDB, data []byte, maxSize uint32, maxFragments uint16, isActivation bool) ([]byte, error) { + root, err := state.NewStylusRoot(data) + if err != nil { + return nil, err + } + + if isActivation { + if root.DecompressedLength > maxSize { + return nil, fmt.Errorf("invalid wasm: decompressedLength %d is greater then MaxWasmSize %d", root.DecompressedLength, maxSize) + } + if len(root.Addresses) > int(maxFragments) { + return nil, fmt.Errorf("invalid wasm: fragment count exceeds limit of %d", maxFragments) + } + } + + if len(root.Addresses) == 0 { + return nil, fmt.Errorf("invalid wasm: fragment count cannot be zero") + } + + var compressedWasm []byte + for _, addr := range root.Addresses { + fragCode := statedb.GetCode(addr) + + payload, err := state.StripStylusFragmentPrefix(fragCode) + if err != nil { + return nil, err + } + + compressedWasm = append(compressedWasm, payload...) + } + + dict, err := getStylusCompressionDict(root.DictionaryType) if err != nil { return nil, err } - var dict arbcompress.Dictionary - switch dictByte { + wasm, err := arbcompress.DecompressWithDictionary(compressedWasm, int(root.DecompressedLength), dict) + if err != nil { + return nil, err + } + + if len(wasm) != int(root.DecompressedLength) { + return nil, fmt.Errorf("invalid wasm: decompressed length %d does not match expected length %d", len(wasm), root.DecompressedLength) + } + + return wasm, nil +} + +// Named return parameters allow us to return the zero-value for 'dict' implicitly on error +func getStylusCompressionDict(id byte) (dict arbcompress.Dictionary, err error) { + switch id { case 0: - dict = arbcompress.EmptyDictionary + return arbcompress.EmptyDictionary, nil case 1: - dict = arbcompress.StylusProgramDictionary + return arbcompress.StylusProgramDictionary, nil default: - return nil, fmt.Errorf("unsupported dictionary %v", dictByte) + return dict, fmt.Errorf("unsupported dictionary type: %d", id) } - return arbcompress.DecompressWithDictionary(wasm, int(maxWasmSize), dict) } // Gets a program entry, which may be expired or not yet activated. diff --git a/arbos/programs/wasm.go b/arbos/programs/wasm.go index 26ae73cb09..9a9325e5f4 100644 --- a/arbos/programs/wasm.go +++ b/arbos/programs/wasm.go @@ -136,7 +136,7 @@ func startProgram(module uint32) uint32 //go:wasmimport programs send_response func sendResponse(req_id uint32) uint32 -func getCompiledProgram(statedb vm.StateDB, moduleHash common.Hash, addressForLogging common.Address, code []byte, codeHash common.Hash, maxWasmSize uint32, pagelimit uint16, time uint64, debugMode bool, program Program, runCtx *core.MessageRunContext) (map[rawdb.WasmTarget][]byte, error) { +func getCompiledProgram(statedb vm.StateDB, moduleHash common.Hash, addressForLogging common.Address, code []byte, codeHash common.Hash, stylusParams *StylusParams, time uint64, debugMode bool, program Program, runCtx *core.MessageRunContext) (map[rawdb.WasmTarget][]byte, error) { // we need to return asm map with an entry for local target to make checks for local target work return map[rawdb.WasmTarget][]byte{rawdb.LocalTarget(): {}}, nil } diff --git a/arbos/programs/wasmstorehelper.go b/arbos/programs/wasmstorehelper.go index 27da8f0a66..92aa541adf 100644 --- a/arbos/programs/wasmstorehelper.go +++ b/arbos/programs/wasmstorehelper.go @@ -51,7 +51,7 @@ func (p Programs) SaveActiveProgramToWasmStore(statedb *state.StateDB, codeHash return nil } - wasm, err := getWasmFromContractCode(code, progParams.MaxWasmSize) + wasm, err := getWasmFromContractCode(statedb, code, progParams, false) if err != nil { log.Error("Failed to reactivate program while rebuilding wasm store: getWasmFromContractCode", "expected moduleHash", moduleHash, "err", err) return fmt.Errorf("failed to reactivate program while rebuilding wasm store: %w", err) diff --git a/changelog/kolbyml-increase-stylus-smart-contract-size.md b/changelog/kolbyml-increase-stylus-smart-contract-size.md new file mode 100644 index 0000000000..02055393da --- /dev/null +++ b/changelog/kolbyml-increase-stylus-smart-contract-size.md @@ -0,0 +1,2 @@ +### Added +- Increase Stylus smart contract size limit via merge-on-activate diff --git a/contracts-local/src/precompiles b/contracts-local/src/precompiles index acb12a8bcc..a60077f472 160000 --- a/contracts-local/src/precompiles +++ b/contracts-local/src/precompiles @@ -1 +1 @@ -Subproject commit acb12a8bcc5db8eea36a5ad641b6687a7be0e7ed +Subproject commit a60077f472b26686d4ee9bb1cf60cfd2d3eada55 diff --git a/go-ethereum b/go-ethereum index 9db3547817..b5365437dd 160000 --- a/go-ethereum +++ b/go-ethereum @@ -1 +1 @@ -Subproject commit 9db354781776766033914bdcec72f23f0f1e5b38 +Subproject commit b5365437ddfa03d3e759c8d07f0f3c8f3fcbff1c diff --git a/precompiles/ArbOwner.go b/precompiles/ArbOwner.go index b88afc3ec5..57bc2250b8 100644 --- a/precompiles/ArbOwner.go +++ b/precompiles/ArbOwner.go @@ -543,3 +543,12 @@ func (con ArbOwner) SetMultiGasPricingConstraints( } return nil } + +func (con ArbOwner) SetMaxStylusContractFragments(c ctx, evm mech, maxFragments uint16) error { + params, err := c.State.Programs().Params() + if err != nil { + return err + } + params.MaxFragmentCount = maxFragments + return params.Save() +} diff --git a/precompiles/ArbOwnerPublic.go b/precompiles/ArbOwnerPublic.go index c885fae25b..bc5fc4e586 100644 --- a/precompiles/ArbOwnerPublic.go +++ b/precompiles/ArbOwnerPublic.go @@ -93,3 +93,11 @@ func (con ArbOwnerPublic) IsCalldataPriceIncreaseEnabled(c ctx, _ mech) (bool, e func (con ArbOwnerPublic) GetParentGasFloorPerToken(c ctx, evm mech) (uint64, error) { return c.State.L1PricingState().ParentGasFloorPerToken() } + +func (con ArbOwnerPublic) GetMaxStylusContractFragments(c ctx, evm mech) (uint16, error) { + params, err := c.State.Programs().Params() + if err != nil { + return 0, err + } + return params.MaxFragmentCount, nil +} diff --git a/precompiles/precompile.go b/precompiles/precompile.go index b41ffa7d9b..73386bd0bd 100644 --- a/precompiles/precompile.go +++ b/precompiles/precompile.go @@ -561,6 +561,7 @@ func Precompiles() map[addr]ArbosPrecompile { ArbOwnerPublic.methodsByName["IsNativeTokenOwner"].arbosVersion = params.ArbosVersion_41 ArbOwnerPublic.methodsByName["GetAllNativeTokenOwners"].arbosVersion = params.ArbosVersion_41 ArbOwnerPublic.methodsByName["GetParentGasFloorPerToken"].arbosVersion = params.ArbosVersion_50 + ArbOwnerPublic.methodsByName["GetMaxStylusContractFragments"].arbosVersion = params.ArbosVersion_StylusContractLimit ArbWasmImpl := &ArbWasm{Address: types.ArbWasmAddress} ArbWasm := insert(MakePrecompile(precompilesgen.ArbWasmMetaData, ArbWasmImpl)) @@ -654,6 +655,7 @@ func Precompiles() map[addr]ArbosPrecompile { ArbOwner.methodsByName["GetAllNativeTokenOwners"].arbosVersion = params.ArbosVersion_41 ArbOwner.methodsByName["SetParentGasFloorPerToken"].arbosVersion = params.ArbosVersion_50 ArbOwner.methodsByName["SetMaxBlockGasLimit"].arbosVersion = params.ArbosVersion_50 + ArbOwner.methodsByName["SetMaxStylusContractFragments"].arbosVersion = params.ArbosVersion_StylusContractLimit ArbOwnerPublic.methodsByName["GetNativeTokenManagementFrom"].arbosVersion = params.ArbosVersion_50 diff --git a/precompiles/precompile_test.go b/precompiles/precompile_test.go index 4aa3956cbe..491cd62584 100644 --- a/precompiles/precompile_test.go +++ b/precompiles/precompile_test.go @@ -192,7 +192,7 @@ func TestPrecompilesPerArbosVersion(t *testing.T) { params.ArbosVersion_40: 3, params.ArbosVersion_41: 10, params.ArbosVersion_50: 9, - params.ArbosVersion_60: 3, + params.ArbosVersion_60: 5, } precompiles := Precompiles() diff --git a/system_tests/archival_path_scheme_test.go b/system_tests/archival_path_scheme_test.go index 082fd507cb..8ed1f90d67 100644 --- a/system_tests/archival_path_scheme_test.go +++ b/system_tests/archival_path_scheme_test.go @@ -2,7 +2,6 @@ package arbtest import ( "context" - "fmt" "math/big" "testing" @@ -71,7 +70,6 @@ func TestAccessingPathSchemeArchivalState(t *testing.T) { // Build a node with history past the 128 block diff threshold cancelNode := buildWithHistory(t, ctx, builder, 150) - fmt.Println("bluebird 5-3", builder.execConfig.Caching.StateScheme) execNode, l2client := builder.L2.ExecNode, builder.L2.Client defer cancelNode() bc := execNode.Backend.ArbInterface().BlockChain() diff --git a/system_tests/common_test.go b/system_tests/common_test.go index 14ea8a5d67..504a62eee4 100644 --- a/system_tests/common_test.go +++ b/system_tests/common_test.go @@ -2438,10 +2438,20 @@ func deployContractInitCode(code []byte, revert bool) []byte { func deployContract( t *testing.T, ctx context.Context, auth bind.TransactOpts, client *ethclient.Client, code []byte, ) common.Address { + address, err := deployContractForwardError(t, ctx, auth, client, code) + Require(t, err) + return address +} + +func deployContractForwardError( + t *testing.T, ctx context.Context, auth bind.TransactOpts, client *ethclient.Client, code []byte, +) (common.Address, error) { deploy := deployContractInitCode(code, false) basefee := arbmath.BigMulByFrac(GetBaseFee(t, client, ctx), 6, 5) // current*1.2 nonce, err := client.NonceAt(ctx, auth.From, nil) - Require(t, err) + if err != nil { + return common.Address{}, err + } gas, err := client.EstimateGas(ctx, ethereum.CallMsg{ From: auth.From, GasPrice: basefee, @@ -2449,14 +2459,22 @@ func deployContract( Value: big.NewInt(0), Data: deploy, }) - Require(t, err) + if err != nil { + return common.Address{}, err + } tx := types.NewContractCreation(nonce, big.NewInt(0), gas, basefee, deploy) tx, err = auth.Signer(auth.From, tx) - Require(t, err) - Require(t, client.SendTransaction(ctx, tx)) + if err != nil { + return common.Address{}, err + } + if err := client.SendTransaction(ctx, tx); err != nil { + return common.Address{}, err + } _, err = EnsureTxSucceeded(ctx, client, tx) - Require(t, err) - return crypto.CreateAddress(auth.From, nonce) + if err != nil { + return common.Address{}, err + } + return crypto.CreateAddress(auth.From, nonce), nil } func sendContractCall( diff --git a/system_tests/stylus_contract_limit_increase_test.go b/system_tests/stylus_contract_limit_increase_test.go new file mode 100644 index 0000000000..bc4320eb07 --- /dev/null +++ b/system_tests/stylus_contract_limit_increase_test.go @@ -0,0 +1,722 @@ +// Copyright 2025, Offchain Labs, Inc. +// For license information, see https://github.com/OffchainLabs/nitro/blob/master/LICENSE.md + +package arbtest + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" + _ "github.com/ethereum/go-ethereum/eth/tracers/js" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" + + "github.com/offchainlabs/nitro/arbcompress" + "github.com/offchainlabs/nitro/arbos/programs" + "github.com/offchainlabs/nitro/arbutil" + "github.com/offchainlabs/nitro/execution/gethexec" + "github.com/offchainlabs/nitro/solgen/go/precompilesgen" + "github.com/offchainlabs/nitro/util/colors" + "github.com/offchainlabs/nitro/util/testhelpers" +) + +// Shared Helpers + +type deployConfig struct { + fragmentCount uint16 + mutateDict func(arbcompress.Dictionary) arbcompress.Dictionary + mutateAddrs func([]common.Address) + mutateSize func(int) int + expectActivation bool + expectedErr string +} + +func defaultDeployConfig() deployConfig { + return deployConfig{ + fragmentCount: 2, + expectActivation: true, + } +} + +// deployAndActivateFragmentedContract handles the common flow of reading rust files, +// splitting them, deploying fragments, constructing the root, and activating it. +func deployAndActivateFragmentedContract( + t *testing.T, + ctx context.Context, + auth bind.TransactOpts, + l2client *ethclient.Client, + cfg deployConfig, +) (common.Address, []byte, *types.Receipt) { + file := rustFile("storage") + name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) + + // Read and fragment + fragments, sourceWasm, dictType := readFragmentedContractFile(t, file, cfg.fragmentCount) + require.Len(t, fragments, int(cfg.fragmentCount)) + + // Apply dictionary mutations if any + if cfg.mutateDict != nil { + dictType = cfg.mutateDict(dictType) + } + + // Deploy fragments + auth.GasLimit = 32000000 // skip gas estimation + addresses := make([]common.Address, 0, len(fragments)) + for i, fragment := range fragments { + fragmentAddress := deployContract(t, ctx, auth, l2client, fragment) + colors.PrintGrey(name, ": fragment contract", i, " deployed to ", fragmentAddress.Hex()) + addresses = append(addresses, fragmentAddress) + } + + // Apply address mutations if any + if cfg.mutateAddrs != nil { + cfg.mutateAddrs(addresses) + } + + // Calculate size + // #nosec G115 + decompressedSize := uint32(len(sourceWasm)) + if cfg.mutateSize != nil { + // #nosec G115 + decompressedSize = uint32(cfg.mutateSize(len(sourceWasm))) + } + + // Deploy root contract + rootContract := constructRootContract(t, decompressedSize, addresses, dictType) + rootAddress := deployContract(t, ctx, auth, l2client, rootContract) + colors.PrintGrey(name, ": root contract deployed to ", rootAddress.Hex()) + + // Activate + arbWasm, err := precompilesgen.NewArbWasm(types.ArbWasmAddress, l2client) + Require(t, err) + auth.Value = oneEth + tx, err := arbWasm.ActivateProgram(&auth, rootAddress) + Require(t, err) + + receipt, err := EnsureTxSucceeded(ctx, l2client, tx) + if cfg.expectActivation { + Require(t, err) + return rootAddress, sourceWasm, receipt + } + + require.Error(t, err, cfg.expectedErr) + return rootAddress, sourceWasm, nil +} + +// Validation Tests + +func TestFragmentedContractValidation(t *testing.T) { + tests := []struct { + name string + cfg deployConfig + }{ + { + name: "Valid 2 Fragments", + cfg: defaultDeployConfig(), + }, + { + name: "Valid 1 Fragment", + cfg: deployConfig{ + fragmentCount: 1, + expectActivation: true, + }, + }, + { + name: "Zero Fragments", + cfg: deployConfig{ + fragmentCount: 0, + expectActivation: false, + expectedErr: "We can't deploy fragmented contracts which have zero fragments", + }, + }, + { + name: "Too Many Fragments (3)", + cfg: deployConfig{ + fragmentCount: 3, + expectActivation: false, + expectedErr: "more fragments then the current limit", + }, + }, + { + name: "Decompression Size Too Small", + cfg: deployConfig{ + fragmentCount: 2, + expectActivation: false, + expectedErr: "smaller decompression size then the actual wasm size", + mutateSize: func(i int) int { return i - 1 }, + }, + }, + { + name: "Decompression Size Too Big", + cfg: deployConfig{ + fragmentCount: 2, + expectActivation: false, + expectedErr: "bigger decompression size then the actual wasm size", + mutateSize: func(i int) int { return i + 1 }, + }, + }, + { + name: "Incorrect Dictionary Type", + cfg: deployConfig{ + fragmentCount: 2, + expectActivation: false, + expectedErr: "incorrect dictionary type", + mutateDict: func(d arbcompress.Dictionary) arbcompress.Dictionary { + return (d + 1) % 2 + }, + }, + }, + { + name: "Invalid Address Order", + cfg: deployConfig{ + fragmentCount: 2, + expectActivation: false, + expectedErr: "fragment addresses in the wrong order", + mutateAddrs: func(addrs []common.Address) { + slices.Reverse(addrs) + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder, auth, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_StylusContractLimit) + }) + defer cleanup() + + // If testing 0 fragments, readFragmentedContractFile returns empty sourceWasm, + // creating an issue for constructRootContract size calculation if not handled. + // The helper handles typical cases, edge cases are covered by logic inside deployAndActivate. + deployAndActivateFragmentedContract(t, builder.ctx, auth, builder.L2.Client, tt.cfg) + }) + } +} + +// Specific Edge Case Tests + +func TestThatWeCantActivateStylusFragmentContract(t *testing.T) { + builder, auth, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_StylusContractLimit) + }) + defer cleanup() + + file := rustFile("storage") + fragments, _, _ := readFragmentedContractFile(t, file, 1) + require.Len(t, fragments, 1) + auth.GasLimit = 32000000 + fragmentAddress := deployContract(t, builder.ctx, auth, builder.L2.Client, fragments[0]) + + arbWasm, err := precompilesgen.NewArbWasm(types.ArbWasmAddress, builder.L2.Client) + Require(t, err) + auth.Value = oneEth + tx, err := arbWasm.ActivateProgram(&auth, fragmentAddress) + Require(t, err) + _, err = EnsureTxSucceeded(builder.ctx, builder.L2.Client, tx) + require.Error(t, err, "We can't activate a stylus fragment contract directly") +} + +func TestDeployStylusRootContractGreaterThanMaxCodeSize(t *testing.T) { + builder, auth, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_StylusContractLimit) + b.chainConfig.ArbitrumChainParams.MaxCodeSize = 4500 + }) + defer cleanup() + + // 1. Classic contract fail + file := rustFile("storage") + wasm, _ := readWasmFile(t, file) + auth.GasLimit = 32000000 + _, err := deployContractForwardError(t, builder.ctx, auth, builder.L2.Client, wasm) + require.Error(t, err, "We can't activate a classic stylus contract greater than the MaxCodeSize") + + // 2. Fragmented contract success + deployAndActivateFragmentedContract(t, builder.ctx, auth, builder.L2.Client, defaultDeployConfig()) +} + +// ArbOwner Limit Modification Tests + +func TestCantActivateRootContractBiggerThanMaxWasmSize(t *testing.T) { + builder, auth, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_StylusContractLimit) + }) + defer cleanup() + + // Deploy manually to inject custom logic before activation + file := rustFile("storage") + name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) + fragments, sourceWasm, dictType := readFragmentedContractFile(t, file, 2) + auth.GasLimit = 32000000 + + addresses := make([]common.Address, 0, len(fragments)) + for i, fragment := range fragments { + addr := deployContract(t, builder.ctx, auth, builder.L2.Client, fragment) + addresses = append(addresses, addr) + colors.PrintGrey(name, ": fragment", i, addr.Hex()) + } + + // #nosec G115 + rootContract := constructRootContract(t, uint32(len(sourceWasm)), addresses, dictType) + rootAddress := deployContract(t, builder.ctx, auth, builder.L2.Client, rootContract) + + // Decrease limit + arbOwner, err := precompilesgen.NewArbOwner(types.ArbOwnerAddress, builder.L2.Client) + Require(t, err) + // #nosec G115 + tx, err := arbOwner.SetWasmMaxSize(&auth, uint32(len(sourceWasm)-1)) + Require(t, err) + _, err = EnsureTxSucceeded(builder.ctx, builder.L2.Client, tx) + Require(t, err) + + // Attempt activation + arbWasm, err := precompilesgen.NewArbWasm(types.ArbWasmAddress, builder.L2.Client) + Require(t, err) + auth.Value = oneEth + tx, err = arbWasm.ActivateProgram(&auth, rootAddress) + Require(t, err) + _, err = EnsureTxSucceeded(builder.ctx, builder.L2.Client, tx) + require.Error(t, err, "We can't activate a fragmented contract greater than the MaxWasmSize") +} + +func TestArbOwnerModifyingMaxFragmentCount(t *testing.T) { + builder, auth, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_StylusContractLimit) + }) + defer cleanup() + + arbOwnerPublic, err := precompilesgen.NewArbOwnerPublic(types.ArbOwnerPublicAddress, builder.L2.Client) + Require(t, err) + arbOwner, err := precompilesgen.NewArbOwner(types.ArbOwnerAddress, builder.L2.Client) + Require(t, err) + callOpts := &bind.CallOpts{Context: builder.ctx} + + // Verify initial + count, err := arbOwnerPublic.GetMaxStylusContractFragments(callOpts) + Require(t, err) + require.Equal(t, uint16(2), count) + + // Change to 1 + tx, err := arbOwner.SetMaxStylusContractFragments(&auth, 1) + Require(t, err) + _, err = EnsureTxSucceeded(builder.ctx, builder.L2.Client, tx) + Require(t, err) + + count, err = arbOwnerPublic.GetMaxStylusContractFragments(callOpts) + Require(t, err) + require.Equal(t, uint16(1), count) + + // Change to 3 + tx, err = arbOwner.SetMaxStylusContractFragments(&auth, 3) + Require(t, err) + _, err = EnsureTxSucceeded(builder.ctx, builder.L2.Client, tx) + Require(t, err) + + count, err = arbOwnerPublic.GetMaxStylusContractFragments(callOpts) + Require(t, err) + require.Equal(t, uint16(3), count) +} + +func TestArbOwnerPublicReturnsCorrectMaxFragmentCount(t *testing.T) { + builder, _, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_StylusContractLimit) + }) + defer cleanup() + + arbOwnerPublic, err := precompilesgen.NewArbOwnerPublic(types.ArbOwnerPublicAddress, builder.L2.Client) + Require(t, err) + + count, err := arbOwnerPublic.GetMaxStylusContractFragments(&bind.CallOpts{Context: builder.ctx}) + Require(t, err) + require.Equal(t, uint16(2), count) +} + +// Generic Runners for Limit Decrease Scenarios +// These generic functions handle the heavy lifting for Rebuild, Execute, Cache, and Deploy tests. +// They accept a `setLimitFunc` to toggle between testing MaxWasmSize and MaxFragmentCount. + +type limitSetter func(t *testing.T, ctx context.Context, auth *bind.TransactOpts, client *ethclient.Client) + +func runRebuildWasmStoreTest(t *testing.T, setLimit limitSetter) { + databaseEngine := rawdb.DBLeveldb + builder, auth, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_StylusContractLimit) + b.WithDatabase(databaseEngine) + }) + ctx := builder.ctx + defer cleanup() + + rootAddress, _, _ := deployAndActivateFragmentedContract(t, ctx, auth, builder.L2.Client, defaultDeployConfig()) + + // Store value + zero := common.Hash{} + val := common.HexToHash("0x121233445566") + storeTx := builder.L2Info.PrepareTxTo("Owner", &rootAddress, builder.L2Info.TransferGas, nil, argsForStorageWrite(zero, val)) + Require(t, builder.L2.Client.SendTransaction(ctx, storeTx)) + _, err := EnsureTxSucceeded(ctx, builder.L2.Client, storeTx) + Require(t, err) + + // Decrease Limit + auth.GasLimit = 0 + auth.Value = nil + setLimit(t, ctx, &auth, builder.L2.Client) + + // Build 2nd Node + testDir := t.TempDir() + nodeBStack := testhelpers.CreateStackConfigForTest(testDir) + nodeBStack.DBEngine = databaseEngine + nodeB, cleanupB := builder.Build2ndNode(t, &SecondNodeParams{stackConfig: nodeBStack}) + + // Ensure tx succeeds on nodeB + _, err = EnsureTxSucceeded(ctx, nodeB.Client, storeTx) + Require(t, err) + + // Verify read + loadTx := builder.L2Info.PrepareTxTo("Owner", &rootAddress, builder.L2Info.TransferGas, nil, argsForStorageRead(zero)) + result, err := arbutil.SendTxAsCall(ctx, nodeB.Client, loadTx, builder.L2Info.GetAddress("Owner"), nil, true) + Require(t, err) + require.Equal(t, val, common.BytesToHash(result)) + + // Verify Wasm Store + wasmDb := nodeB.ExecNode.Backend.ArbInterface().BlockChain().StateCache().WasmStore() + checkWasmStoreContent(t, wasmDb, builder.execConfig.StylusTarget.WasmTargets(), 1) + + // Rebuild Test: Close, Delete Wasm, Reopen, Rebuild + cleanupB() + wasmPath := filepath.Join(testDir, nodeBStack.Name, "wasm") + dirContents, err := os.ReadDir(wasmPath) + Require(t, err) + require.NotEmpty(t, dirContents) + os.RemoveAll(wasmPath) + + nodeB, cleanupB = builder.Build2ndNode(t, &SecondNodeParams{stackConfig: nodeBStack}) + + // Verify empty before rebuild + wasmDbAfterDelete := nodeB.ExecNode.Backend.ArbInterface().BlockChain().StateCache().WasmStore() + storeMapAfterDelete, err := createMapFromDb(wasmDbAfterDelete) + Require(t, err) + require.Empty(t, storeMapAfterDelete) + + log.Info("starting rebuilding of wasm store") + execConfig := builder.execConfig + bc := nodeB.ExecNode.Backend.ArbInterface().BlockChain() + Require(t, gethexec.RebuildWasmStore(ctx, wasmDbAfterDelete, nodeB.ExecNode.ExecutionDB, execConfig.RPC.MaxRecreateStateDepth, &execConfig.StylusTarget, bc, common.Hash{}, bc.CurrentBlock().Hash())) + + wasmDbAfterRebuild := nodeB.ExecNode.Backend.ArbInterface().BlockChain().StateCache().WasmStore() + checkWasmStoreContent(t, wasmDbAfterRebuild, builder.execConfig.StylusTarget.WasmTargets(), 1) + cleanupB() +} + +func runExecuteWasmTest(t *testing.T, setLimit limitSetter, deleteWasm bool) { + databaseEngine := rawdb.DBLeveldb + builder, auth, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_StylusContractLimit) + b.WithDatabase(databaseEngine) + }) + ctx := builder.ctx + defer cleanup() + + rootAddress, _, receipt := deployAndActivateFragmentedContract(t, ctx, auth, builder.L2.Client, defaultDeployConfig()) + + // Decrease Limit + auth.GasLimit = 0 + auth.Value = nil + setLimit(t, ctx, &auth, builder.L2.Client) + + if deleteWasm { + arbWasm, err := precompilesgen.NewArbWasm(types.ArbWasmAddress, builder.L2.Client) + Require(t, err) + l, err := arbWasm.ParseProgramActivated(*receipt.Logs[0]) + Require(t, err) + + wasmStore := builder.L2.ExecNode.Backend.ArbInterface().BlockChain().StateCache().WasmStore() + Require(t, deleteAnyKeysContainingModuleHash(wasmStore, l.ModuleHash)) + } + + // Execute Wasm + zero := common.Hash{} + val := common.HexToHash("0x121233445566") + storeTx := builder.L2Info.PrepareTxTo("Owner", &rootAddress, builder.L2Info.TransferGas, nil, argsForStorageWrite(zero, val)) + Require(t, builder.L2.Client.SendTransaction(ctx, storeTx)) + _, err := EnsureTxSucceeded(ctx, builder.L2.Client, storeTx) + Require(t, err) +} + +func runCacheProgramTest(t *testing.T, setLimit limitSetter) { + databaseEngine := rawdb.DBLeveldb + builder, auth, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_StylusContractLimit) + b.WithDatabase(databaseEngine) + }) + ctx := builder.ctx + defer cleanup() + + rootAddress, _, receipt := deployAndActivateFragmentedContract(t, ctx, auth, builder.L2.Client, defaultDeployConfig()) + + // Decrease Limit + auth.GasLimit = 0 + auth.Value = nil + setLimit(t, ctx, &auth, builder.L2.Client) + + // Identify module hash and delete from store + arbWasm, err := precompilesgen.NewArbWasm(types.ArbWasmAddress, builder.L2.Client) + Require(t, err) + l, err := arbWasm.ParseProgramActivated(*receipt.Logs[0]) + Require(t, err) + wasmStore := builder.L2.ExecNode.Backend.ArbInterface().BlockChain().StateCache().WasmStore() + Require(t, deleteAnyKeysContainingModuleHash(wasmStore, l.ModuleHash)) + + // Cache + arbWasmCache, err := precompilesgen.NewArbWasmCache(types.ArbWasmCacheAddress, builder.L2.Client) + Require(t, err) + _, err = arbWasmCache.CacheProgram(&auth, rootAddress) + Require(t, err) +} + +func runDeployAfterLimitTest(t *testing.T, setLimit limitSetter) { + databaseEngine := rawdb.DBLeveldb + builder, auth, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_StylusContractLimit) + b.WithDatabase(databaseEngine) + }) + ctx := builder.ctx + defer cleanup() + + // Initial Deploy + rootAddress, _, _ := deployAndActivateFragmentedContract(t, ctx, auth, builder.L2.Client, defaultDeployConfig()) + + // Write + zero := common.Hash{} + val := common.HexToHash("0x121233445566") + storeTx := builder.L2Info.PrepareTxTo("Owner", &rootAddress, builder.L2Info.TransferGas, nil, argsForStorageWrite(zero, val)) + Require(t, builder.L2.Client.SendTransaction(ctx, storeTx)) + _, err := EnsureTxSucceeded(ctx, builder.L2.Client, storeTx) + Require(t, err) + + // Decrease Limit + auth.GasLimit = 0 + auth.Value = nil + setLimit(t, ctx, &auth, builder.L2.Client) + + // Second Deploy (should fail) + failCfg := defaultDeployConfig() + failCfg.expectActivation = false + deployAndActivateFragmentedContract(t, ctx, auth, builder.L2.Client, failCfg) +} + +// Specific Implementations of Limit Tests + +// Shared Limit Setters +func setWasmLimitTo10k(t *testing.T, ctx context.Context, auth *bind.TransactOpts, client *ethclient.Client) { + arbOwner, err := precompilesgen.NewArbOwner(types.ArbOwnerAddress, client) + Require(t, err) + tx, err := arbOwner.SetWasmMaxSize(auth, 10000) + Require(t, err) + _, err = EnsureTxSucceeded(ctx, client, tx) + Require(t, err) +} + +func setFragmentLimitTo1(t *testing.T, ctx context.Context, auth *bind.TransactOpts, client *ethclient.Client) { + arbOwner, err := precompilesgen.NewArbOwner(types.ArbOwnerAddress, client) + Require(t, err) + tx, err := arbOwner.SetMaxStylusContractFragments(auth, 1) + Require(t, err) + _, err = EnsureTxSucceeded(ctx, client, tx) + Require(t, err) +} + +// Tests: Decrease Max Wasm Size +func TestRebuildWasmStoreWithDecreasedMaxWasmSize(t *testing.T) { + runRebuildWasmStoreTest(t, setWasmLimitTo10k) +} +func TestExecuteWasmWithDecreasedMaxWasmSizeWasmPresent(t *testing.T) { + runExecuteWasmTest(t, setWasmLimitTo10k, false) +} +func TestExecuteWasmWithDecreasedMaxWasmSizeRecoverWasm(t *testing.T) { + runExecuteWasmTest(t, setWasmLimitTo10k, true) +} +func TestCacheProgramWithDecreasedMaxWasmSizeRecoverWasm(t *testing.T) { + runCacheProgramTest(t, setWasmLimitTo10k) +} +func TestDeployingContractBeforeAndAfterDecreaseMaxWasmSize(t *testing.T) { + runDeployAfterLimitTest(t, setWasmLimitTo10k) +} + +// Tests: Decrease Max Fragment Count +func TestRebuildWasmStoreWithDecreasedMaxFragmentCount(t *testing.T) { + runRebuildWasmStoreTest(t, setFragmentLimitTo1) +} +func TestExecuteWasmWithDecreasedMaxFragmentCountWasmPresent(t *testing.T) { + runExecuteWasmTest(t, setFragmentLimitTo1, false) +} +func TestExecuteWasmWithDecreasedMaxFragmentCountRecoverWasm(t *testing.T) { + runExecuteWasmTest(t, setFragmentLimitTo1, true) +} +func TestCacheProgramWithDecreasedMaxFragmentCountRecoverWasm(t *testing.T) { + runCacheProgramTest(t, setFragmentLimitTo1) +} +func TestDeployingContractBeforeAndAfterDecreaseMaxFragmentCount(t *testing.T) { + runDeployAfterLimitTest(t, setFragmentLimitTo1) +} + +// Test that fragmented contracts fail on ArbOS versions before the feature is active + +func TestFragmentedContractFailsOnArbOS50(t *testing.T) { + builder, auth, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_50) + }) + defer cleanup() + + cfg := defaultDeployConfig() + cfg.expectActivation = false + cfg.expectedErr = "unknown stylus version" + deployAndActivateFragmentedContract(t, builder.ctx, auth, builder.L2.Client, cfg) +} + +func TestArbOwnerPublicGetMaxFragmentCountFailsOnArbOS50(t *testing.T) { + builder, _, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_50) + }) + defer cleanup() + + arbOwnerPublic, err := precompilesgen.NewArbOwnerPublic(types.ArbOwnerPublicAddress, builder.L2.Client) + Require(t, err) + + _, err = arbOwnerPublic.GetMaxStylusContractFragments(&bind.CallOpts{Context: builder.ctx}) + require.Error(t, err, "GetMaxStylusContractFragments should fail on ArbOS 50 because the feature is not yet active") +} + +func TestArbOwnerSetMaxFragmentCountFailsOnArbOS50(t *testing.T) { + builder, auth, cleanup := setupProgramTest(t, true, func(b *NodeBuilder) { + b.WithExtraArchs(allWasmTargets) + b.WithArbOSVersion(params.ArbosVersion_50) + }) + defer cleanup() + + arbOwner, err := precompilesgen.NewArbOwner(types.ArbOwnerAddress, builder.L2.Client) + Require(t, err) + + tx, err := arbOwner.SetMaxStylusContractFragments(&auth, 10) + if err == nil { + _, err = EnsureTxSucceeded(builder.ctx, builder.L2.Client, tx) + } + require.Error(t, err, "SetMaxStylusContractFragments should fail on ArbOS 50") +} + +// Utils + +// readFragmentedContractFile reads, compiles, compresses, and fragments a contract. +func readFragmentedContractFile(t *testing.T, file string, fragmentCount uint16) ([][]byte, []byte, arbcompress.Dictionary) { + t.Helper() + name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) + source, err := os.ReadFile(file) + Require(t, err) + + // #nosec G115 + randDict := arbcompress.Dictionary((len(file) + len(t.Name())) % 2) + + wasmSource, err := programs.Wat2Wasm(source) + Require(t, err) + + fragments := make([][]byte, 0, fragmentCount) + if fragmentCount == 0 { + return fragments, wasmSource, randDict + } + + compressedWasm, err := arbcompress.Compress(wasmSource, arbcompress.LEVEL_WELL, randDict) + Require(t, err) + + toKb := func(data []byte) float64 { return float64(len(data)) / 1024.0 } + colors.PrintGrey(fmt.Sprintf("%v: len %.2fK vs %.2fK", name, toKb(compressedWasm), toKb(wasmSource))) + + prefix := state.NewStylusFragmentPrefix() + payloadLen := len(compressedWasm) + chunkSize := (payloadLen + int(fragmentCount) - 1) / int(fragmentCount) + + for i := 0; i < int(fragmentCount); i++ { + start := i * chunkSize + if start >= payloadLen { + break + } + end := start + chunkSize + if end > payloadLen { + end = payloadLen + } + frag := make([]byte, 0, len(prefix)+(end-start)) + frag = append(frag, prefix...) + frag = append(frag, compressedWasm[start:end]...) + fragments = append(fragments, frag) + } + + return fragments, wasmSource, randDict +} + +func constructRootContract( + t *testing.T, + dictionaryTypeUncompressedWasmSize uint32, + addresses []common.Address, + dictionaryType arbcompress.Dictionary, +) []byte { + t.Helper() + // prefix 3 bytes + dict 1 byte + length 4 bytes + len(address) * 20 bytes + contract := make([]byte, 0, 3+1+4+len(addresses)*common.AddressLength) + contract = append(contract, state.NewStylusRootPrefix(byte(dictionaryType))...) + var sizeBuf [4]byte + binary.BigEndian.PutUint32(sizeBuf[:], dictionaryTypeUncompressedWasmSize) + contract = append(contract, sizeBuf[:]...) + for _, addr := range addresses { + contract = append(contract, addr.Bytes()...) + } + return contract +} + +func deleteAnyKeysContainingModuleHash(db ethdb.KeyValueStore, moduleHash common.Hash) error { + it := db.NewIterator(nil, nil) + defer it.Release() + + batch := db.NewBatch() + mh := moduleHash.Bytes() + + for it.Next() { + k := it.Key() + if bytes.Contains(k, mh) { + kk := append([]byte(nil), k...) + err := batch.Delete(kk) + if err != nil { + return err + } + } + } + if err := it.Error(); err != nil { + return err + } + return batch.Write() +} From 6b48d048acb7e53ca4a8e330e5d8135363f7df4f Mon Sep 17 00:00:00 2001 From: Kolby Moroz Liebl <31669092+KolbyML@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:13:15 -0700 Subject: [PATCH 2/7] Resolve compiler errors and submodules --- arbos/arbosState/arbosstate.go | 4 ++-- arbos/programs/native.go | 4 ++-- arbos/programs/programs.go | 2 +- arbos/programs/wasm.go | 6 +++--- contracts-local/src/precompiles | 2 +- go-ethereum | 2 +- precompiles/precompile_test.go | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/arbos/arbosState/arbosstate.go b/arbos/arbosState/arbosstate.go index 638a8233f7..8e302a42a1 100644 --- a/arbos/arbosState/arbosstate.go +++ b/arbos/arbosState/arbosstate.go @@ -462,12 +462,12 @@ func (state *ArbosState) UpgradeArbosVersion( // these versions are left to Orbit chains for custom upgrades. case params.ArbosVersion_60: + // Changes for ArbosVersion_StylusContractLimit p, err := state.Programs().Params() ensure(err) ensure(p.UpgradeToArbosVersion(nextArbosVersion)) ensure(p.Save()) - case params.ArbosVersion_TransactionFiltering: - // Once the final ArbOS version is locked in, this can be moved to that numeric version. + // Changes for ArbosVersion_TransactionFiltering ensure(addressSet.Initialize(state.backingStorage.OpenSubStorage(transactionFiltererSubspace))) default: diff --git a/arbos/programs/native.go b/arbos/programs/native.go index 0957829b33..81c1d2f865 100644 --- a/arbos/programs/native.go +++ b/arbos/programs/native.go @@ -326,8 +326,8 @@ func getCompiledProgram(statedb vm.StateDB, moduleHash common.Hash, addressForLo return asmMap, nil } -func handleProgramPrepare(statedb vm.StateDB, moduleHash common.Hash, addressForLogging common.Address, code []byte, codehash common.Hash, maxWasmSize uint32, pagelimit uint16, time uint64, debugMode bool, program Program, runCtx *core.MessageRunContext) []byte { - asmMap, err := getCompiledProgram(statedb, moduleHash, addressForLogging, code, codehash, maxWasmSize, pagelimit, time, debugMode, program, runCtx) +func handleProgramPrepare(statedb vm.StateDB, moduleHash common.Hash, addressForLogging common.Address, code []byte, codehash common.Hash, params *StylusParams, time uint64, debugMode bool, program Program, runCtx *core.MessageRunContext) []byte { + asmMap, err := getCompiledProgram(statedb, moduleHash, addressForLogging, code, codehash, params, time, debugMode, program, runCtx) var ok bool var localAsm []byte if asmMap != nil { diff --git a/arbos/programs/programs.go b/arbos/programs/programs.go index 6e49305433..2f9df4810d 100644 --- a/arbos/programs/programs.go +++ b/arbos/programs/programs.go @@ -224,7 +224,7 @@ func (p Programs) CallProgram( statedb.AddStylusPages(program.footprint) defer statedb.SetStylusPagesOpen(open) - localAsm := handleProgramPrepare(statedb, moduleHash, contract.Address(), contract.Code, contract.CodeHash, params.MaxWasmSize, params.PageLimit, evm.Context.Time, debugMode, program, runCtx) + localAsm := handleProgramPrepare(statedb, moduleHash, contract.Address(), contract.Code, contract.CodeHash, params, evm.Context.Time, debugMode, program, runCtx) evmData := &EvmData{ arbosVersion: evm.Context.ArbOSVersion, diff --git a/arbos/programs/wasm.go b/arbos/programs/wasm.go index 96660108a8..5cd7429cb0 100644 --- a/arbos/programs/wasm.go +++ b/arbos/programs/wasm.go @@ -156,7 +156,7 @@ func startProgram(module uint32) uint32 //go:wasmimport programs send_response func sendResponse(req_id uint32) uint32 -func handleProgramPrepare(statedb vm.StateDB, moduleHash common.Hash, addressForLogging common.Address, code []byte, codehash common.Hash, maxWasmSize uint32, pagelimit uint16, time uint64, debugMode bool, program Program, runCtx *core.MessageRunContext) []byte { +func handleProgramPrepare(statedb vm.StateDB, moduleHash common.Hash, addressForLogging common.Address, code []byte, codehash common.Hash, params *StylusParams, time uint64, debugMode bool, program Program, runCtx *core.MessageRunContext) []byte { requiresPrepare := programRequiresPrepare(unsafe.Pointer(&moduleHash[0])) if requiresPrepare != 0 { var debugInt uint32 @@ -173,8 +173,8 @@ func handleProgramPrepare(statedb vm.StateDB, moduleHash common.Hash, addressFor unsafe.Pointer(&code), codeSize, unsafe.Pointer(&codehash), - maxWasmSize, - uint32(pagelimit), + params.MaxWasmSize, + uint32(params.PageLimit), time, debugInt, unsafe.Pointer(&program), diff --git a/contracts-local/src/precompiles b/contracts-local/src/precompiles index 0e455541b5..b0777f7a3f 160000 --- a/contracts-local/src/precompiles +++ b/contracts-local/src/precompiles @@ -1 +1 @@ -Subproject commit 0e455541b5dc9203506d995b153575f18cf71cdd +Subproject commit b0777f7a3f6382a109e4481bacbbe679560ab667 diff --git a/go-ethereum b/go-ethereum index b5365437dd..881f481c42 160000 --- a/go-ethereum +++ b/go-ethereum @@ -1 +1 @@ -Subproject commit b5365437ddfa03d3e759c8d07f0f3c8f3fcbff1c +Subproject commit 881f481c42d6f3457e1daabb22e339b0389f78bc diff --git a/precompiles/precompile_test.go b/precompiles/precompile_test.go index bbfcd2613c..ac38859dee 100644 --- a/precompiles/precompile_test.go +++ b/precompiles/precompile_test.go @@ -192,7 +192,7 @@ func TestPrecompilesPerArbosVersion(t *testing.T) { params.ArbosVersion_40: 3, params.ArbosVersion_41: 10, params.ArbosVersion_50: 9, - params.ArbosVersion_60: 20, + params.ArbosVersion_60: 17, } precompiles := Precompiles() From 15bbdc924ebca5effa4bf7620fc732c9a7dca0dd Mon Sep 17 00:00:00 2001 From: Kolby Moroz Liebl <31669092+KolbyML@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:27:54 -0700 Subject: [PATCH 3/7] Changes from go-ethereum pr review --- arbos/programs/programs.go | 6 +++--- execution/gethexec/wasmstorerebuilder.go | 2 +- go-ethereum | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/arbos/programs/programs.go b/arbos/programs/programs.go index 2f9df4810d..16c85f253b 100644 --- a/arbos/programs/programs.go +++ b/arbos/programs/programs.go @@ -308,16 +308,16 @@ func getWasmFromContractCode(statedb vm.StateDB, prefixedWasm []byte, params *St return nil, ProgramNotWasmError() } - if state.IsStylusProgramClassic(prefixedWasm) { + if state.IsStylusClassicProgramPrefix(prefixedWasm) { return handleClassicStylus(prefixedWasm, params.MaxWasmSize) } if params.arbosVersion >= gethParams.ArbosVersion_StylusContractLimit { - if state.IsStylusProgramRoot(prefixedWasm) { + if state.IsStylusRootProgramPrefix(prefixedWasm) { return handleRootStylus(statedb, prefixedWasm, params.MaxWasmSize, params.MaxFragmentCount, isActivation) } - if state.IsStylusProgramFragment(prefixedWasm) { + if state.IsStylusFragmentPrefix(prefixedWasm) { return nil, errors.New("fragmented stylus programs cannot be activated directly; activate the root program instead") } } diff --git a/execution/gethexec/wasmstorerebuilder.go b/execution/gethexec/wasmstorerebuilder.go index 0df2c7bd96..144094a882 100644 --- a/execution/gethexec/wasmstorerebuilder.go +++ b/execution/gethexec/wasmstorerebuilder.go @@ -93,7 +93,7 @@ func RebuildWasmStore(ctx context.Context, wasmStore ethdb.KeyValueStore, execut codeHashBytes := bytes.TrimPrefix(iter.Key(), rawdb.CodePrefix) codeHash := common.BytesToHash(codeHashBytes) code := iter.Value() - if state.IsStylusProgram(code) { + if state.IsStylusDeployableProgramPrefix(code) { if err := programs.SaveActiveProgramToWasmStore(stateDB, codeHash, code, latestHeader.Time, l2Blockchain.Config().DebugMode(), rebuildingStartHeader.Time, targets); err != nil { return fmt.Errorf("error while rebuilding of wasm store, aborting rebuilding: %w", err) } diff --git a/go-ethereum b/go-ethereum index 881f481c42..87b8f6b8c6 160000 --- a/go-ethereum +++ b/go-ethereum @@ -1 +1 @@ -Subproject commit 881f481c42d6f3457e1daabb22e339b0389f78bc +Subproject commit 87b8f6b8c6cfa9dac630279b275ba7d8adbb1155 From e371f381c80bef4719102d7714a32769cc390535 Mon Sep 17 00:00:00 2001 From: Kolby Moroz Liebl <31669092+KolbyML@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:29:35 -0700 Subject: [PATCH 4/7] merge commit --- go-ethereum | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go-ethereum b/go-ethereum index 87b8f6b8c6..bd9b9d14ac 160000 --- a/go-ethereum +++ b/go-ethereum @@ -1 +1 @@ -Subproject commit 87b8f6b8c6cfa9dac630279b275ba7d8adbb1155 +Subproject commit bd9b9d14ac5839e34cfa83d4d93dc78a5014a842 From fdb0be2fcb3a84e5888c5b77659688ba9e633752 Mon Sep 17 00:00:00 2001 From: Kolby Moroz Liebl <31669092+KolbyML@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:07:18 -0700 Subject: [PATCH 5/7] Fix concern --- arbos/programs/programs.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/arbos/programs/programs.go b/arbos/programs/programs.go index 16c85f253b..0eecc758e4 100644 --- a/arbos/programs/programs.go +++ b/arbos/programs/programs.go @@ -387,15 +387,14 @@ func handleRootStylus(statedb vm.StateDB, data []byte, maxSize uint32, maxFragme return wasm, nil } -// Named return parameters allow us to return the zero-value for 'dict' implicitly on error -func getStylusCompressionDict(id byte) (dict arbcompress.Dictionary, err error) { +func getStylusCompressionDict(id byte) (arbcompress.Dictionary, error) { switch id { case 0: return arbcompress.EmptyDictionary, nil case 1: return arbcompress.StylusProgramDictionary, nil default: - return dict, fmt.Errorf("unsupported dictionary type: %d", id) + return 0, fmt.Errorf("unsupported dictionary type: %d", id) } } From 60f4479f76c1ec98ed8490c342a887a3f84da0fd Mon Sep 17 00:00:00 2001 From: Kolby Moroz Liebl <31669092+KolbyML@users.noreply.github.com> Date: Fri, 23 Jan 2026 17:22:49 -0700 Subject: [PATCH 6/7] Bump go-ethereum pin --- go-ethereum | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go-ethereum b/go-ethereum index bd9b9d14ac..2b1022a64a 160000 --- a/go-ethereum +++ b/go-ethereum @@ -1 +1 @@ -Subproject commit bd9b9d14ac5839e34cfa83d4d93dc78a5014a842 +Subproject commit 2b1022a64a1864083fe590f331f5c7c998fcfd63 From de08187cb6b1296ba85637c25c8b5ca21d74650e Mon Sep 17 00:00:00 2001 From: Kolby Moroz Liebl <31669092+KolbyML@users.noreply.github.com> Date: Fri, 23 Jan 2026 17:43:03 -0700 Subject: [PATCH 7/7] fix TestFragmentedContractFailsOnArbOS50 --- system_tests/stylus_contract_limit_increase_test.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/system_tests/stylus_contract_limit_increase_test.go b/system_tests/stylus_contract_limit_increase_test.go index bc4320eb07..fa879fc22e 100644 --- a/system_tests/stylus_contract_limit_increase_test.go +++ b/system_tests/stylus_contract_limit_increase_test.go @@ -21,6 +21,7 @@ import ( "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/state" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" _ "github.com/ethereum/go-ethereum/eth/tracers/js" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/ethdb" @@ -594,10 +595,12 @@ func TestFragmentedContractFailsOnArbOS50(t *testing.T) { }) defer cleanup() - cfg := defaultDeployConfig() - cfg.expectActivation = false - cfg.expectedErr = "unknown stylus version" - deployAndActivateFragmentedContract(t, builder.ctx, auth, builder.L2.Client, cfg) + fragments, _, _ := readFragmentedContractFile(t, rustFile("storage"), 2) + require.Len(t, fragments, 2) + + auth.GasLimit = 32_000_000 + _, err := deployContractForwardError(t, builder.ctx, auth, builder.L2.Client, fragments[0]) + require.ErrorContains(t, err, vm.ErrInvalidCode.Error()) } func TestArbOwnerPublicGetMaxFragmentCountFailsOnArbOS50(t *testing.T) {