Skip to main content

How to customize your Orbit chain's precompiles

caution

Customizations require expertise

Customizing your chain is a core benefit of building with Arbitrum Orbit. We strongly recommend that teams interested in customizations work alongside a partner with ArbOS and Nitro software expertise, such as a Rollup-as-a-Service team.

Working alongside an experienced Orbit operator can help your team navigate the complex tradeoff space of rollup customizations, which can include performance, security, and cost considerations. Offchain Labs is positioned to train and enable Rollup-as-a-Service in their work with clients to scale support to the Orbit ecosystem as a whole. As such, Offchain Labs does not necessarily have the capacity to review code changes made by individual Orbit chains.

We encourage you to leverage your in-house expertise, collaborate with expert partners, and allocate appropriate resources for both an initial implementation (including an audit) and ongoing maintenance and security management of your customization.

caution

The guidance in this document will only work if you use eth_call to call the new precompiles. If you call them from other contracts or add non-view/pure methods, this approach will break the block validation.

To support these additional use-cases, follow the instructions described in How to customize your Orbit chain's behavior.

There are five primary ways to customize your chain's precompiles:

  1. Add new methods to an existing precompile.
  2. Create a new precompile.
  3. Define a new event.
  4. Customize gas usage for a specific method.
  5. Call and modify state.

Prerequisites

Clone the Nitro repository before you begin:

git clone --branch v3.2.1 <https://github.com/OffchainLabs/nitro.git>
cd nitro
git submodule update --init --recursive --force

Option 1: Add new methods to an existing precompile

Using your favorite code editor, open an existing precompile from the precompiles implementation directory, /precompiles. We'll use ArbSys.go as an example. Open the corresponding Go implementation file (ArbSys.go) and add a simple SayHi method:

func (con *ArbSys) SayHi(c ctx, evm mech) (string, error) {
return "hi", nil
}

Then, open the corresponding Solidity interface file (ArbSys.sol) from the precompiles interface directory, /src/precompiles, and add the required interface. Ensure that the method name on the interface matches the name of the function you introduced in the previous step, camelCased:

function sayHi() external view returns(string memory);

Next, follow the steps in How to customize your Orbit chain's behavior to build a modified Arbitrum Nitro node docker image and run it.

info

Note that the instructions provided in How to run a full node will not work with your Orbit node. See Optional parameters (Orbit) for Orbit-specific CLI flags.

Once your node is running, you can call ArbSys.sol either directly using curl, or through Foundry's cast call.

Call your function directly using curl

curl http://localhost:8449 \
-X POST \
-H "Content-Type: application/json" \
--data '{"method":"eth_call","params":[{"from":null,"to":"0x0000000000000000000000000000000000000064","data":"0x0c49c36c"}, "latest"],"id":1,"jsonrpc":"2.0"}'

You should see something like this:

{"jsonrpc":"2.0","id":1,"result":"0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000026869000000000000000000000000000000000000000000000000000000000000"}

0x6869 is the hex-encoded utf8 representation of hi, which you'll see embedded in the result hex string.

Call your function using Foundry's cast call

cast call 0x0000000000000000000000000000000000000064 "sayHi()(string)”

You should see something like this:

hi

Option 2: Create a new precompile

First, navigate to the precompiles implementation directory, /precompiles, and create a new precompile implementation file called ArbHi.go. We'll define a new method, and we'll give it an address:

package precompiles

// ArbHi provides a friendly greeting to anyone who calls it.
type ArbHi struct {
Address addr // 0x11a, for example
}

func (con *ArbHi) SayHi(c ctx, evm mech) (string, error) {
return "hi", nil
}

Then, update precompile.go to register the new precompile under the Precompiles() method:

insert(MakePrecompile(pgen.ArbHiMetaData, &ArbHi{Address: hex("11a")})) // 0x011a here is an example address

Navigate to the precompiles interface directory, /src/precompiles, create ArbHi.sol, and add the required interface. Ensure that the method name on the interface matches the name of the function you introduced in the previous step, camelCased:

pragma solidity >=0.4.21 <0.9.0;

/// @title Say hi.
/// @notice just for test
/// This custom contract will set on 0x000000000000000000000000000000000000011a since we set it in precompile.go.
interface ArbHi {
function sayHi() external view returns(string memory);
}

Next, follow the steps in How to customize your Orbit chain's behavior to build a modified Arbitrum Nitro node docker image and run it.

info

Note that the instructions provided in How to run a full node will not work with your Orbit node. See Optional parameters (Orbit) for Orbit-specific CLI flags.

Once your node is running, you can call ArbHi.sol either directly using curl, or through Foundry's cast call.

