Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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-offchain-arbowner` 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"`
DisableOffchainArbOwner bool `koanf:"disable-offchain-arbowner"`
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.

If you check MessageRunContext, you are going to see that NonMutating happens to be the negation of ExecutedOnChain.
So using non mutating here can be more appropriate than using offchain.

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.

Yeah and furthermore IsNonMutating() and !IsExecutedOnChain() are equivalent:

func (c *MessageRunContext) IsNonMutating() bool {
	return c.runMode == messageGasEstimationMode || c.runMode == messageEthcallMode
}
...
func (c *MessageRunContext) IsExecutedOnChain() bool {
	return c.IsCommitMode() || c.runMode == messageReplayMode || c.runMode == messageRecordingMode
}

@joshuacolvin0 please advice if you had any other plans for DisableOffchainArbOwner as using IsNonMutating seems to be doing exactly what DisableOffchainArbOwner. If we went with IsNonMutating then the following check:

 if wrapper.disableOffchain {                                                                                           
      txProcessor, ok := evm.ProcessingHook.(*arbos.TxProcessor)                                                         
      if !ok || !txProcessor.RunContext().IsExecutedOnChain() {
          return nil, gasSupplied, multigas.ZeroGas(), errors.New("ArbOwner precompile is disabled outside on-chain      
  execution")                                                                                                            
      }                                                                                                                  
  }   

would turn into something like:

  txProcessor, ok := evm.ProcessingHook.(*arbos.TxProcessor)                                                             
  if !ok || txProcessor.MsgIsNonMutating() {                                                                           
      return nil, gasSupplied, multigas.ZeroGas(), errors.New("ArbOwner precompile is disabled outside on-chain 
  execution")                                                                                                            
  }


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-offchain-arbowner", ConfigDefault.DisableOffchainArbOwner, "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,
DisableOffchainArbOwner: 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.DisableOffchainArbOwner {
ownerPC := gethhook.GetOwnerPrecompile()
if ownerPC == nil {
return fmt.Errorf("cannot enable disable-offchain-arbowner: ArbOwner precompile not found")
}
ownerPC.SetDisableOffchain(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

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 {
log.Error("ArbOwner precompile is not an *OwnerPrecompile, disable-offchain-arbowner flag will not work")
}
for addr, precompile := range arbosPrecompiles {
for _, errABI := range precompile.Precompile().GetErrorABIs() {
precompileErrors[[4]byte(errABI.ID.Bytes())] = errABI
}
Expand Down
17 changes: 15 additions & 2 deletions precompiles/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/params"

"github.com/offchainlabs/nitro/arbos"
"github.com/offchainlabs/nitro/arbos/arbosState"
"github.com/offchainlabs/nitro/arbos/util"
)
Expand Down Expand Up @@ -61,8 +62,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
disableOffchain bool
}

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

func (wrapper *OwnerPrecompile) SetDisableOffchain(disable bool) {
wrapper.disableOffchain = disable
}

func (wrapper *OwnerPrecompile) Address() common.Address {
return wrapper.precompile.Address()
}
Expand All @@ -85,6 +91,13 @@ func (wrapper *OwnerPrecompile) Call(
gasSupplied uint64,
evm *vm.EVM,
) ([]byte, uint64, multigas.MultiGas, error) {
if wrapper.disableOffchain {
txProcessor, ok := evm.ProcessingHook.(*arbos.TxProcessor)
if !ok || !txProcessor.RunContext().IsExecutedOnChain() {
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.

The task mentions to use IsExecutedOnChain.
Check with who created the task if you can use evm.ProcessinHook.MsgIsNonMutating() instead, which today is the negation of IsExecutedOnChain.
This way you avoid casting.

If not possible then split this if in two.
One to return an error due to casting, and another to return the error that is being proposed here.

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.

related to #4591 (comment)

return nil, gasSupplied, multigas.ZeroGas(), errors.New("ArbOwner precompile is disabled outside on-chain execution")
}
}

con := wrapper.precompile

burner := &Context{
Expand Down
53 changes: 53 additions & 0 deletions system_tests/precompile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"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 +27,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 +1359,54 @@ func TestArbDebugOverwriteContractCode(t *testing.T) {
t.Fatal("expected code B to be", testCodeB, "got", code)
}
}

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

builder := NewNodeBuilder(ctx).DefaultConfig(t, false).DontParalellise()
builder.execConfig.DisableOffchainArbOwner = true
cleanup := builder.Build(t)
// The DisableOffchainArbOwner 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().SetDisableOffchain(false)
cleanup()
}()

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

// eth_call should fail
_, err = builder.L2.Client.CallContract(ctx, ethereum.CallMsg{
To: &arbOwnerAddr,
Data: calldata,
}, nil)
if err == nil {
Fatal(t, "eth_call to ArbOwner should fail when disable-offchain-arbowner is enabled")
}

// eth_estimateGas should fail
_, err = builder.L2.Client.EstimateGas(ctx, ethereum.CallMsg{
To: &arbOwnerAddr,
Data: calldata,
})
if err == nil {
Fatal(t, "eth_estimateGas to ArbOwner should fail when disable-offchain-arbowner is enabled")
}

// 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)
}