Skip to content
Open
2 changes: 2 additions & 0 deletions changelog/bragaigor-nit-4744.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Configuration
- Add `--execution.disable-arbowner-ethcall` flag to disable ArbOwner precompile calls outside on-chain execution
11 changes: 11 additions & 0 deletions execution/gethexec/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"github.com/offchainlabs/nitro/consensus/consensusrpcclient"
"github.com/offchainlabs/nitro/execution"
executionrpcserver "github.com/offchainlabs/nitro/execution/rpcserver"
"github.com/offchainlabs/nitro/gethhook"
"github.com/offchainlabs/nitro/solgen/go/precompilesgen"
"github.com/offchainlabs/nitro/timeboost"
"github.com/offchainlabs/nitro/util"
Expand Down Expand Up @@ -140,6 +141,7 @@ type Config struct {
ExposeMultiGas bool `koanf:"expose-multi-gas"`
RPCServer rpcserver.Config `koanf:"rpc-server"`
ConsensusRPCClient rpcclient.ClientConfig `koanf:"consensus-rpc-client" reload:"hot"`
DisableArbOwnerEthCall bool `koanf:"disable-arbowner-ethcall"`

forwardingTarget string
}
Expand Down Expand Up @@ -191,6 +193,7 @@ func ConfigAddOptions(prefix string, f *pflag.FlagSet) {
f.Uint64(prefix+".block-metadata-api-cache-size", ConfigDefault.BlockMetadataApiCacheSize, "size (in bytes) of lru cache storing the blockMetadata to service arb_getRawBlockMetadata")
f.Uint64(prefix+".block-metadata-api-blocks-limit", ConfigDefault.BlockMetadataApiBlocksLimit, "maximum number of blocks allowed to be queried for blockMetadata per arb_getRawBlockMetadata query. Enabled by default, set 0 to disable the limit")
f.Bool(prefix+".expose-multi-gas", false, "experimental: expose multi-dimensional gas in transaction receipts")
f.Bool(prefix+".disable-arbowner-ethcall", ConfigDefault.DisableArbOwnerEthCall, "disable ArbOwner precompile calls outside on-chain execution (ethcall, gas estimation)")
LiveTracingConfigAddOptions(prefix+".vmtrace", f)
rpcserver.ConfigAddOptions(prefix+".rpc-server", "execution", f)
rpcclient.RPCClientAddOptions(prefix+".consensus-rpc-client", f, &ConfigDefault.ConsensusRPCClient)
Expand Down Expand Up @@ -230,6 +233,7 @@ var ConfigDefault = Config{
BlockMetadataApiBlocksLimit: 100,
VmTrace: DefaultLiveTracingConfig,
ExposeMultiGas: false,
DisableArbOwnerEthCall: false,

RPCServer: rpcserver.DefaultConfig,
ConsensusRPCClient: rpcclient.ClientConfig{
Expand Down Expand Up @@ -442,6 +446,13 @@ func (n *ExecutionNode) MarkFeedStart(to arbutil.MessageIndex) containers.Promis

func (n *ExecutionNode) Initialize(ctx context.Context) error {
config := n.configFetcher.Get()
if config.DisableArbOwnerEthCall {
ownerPC := gethhook.GetOwnerPrecompile()
if ownerPC == nil {
return fmt.Errorf("cannot enable disable-arbowner-ethcall: ArbOwner precompile not found")
}
ownerPC.SetDisableEthCall(true)
}
err := n.ExecEngine.Initialize(config.Caching.StylusLRUCacheCapacity, &config.StylusTarget)
if err != nil {
return fmt.Errorf("error initializing execution engine: %w", err)
Expand Down
17 changes: 16 additions & 1 deletion gethhook/geth-hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,22 @@ import (
"github.com/ethereum/go-ethereum/arbitrum/multigas"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/log"

"github.com/offchainlabs/nitro/arbos"
"github.com/offchainlabs/nitro/precompiles"
)

// arbOwnerPrecompile holds the *OwnerPrecompile retrieved from the precompile map during init().
// ExecutionNode.Initialize() configures it later when the node config is available.
var arbOwnerPrecompile *precompiles.OwnerPrecompile

func GetOwnerPrecompile() *precompiles.OwnerPrecompile {
return arbOwnerPrecompile
}

Comment on lines +23 to +28
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The *OwnerPrecompile reference in gethhook is an unexported package-level variable, which we'd normally avoid. Here's why it's necessary:

Precompile instances are created during gethhook.init() — a void function that runs automatically at package load time. Node config (DisableOffchainArbOwner) only becomes available much later in ExecutionNode.Initialize(). Since init() can't return values and has no caller to hand the instance to, the pointer must be held somewhere between creation and configuration. The alternatives we evaluated:

  • storing it in chain config (wrong: this is a node-level concern, not consensus state)
  • a freestanding global atomic bool (worse: config detached from the object it belongs to)
  • or looking it up from the VM precompile maps (fragile: requires unwrapping across multiple layers and picking an arbitrary ArbOS version map)

all the above were all strictly worse. The unexported var with an exported getter is the smallest surface: set exactly once in init(), immutable after that, and the actual config flag (disableOffchain) lives on the OwnerPrecompile struct where it belongs.

In more detail:

  1. gethhook/geth-hook.go init() — runs at package load time (triggered in gethexec/blockchain.go importing gethhook). This is where precompiles.Precompiles() is called, which creates the *OwnerPrecompile instance. That instance gets wrapped in ArbosPrecompileWrapper and stored in the VM's global precompile maps (vm.PrecompiledContractsBeforeArbOS30, etc.). After init() returns, nobody holds a direct reference to the *OwnerPrecompile — it's buried inside the wrapper inside the VM maps.
  2. gethexec.CreateExecutionNode() — runs much later, when the node is actually being set up. This is where configFetcher (which has the DisableOffchainArbOwner flag) first becomes available.
  3. ExecutionNode.Initialize() — called after CreateExecutionNode. This is where we want to call ownerPC.SetDisableOffchain(config.DisableOffchainArbOwner). But we need the *OwnerPrecompile pointer to do that.

The gap: the instance is created in step 1, the config arrives in step 3, and there's no object that naturally carries the pointer from 1 to 3. Hence the global

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice solve!

type ArbosPrecompileWrapper struct {
inner precompiles.ArbosPrecompile
}
Expand Down Expand Up @@ -59,7 +68,13 @@ func init() {

// process arbos precompiles
precompileErrors := make(map[[4]byte]abi.Error)
for addr, precompile := range precompiles.Precompiles() {
arbosPrecompiles := precompiles.Precompiles()
if ownerPC, ok := arbosPrecompiles[types.ArbOwnerAddress].(*precompiles.OwnerPrecompile); ok {
arbOwnerPrecompile = ownerPC
} else {
panic("ArbOwner precompile is not an *OwnerPrecompile, disable-arbowner-ethcall flag will not work")
}
for addr, precompile := range arbosPrecompiles {
for _, errABI := range precompile.Precompile().GetErrorABIs() {
precompileErrors[[4]byte(errABI.ID.Bytes())] = errABI
}
Expand Down
13 changes: 11 additions & 2 deletions precompiles/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ func (wrapper *DebugPrecompile) Name() string {

// OwnerPrecompile is a precompile wrapper for those only chain owners may use
type OwnerPrecompile struct {
precompile ArbosPrecompile
emitSuccess func(mech, bytes4, addr, []byte) error
precompile ArbosPrecompile
emitSuccess func(mech, bytes4, addr, []byte) error
disableEthCall bool
}

func ownerOnly(address addr, impl ArbosPrecompile, emit func(mech, bytes4, addr, []byte) error) (addr, ArbosPrecompile) {
Expand All @@ -72,6 +73,10 @@ func ownerOnly(address addr, impl ArbosPrecompile, emit func(mech, bytes4, addr,
}
}

func (wrapper *OwnerPrecompile) SetDisableEthCall(disable bool) {
wrapper.disableEthCall = disable
}

func (wrapper *OwnerPrecompile) Address() common.Address {
return wrapper.precompile.Address()
}
Expand All @@ -85,6 +90,10 @@ func (wrapper *OwnerPrecompile) Call(
gasSupplied uint64,
evm *vm.EVM,
) ([]byte, uint64, multigas.MultiGas, error) {
if wrapper.disableEthCall && evm.ProcessingHook.MsgIsNonMutating() {
return nil, gasSupplied, multigas.ZeroGas(), errors.New("ArbOwner precompile is disabled outside on-chain execution")
}

con := wrapper.precompile

burner := &Context{
Expand Down
85 changes: 85 additions & 0 deletions system_tests/precompile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import (
"math/big"
"slices"
"sort"
"strings"
"testing"
"time"

"github.com/google/go-cmp/cmp"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
Expand All @@ -26,6 +28,7 @@ import (
"github.com/offchainlabs/nitro/arbos/burn"
"github.com/offchainlabs/nitro/arbos/l1pricing"
"github.com/offchainlabs/nitro/cmd/chaininfo"
"github.com/offchainlabs/nitro/gethhook"
"github.com/offchainlabs/nitro/precompiles"
"github.com/offchainlabs/nitro/solgen/go/localgen"
"github.com/offchainlabs/nitro/solgen/go/precompilesgen"
Expand Down Expand Up @@ -1357,3 +1360,85 @@ func TestArbDebugOverwriteContractCode(t *testing.T) {
t.Fatal("expected code B to be", testCodeB, "got", code)
}
}

func TestDisableArbOwnerEthCall(t *testing.T) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It would be nice to also have a test to check that eth_call/eth_estimateGas, with arbowner calls, work fine when DisableArbOwnerEthCall is set to false.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added in 5060a63

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

builder := NewNodeBuilder(ctx).DefaultConfig(t, false).DontParalellise()
builder.execConfig.DisableArbOwnerEthCall = true
cleanup := builder.Build(t)
// The DisableArbOwnerEthCall flag is set on the global OwnerPrecompile singleton,
// so it persists across tests running in the same process. We must:
// 1. Not run in parallel (DontParalellise above) to avoid affecting concurrent tests
// 2. Reset the flag on cleanup to avoid affecting subsequent tests
defer func() {
gethhook.GetOwnerPrecompile().SetDisableEthCall(false)
cleanup()
}()

arbOwnerABI, err := precompilesgen.ArbOwnerMetaData.GetAbi()
Require(t, err)
calldata, err := arbOwnerABI.Pack("getAllChainOwners")
Require(t, err)
arbOwnerAddr := types.ArbOwnerAddress

expectedErrMsg := "ArbOwner precompile is disabled outside on-chain execution"

// eth_call should fail
_, err = builder.L2.Client.CallContract(ctx, ethereum.CallMsg{
To: &arbOwnerAddr,
Data: calldata,
}, nil)
if err == nil || !strings.Contains(err.Error(), expectedErrMsg) {
Fatal(t, "eth_call to ArbOwner expected error containing", expectedErrMsg, "got", err)
}

// eth_estimateGas should fail
_, err = builder.L2.Client.EstimateGas(ctx, ethereum.CallMsg{
To: &arbOwnerAddr,
Data: calldata,
})
if err == nil || !strings.Contains(err.Error(), expectedErrMsg) {
Fatal(t, "eth_estimateGas to ArbOwner expected error containing", expectedErrMsg, "got", err)
}

// On-chain transaction should still succeed
auth := builder.L2Info.GetDefaultTransactOpts("Owner", ctx)
auth.GasLimit = 32_000_000
arbOwner, err := precompilesgen.NewArbOwner(arbOwnerAddr, builder.L2.Client)
Require(t, err)
tx, err := arbOwner.AddChainOwner(&auth, common.HexToAddress("0xdeadbeef"))
Require(t, err)
_, err = builder.L2.EnsureTxSucceeded(tx)
Require(t, err)
}

func TestArbOwnerEthCallEnabled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

builder := NewNodeBuilder(ctx).DefaultConfig(t, false).DontParalellise()
cleanup := builder.Build(t)
defer cleanup()

arbOwnerABI, err := precompilesgen.ArbOwnerMetaData.GetAbi()
Require(t, err)
calldata, err := arbOwnerABI.Pack("getAllChainOwners")
Require(t, err)
arbOwnerAddr := types.ArbOwnerAddress

// eth_call should succeed when DisableArbOwnerEthCall is false (default)
_, err = builder.L2.Client.CallContract(ctx, ethereum.CallMsg{
To: &arbOwnerAddr,
Data: calldata,
}, nil)
Require(t, err)

// eth_estimateGas should succeed when DisableArbOwnerEthCall is false (default)
_, err = builder.L2.Client.EstimateGas(ctx, ethereum.CallMsg{
To: &arbOwnerAddr,
Data: calldata,
})
Require(t, err)
}
Loading