Call your function directly using curl

curl http://localhost:8449 \
-X POST \
-H "Content-Type: application/json" \
--data '{"method":"eth_call","params":[{"from":null,"to":"0x000000000000000000000000000000000000011a","data":"0x0c49c36c"}, "latest"],"id":1,"jsonrpc":"2.0"}'

You should see something like this:

{"jsonrpc":"2.0","id":1,"result":"0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000026869000000000000000000000000000000000000000000000000000000000000"}

Call your function using Foundry's cast call

cast call 0x000000000000000000000000000000000000011a "sayHi()(string)”

You should see something like this:

hi

Option 3: Define a new event

We'll reuse the Arbsys precompile from Option 1 above to demonstrate how to emit a simple Hi event from the SayHi method in ArbSys.sol.

First, go to the precompiles implementation directory, find ArbSys.go, and edit the ArbSys struct:

// ArbSys provides system-level functionality for interacting with L1 and understanding the call stack.
type ArbSys struct {
Address addr // 0x64
L2ToL1Tx func(ctx, mech, addr, addr, huge, huge, huge, huge, huge, huge, []byte) error
L2ToL1TxGasCost func(addr, addr, huge, huge, huge, huge, huge, huge, []byte) (uint64, error)
SendMerkleUpdate func(ctx, mech, huge, bytes32, huge) error
SendMerkleUpdateGasCost func(huge, bytes32, huge) (uint64, error)
InvalidBlockNumberError func(huge, huge) error

// deprecated event
L2ToL1Transaction func(ctx, mech, addr, addr, huge, huge, huge, huge, huge, huge, huge, []byte) error
L2ToL1TransactionGasCost func(addr, addr, huge, huge, huge, huge, huge, huge, huge, []byte) (uint64, error)

// Add your customize event here:
Hi func(ctx, mech, addr) error
// This is needed and will tell you how much gas it will cost, the param is the same as your event but without the first two (ctx, mech), the return param is always (uint64, error)
HiGasCost func(addr) (uint64, error)
}

Then add the event to the SayHi method:

func (con *ArbSys) SayHi(c ctx, evm mech) (string, error) {
err := con.Hi(c, evm, c.caller)
return "hi", err
}

Now navigate to the precompiles interface directory, open Arbsys.sol, and add the required interface. Ensure that the event name on the interface matches the name of the function you introduced in ArbSys struct in the previous step:

event Hi(address caller);

If you want to index the parameter of the event (if you want to filter by that parameter in the future, for example), just add indexed to the Solidity interface:

event Hi(address indexed caller);

Our function now emits an event, which means that when calling it, the state will change and a gas cost will be incurred. So we have to remove the view function behavior:

function sayHi() external returns(string memory);

Next, build Nitro by following the instructions in How to build Nitro locally. Note that if you've already built the Docker image, you still need run the last step to rebuild.

Run Nitro with the following command:

docker run --rm -it  -v /some/local/dir/arbitrum:/home/user/.arbitrum -p 0.0.0.0:8547:8547 -p 0.0.0.0:8548:8548 offchainlabs/nitro-node:v3.2.1-d81324d --parent-chain.connection.url=<YourParentChainUrl> --chain.id=<YourOrbitChainId> --http.api=net,web3,eth,debug --http.corsdomain=* --http.addr=0.0.0.0 --http.vhosts=*
info

Note that the instructions provided in How to run a full node will not work with your Orbit node. See Optional parameters (Orbit) for Orbit-specific CLI flags.

Send the transaction and get the transaction receipt

To send a transaction to ArbSys, we need to include a gas cost, because the function is no longer a view/pure function:

cast send 0x0000000000000000000000000000000000000064 "sayHi()(string)"

Call eth_getTransactionReceipt with the returned transaction hash result. You should see something like this:

{"jsonrpc":"2.0","id":1,"result":{"blockHash":"Your_blockHash","blockNumber":"Your_blockNumber","contractAddress":null,"cumulativeGasUsed":"0x680b","effectiveGasPrice":"0x5f5e100","from":"Your_address","gasUsed":"0x680b","gasUsedForL1":"0xe35","l1BlockNumber":"l1_blockNumber","logs":[{"address":"0x0000000000000000000000000000000000000064","topics":["0xa9378d5bd800fae4d5b8d4c6712b2b64e8ecc86fdc831cb51944000fc7c8ecfa","0x000000000000000000000000{Your_address}"],"data":"0x","blockNumber":"Your_blockNumber","transactionHash":"Your_txHash","transactionIndex":"0x1","blockHash":"Your_blockHash","logIndex":"0x0","removed":false}],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000100000000000000040000000000000080004000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000004000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000","status":"0x1","to":"0x0000000000000000000000000000000000000064","transactionHash":"Your_txHash","transactionIndex":"0x1","type":"0x2"}}

Note the logs field within the transaction receipt:

"logs":[
{
"address":"0x0000000000000000000000000000000000000064",
"topics":[
"0xa9378d5bd800fae4d5b8d4c6712b2b64e8ecc86fdc831cb51944000fc7c8ecfa",
"0x000000000000000000000000{Your_address}"
],
"data":"0x",
"blockNumber":"0x40",
"transactionHash":"{Your_txHash}",
"transactionIndex":"0x1",
"blockHash":"0x0b367d705002b3575db99354a0964c033f929f26f4442ed347e47ae43a8f28e4",
"logIndex":"0x0",
"removed":false
}
]

Option 4: Customize gas usage for a specific method

The above instructions demonstrate how you can define a new precompile function. However, if this new function is simply defined without performing gas collection within the function, your precompile will be vulnerable to Denial-of-Service (DOS) attacks. These attacks exploit the function by flooding it with excessive requests without bearing the computational cost.

To deter this type of attack, you can implement a gas collection mechanism within your precompile. The event itself doesn't need to specify the gas cost; the program will calculate the gas cost when the event's execution is initially triggered.

In addition to introducing gas costs where they don't exist, you can also customize gas costs where they're already being incurred. To demonstrate, consider the GetBalance method in ArbInfo.go:

// GetBalance retrieves an account's balance
func (con ArbInfo) GetBalance(c ctx, evm mech, account addr) (huge, error) {
if err := c.Burn(params.BalanceGasEIP1884); err != nil {
return nil, err
}
return evm.StateDB.GetBalance(account), nil
}

The purpose of this method is to retrieve the balance of an address. As defined in EIP1884, the operation code (opcode) for obtaining the address balance has an associated gas cost of 700 gas. The function accounts for this cost by deducting the specified amount of gas, indicated by the protocol constant BalanceGasEIP1884, which is set to 700, through the call to c.Burn(int64).

To customize the gas cost, let's implement an alternative to GetBalance, called GetBalanceCustom:

// GetBalance retrieves an account's balance
func (con ArbInfo) GetBalanceCustom(c ctx, evm mech, account addr) (huge, error) {
gasForBalanceCall := uint64(300)
if err := c.Burn(gasForBalanceCall); err != nil {
return evm.StateDB.GetBalance(account), err
}
return balance, nil
}

To register this new precompile method, refer to Option 1 above.

Next, build Nitro by following the instructions in How to build Nitro locally. Note that if you've already built the Docker image, you still need run the last step to rebuild.

Run Nitro with the following command:

docker run --rm -it  -v /some/local/dir/arbitrum:/home/user/.arbitrum -p 0.0.0.0:8547:8547 -p 0.0.0.0:8548:8548 offchainlabs/nitro-node:v3.2.1-d81324d --parent-chain.connection.url=<YourParentChainUrl> --chain.id=<YourOrbitChainId> --http.api=net,web3,eth,debug --http.corsdomain=* --http.addr=0.0.0.0 --http.vhosts=*
info

Note that the instructions provided in How to run a full node will not work with your Orbit node. See Optional parameters (Orbit) for Orbit-specific CLI flags.

Send the transaction and get the transaction receipt

In order to obtain the gas used, we can use the eth_sendRawTransaction RPC method to test execution on the chain. First, call:

cast send 0x0000000000000000000000000000000000000065 "GetBalance()({Any_Address})"

Then, call:

cast send 0x0000000000000000000000000000000000000065 "GetBalanceCustom()({Any_Address})"

The two responses will look like this, respectively:

Result 1:

{
"jsonrpc":"2.0",
"id":1,
"result":{
"blockHash":"{Your_blockHash}",
"blockNumber":"0x15",
"contractAddress":null,
"cumulativeGasUsed":"0x638f",
"effectiveGasPrice":"0x5f5e100",
"from":"{Your_address}",
"gasUsed":"0x638f",
"gasUsedForL1":"0x9f5",
"l1BlockNumber":"0x979a02",
"logs":[

],
"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"status":"0x1",
"to":"0x0000000000000000000000000000000000000065",
"transactionHash":"{Your_txHash}",
"transactionIndex":"0x1",
"type":"0x2"
}
}

Result 2:

{
"jsonrpc":"2.0",
"id":1,
"result":{
"blockHash":"{Your_blockHash}",
"blockNumber":"0x16",
"contractAddress":null,
"cumulativeGasUsed":"0x61ff",
"effectiveGasPrice":"0x5f5e100",
"from":"{Your_address}",
"gasUsed":"0x61ff",
"gasUsedForL1":"0x9f5",
"l1BlockNumber":"0x979a08",
"logs":[

],
"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"status":"0x1",
"to":"0x0000000000000000000000000000000000000065",
"transactionHash":"{Your_txHash}",
"transactionIndex":"0x1",
"type":"0x2"
}
}

Here we can see that the gas cost incurred by the execution of the first transaction is gasUsed - gasUsedForL1 = 22938. Similarly, the gas cost incurred by the execution of the second transaction is 22538. If you subtract the two, the result is 400, as expected.

To learn more about the gas cost model, see how to estimate gas.

Option 5: Call and modify state

In this example, we'll demonstrate how to read from and write to a precompile contract's ArbOS state.

First, open the arbosstate.go file and locate the ArbosState structure. This is where ArbOS state is defined.

Define a state key called myNumber of type storage.StorageBackedUint64. You can find more types in storage.go:

type ArbosState struct {
// Other states
infraFeeAccount storage.StorageBackedAddress
brotliCompressionLevel storage.StorageBackedUint64 // brotli compression level used for pricing
backingStorage *storage.Storage
Burner burn.Burner
myNumber storage.StorageBackedUint64 // this is what we added
}

Next, define the offset of your newly added state (tip: add it to the end so it won't affect other states):

const (
versionOffset Offset = iota
upgradeVersionOffset
upgradeTimestampOffset
networkFeeAccountOffset
chainIdOffset
genesisBlockNumOffset
infraFeeAccountOffset
brotliCompressionLevelOffset
myNumberOffset // define the offset of your new state here
)

Then, initialize the state under the OpenArbosState and InitializeArbosState methods:

OpenArbosState:

return &ArbosState{
// other states
backingStorage.OpenStorageBackedAddress(uint64(infraFeeAccountOffset)),
backingStorage.OpenStorageBackedUint64(uint64(brotliCompressionLevelOffset)),
backingStorage,
burner,
backingStorage.OpenStorageBackedUint64(uint64(myNumberOffset)), // define your new state here
}, nil

InitializeArbosState:

	_ = sto.SetUint64ByUint64(uint64(versionOffset), 1) // initialize to version 1; upgrade at end of this func if needed
_ = sto.SetUint64ByUint64(uint64(upgradeVersionOffset), 0)
_ = sto.SetUint64ByUint64(uint64(upgradeTimestampOffset), 0)
_ = sto.SetUint64ByUint64(uint64(myNumberOffset), 0) // initialize your new state around here

Next, define your getter and setter::

func (state *ArbosState) SetNewMyNumber(
newNumber uint64,
) error {
return state.myNumber.Set(newNumber)
}

func (state *ArbosState) GetMyNumber() (uint64, error) {
return state.myNumber.Get()
}

Next, head back to the precompiles directory and create a new ArbHi.go (introduced in Option 2). This time, we'll add two new methods to read and write the ArbOS state:

package precompiles

// ArbHi provides a friendly greeting to anyone who calls it.
type ArbHi struct {
Address addr // 0x11a, for example
}

func (con *ArbHi) SayHi(c ctx, evm mech) (string, error) {
return "hi", nil
}

func (con *ArbHi) GetNumber(c ctx, evm mech) (uint64, error) {
return c.State.GetMyNumber()
}

func (con *ArbHi) SetNumber(c ctx, evm mech, newNumber uint64) error {
return c.State.SetNewMyNumber(newNumber)
}

Follow the procedure detailed in Option 2 in order to add this new precompile contract, and then run your node.

Your smart contract interface should look like this:

pragma solidity >=0.4.21 <0.9.0;

/// @title Say hi.
/// @notice just for test
/// This custom contract will set on 0x000000000000000000000000000000000000011a since we set it in precompile.go.
interface ArbHi {
function sayHi() external view returns(string memory);
function getNumber() external view returns(uint64);
function setNumber(uint64) external;
}

Send the transaction and get the transaction receipt

To send a transaction to ArbSys, we need to include a gas cost, because the function is no longer a view/pure function:

cast send 0x000000000000000000000000000000000000011a "setNumber()" "2"

Get results from foundry cast

cast call 0x000000000000000000000000000000000000011a "getNumber()(uint64)”

You should see something like this:

2

Incorporate your changes to precompile into the ArbOS upgrade

If you do not customize the precompile before launching your Orbit network, please continue to follow [customize arbos] to perform an ArbOS version control to avoid blockchain reorg.