Skip to content

Add flag to disable ArbOwner outside on-chain execution#4591

Open
bragaigor wants to merge 10 commits intomasterfrom
braga/disable-arbowner-flag
Open

Add flag to disable ArbOwner outside on-chain execution#4591
bragaigor wants to merge 10 commits intomasterfrom
braga/disable-arbowner-flag

Conversation

@bragaigor
Copy link
Copy Markdown
Contributor

@bragaigor bragaigor commented Apr 1, 2026

  • Add --execution.disable-offchain-arbowner node config flag (default false)
  • When enabled, OwnerPrecompile.Call panics if the run context is not IsExecutedOnChain() (blocks ethcall and gas estimation modes)
  • The flag is an atomic.Bool field on the OwnerPrecompile struct, configured during ExecutionNode.Initialize() via gethhook.GetOwnerPrecompile()

The *OwnerPrecompile reference is held in an unexported var in the gethhook package, exposed via GetOwnerPrecompile(). This is necessary because precompile instances are created during gethhook.init() (a void function that runs at package load time), while node config only becomes available later in ExecutionNode.Initialize(). Since init() cannot return values, the pointer must be held somewhere to bridge that gap. An unexported var with a getter is the smallest surface area — set once, immutable after init, and the actual config (disableOffchain) lives on the OwnerPrecompile struct where it belongs.

closes NIT-4744

- When `DisableOffchainArbOwner` is set to `true` in chain config,
  `OwnerPrecompile.Call` panics
- Defaults to `false` — no behavior change unless explicitly opted in

Signed-off-by: Igor Braga <5835477+bragaigor@users.noreply.github.com>
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 1, 2026

Codecov Report

❌ Patch coverage is 22.22222% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 34.15%. Comparing base (192393a) to head (5060a63).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4591      +/-   ##
==========================================
- Coverage   34.20%   34.15%   -0.05%     
==========================================
  Files         494      494              
  Lines       58926    58943      +17     
==========================================
- Hits        20156    20134      -22     
- Misses      35230    35264      +34     
- Partials     3540     3545       +5     

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

❌ 14 Tests Failed:

Tests completed Failed Passed Skipped
4718 14 4704 0
View the top 3 failed tests by shortest run time
TestPruningDBSizeReduction
Stack Traces | 0.000s run time
=== RUN   TestPruningDBSizeReduction
--- FAIL: TestPruningDBSizeReduction (0.00s)
TestAliasingFlaky
Stack Traces | -0.000s run time
... [CONTENT TRUNCATED: Keeping last 20 lines]
INFO [04-02|21:52:59.304] Writing cached state to disk             block=9  hash=23d8cb..fb12dd root=1659e4..e4729e
INFO [04-02|21:52:59.304] Persisted trie from memory database      nodes=111 flushnodes=0 size=13.48KiB flushsize=0.00B time="174.946µs" flushtime=0s gcnodes=0 gcsize=0.00B gctime="4.6µs"     livenodes=164 livesize=29.87KiB
INFO [04-02|21:52:59.304] Writing cached state to disk             block=8  hash=f70a41..5eb771 root=1015b6..a31f8e
INFO [04-02|21:52:59.304] Persisted trie from memory database      nodes=16  flushnodes=0 size=3.10KiB  flushsize=0.00B time="26.93µs"   flushtime=0s gcnodes=0 gcsize=0.00B gctime=0s          livenodes=148 livesize=26.76KiB
INFO [04-02|21:52:59.304] Writing cached state to disk             block=1  hash=f5dc62..2fafee root=2b3915..838bec
INFO [04-02|21:52:59.304] Persisted trie from memory database      nodes=26  flushnodes=0 size=4.66KiB  flushsize=0.00B time="36.318µs"  flushtime=0s gcnodes=0 gcsize=0.00B gctime=0s          livenodes=122 livesize=22.11KiB
INFO [04-02|21:52:59.304] Writing snapshot state to disk           root=77ae46..2fbcae
INFO [04-02|21:52:59.304] Persisted trie from memory database      nodes=0   flushnodes=0 size=0.00B    flushsize=0.00B time=350ns       flushtime=0s gcnodes=0 gcsize=0.00B gctime=0s          livenodes=122 livesize=22.11KiB
INFO [04-02|21:52:59.304] Starting work on payload                 id=0x0395ca6496c51c00
ERROR[04-02|21:52:59.304] Dangling trie nodes after full cleanup
INFO [04-02|21:52:59.304] Blockchain stopped
INFO [04-02|21:52:59.305] Ethereum protocol stopped
INFO [04-02|21:52:59.305] Transaction pool stopped
INFO [04-02|21:52:59.305] Persisting dirty state                   head=33 root=2a9a87..f9a359 layers=33
INFO [04-02|21:52:59.305] Updated payload                          id=0x0395ca6496c51c00                      number=44 hash=97361f..f18c1a txs=1  withdrawals=0 gas=21000     fees=0.002089422634 root=6c0f02..78f2d0 elapsed="377.671µs"
INFO [04-02|21:52:59.305] Stopping work on payload                 id=0x0395ca6496c51c00                      reason=delivery
INFO [04-02|21:52:59.305] Imported new potential chain segment     number=44 hash=97361f..f18c1a blocks=1  txs=1  mgas=0.021 elapsed="477.819µs" mgasps=43.950   triediffs=214.28KiB triedirty=0.00B
INFO [04-02|21:52:59.305] Chain head was updated                   number=44 hash=97361f..f18c1a root=6c0f02..78f2d0 elapsed="24.706µs"
INFO [04-02|21:52:59.306] Persisted dirty state to disk            size=163.10KiB elapsed="952.745µs"
INFO [04-02|21:52:59.306] Blockchain stopped
TestBatchPosterL1SurplusMatchesBatchGasFlaky
Stack Traces | 0.540s run time
... [CONTENT TRUNCATED: Keeping last 20 lines]
panic: runtime error: invalid memory address or nil pointer dereference [recovered, repanicked]
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x207eaf2]

goroutine 56 [running]:
testing.tRunner.func1.2({0x37e71c0, 0x62039b0})
	/opt/hostedtoolcache/go/1.25.8/x64/src/testing/testing.go:1872 +0x237
testing.tRunner.func1()
	/opt/hostedtoolcache/go/1.25.8/x64/src/testing/testing.go:1875 +0x35b
panic({0x37e71c0?, 0x62039b0?})
	/opt/hostedtoolcache/go/1.25.8/x64/src/runtime/panic.go:783 +0x132
github.com/offchainlabs/nitro/arbnode.(*InboxTracker).GetBatchCount(0x8969900?)
	/home/runner/work/nitro/nitro/arbnode/inbox_tracker.go:210 +0x12
github.com/offchainlabs/nitro/arbnode.(*InboxTracker).FindInboxBatchContainingMessage(0x0, 0x7)
	/home/runner/work/nitro/nitro/arbnode/inbox_tracker.go:225 +0x2f
github.com/offchainlabs/nitro/system_tests.TestBatchPosterL1SurplusMatchesBatchGasFlaky(0xc0003f5180)
	/home/runner/work/nitro/nitro/system_tests/batch_poster_test.go:839 +0x725
testing.tRunner(0xc0003f5180, 0x41b9fd8)
	/opt/hostedtoolcache/go/1.25.8/x64/src/testing/testing.go:1934 +0xea
created by testing.(*T).Run in goroutine 1
	/opt/hostedtoolcache/go/1.25.8/x64/src/testing/testing.go:1997 +0x465

📣 Thoughts on this report? Let Codecov know! | Powered by Codecov

Add `--execution.disable-offchain-arbowner` flag to disable ArbOwner
precompile calls outside on-chain execution

Signed-off-by: Igor Braga <5835477+bragaigor@users.noreply.github.com>
Comment on lines +20 to +25
var arbOwnerPrecompile *precompiles.OwnerPrecompile

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

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!

Signed-off-by: Igor Braga <5835477+bragaigor@users.noreply.github.com>
@bragaigor bragaigor marked this pull request as ready for review April 1, 2026 14:47
@bragaigor bragaigor requested a review from MishkaRogachev April 1, 2026 14:49
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")                                                                                                            
  }

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.

in the internal code - using either IsNonMutating or IsExecutedOnChaon seems to be identical, choose what you will.
Config name is external and should be easy to understand for users. nonmutating seems less understandable than oochain, possibly just disable-arbowner-ethcall or something similar would be best. @joshuacolvin0 your take?

Copy link
Copy Markdown
Contributor Author

@bragaigor bragaigor Apr 2, 2026

Choose a reason for hiding this comment

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

Updated DisableOffchainArbOwner to DisableArbOwnerEthCall and global from disableOffchain to disableEthCall in 5f7da96

) ([]byte, uint64, multigas.MultiGas, error) {
if wrapper.disableOffchain.Load() {
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)

if wrapper.disableOffchain.Load() {
txProcessor, ok := evm.ProcessingHook.(*arbos.TxProcessor)
if !ok || !txProcessor.RunContext().IsExecutedOnChain() {
return nil, 0, multigas.ComputationGas(gasSupplied), errors.New("ArbOwner precompile is disabled outside on-chain execution")
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.

Why returning 0 and multigas.ComputationGas(gasSupplied)?
I am not sure what is the expected behavior, related to those gas values, for eth_estimateGas if it returns an error.

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.

I think it's best to follow what the rest of Call does so modifying to return:

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

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.

obs: I think this likely not matter because this is only used in non mutating calls, but those returned gas values are related to when a arbowner is calling the arbowner precompile.
If another kind of user calls the precompile other gas values are being returned.

Signed-off-by: Igor Braga <5835477+bragaigor@users.noreply.github.com>
Signed-off-by: Igor Braga <5835477+bragaigor@users.noreply.github.com>
@bragaigor bragaigor assigned joshuacolvin0 and unassigned bragaigor Apr 2, 2026
Signed-off-by: Igor Braga <5835477+bragaigor@users.noreply.github.com>
Signed-off-by: Igor Braga <5835477+bragaigor@users.noreply.github.com>
Signed-off-by: Igor Braga <5835477+bragaigor@users.noreply.github.com>
@joshuacolvin0 joshuacolvin0 removed their assignment Apr 2, 2026
if ownerPC, ok := arbosPrecompiles[types.ArbOwnerAddress].(*precompiles.OwnerPrecompile); ok {
arbOwnerPrecompile = ownerPC
} else {
log.Error("ArbOwner precompile is not an *OwnerPrecompile, disable-arbowner-ethcall flag will not work")
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.

We should panic 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.

swapped to panic in 5060a63

Comment on lines +94 to +96
if wrapper.disableEthCall {
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.

Can't this be simplified here?

Suggested change
if wrapper.disableEthCall {
txProcessor, ok := evm.ProcessingHook.(*arbos.TxProcessor)
if !ok || !txProcessor.RunContext().IsExecutedOnChain() {
if wrapper.disableEthCall && evm.ProcessingHook.MsgIsNonMutating() {

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.

Good point, I went with IsExecutedOnChain because it's more "human readable" but I like the simplification

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.

simplified in 5060a63

if wrapper.disableOffchain.Load() {
txProcessor, ok := evm.ProcessingHook.(*arbos.TxProcessor)
if !ok || !txProcessor.RunContext().IsExecutedOnChain() {
return nil, 0, multigas.ComputationGas(gasSupplied), errors.New("ArbOwner precompile is disabled outside on-chain execution")
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.

obs: I think this likely not matter because this is only used in non mutating calls, but those returned gas values are related to when a arbowner is calling the arbowner precompile.
If another kind of user calls the precompile other gas values are being returned.

}
}

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

@bragaigor bragaigor requested a review from diegoximenes April 2, 2026 21:39
@bragaigor bragaigor assigned diegoximenes and unassigned bragaigor Apr 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants