The framework is designed to be flexible and loosely coupled, as explained at the end of proposal functions. However, in some cases, additional customization may be required. Currently, FPS supports four types of proposals, each of which can be further customized to meet specific requirements. This guide will explore an example where the OZ Governor proposal type is customized to implement Arbitrum cross-chain proposals. Arbitrum's governance process involves the use of an OZ governor with a timelock extension on Arbitrum L2 and a simple timelock on L1. The proposal's path is determined by whether it is targeting L1 or L2. Regardless of whether its final destination is an L2 contract, every Arbitrum proposal must go through a settlement process on Layer 1.
The following steps from 1 to 5 are equal no matter which chain the proposal final contract target is deployed:
Proposal is created on L2 Governor
Proposal is voted on and passes
Proposal is queued on L2 timelock
Proposal is executed on L2 timelock after the delay, and therefore initiates a bridge request to L1 inbox by calling the ArbSys precompiled contract
After the bridge delay of 1 week, anyone can call the Bridge contract on L1 using the merkle proof generated for the proposal calldata, effectively scheduling the proposal on the L1 Timelock
Once the proposal is scheduled on the L1 Timelock, there is a three-day delay before it becomes executable. When executed, it can follow two different paths. If the target is an L1 contract, the proposal follows the standard OpenZeppelin Timelock path. For L2 proposals, identified by the target being a Retryable Ticket Magic address, a call to the L1 inbox generates the L2 ticket. Once it is bridged to L2, anyone can execute the ticket. The ticket is responsible for calling the final contract target, which, for proposals that are Arbitrum contract upgrades, will be the Arbitrum Upgrade Executor Contract.
It is worth noting that the Arbitrum governance process has its own specifications and is not a straightforward implementation of the OZ Governor contract. Therefore, it is not possible to directly use the OZ Governor proposal type to simulate an Arbitrum proposal. Customization is needed to accommodate FPS for creating and testing Arbitrum proposals. Thanks to FPS flexibility, this is possible without much extra effort.
Extending FPS for accommodate Arbitrum Governance
The Arbitrum Proposal Type demonstrates the framework's flexibility. Let's go through each of its functions:
setEthForkId(uint256): used to create the Ethereum fork using Foundry as Arbitrum Governance is cross-chain. This function will be set by the child proposal.
/// @notice set eth fork idfunctionsetEthForkId(uint256_forkId) public { ethForkId = _forkId;}
preBuildMock(): deploys the MockOutBox contract and points on the Arbitrum Bridge with the help of the vm.store foundry cheatcode. Additionally, it embeds the MockArbSys bytecode at the Arbitrum sys contract address. As previously mentioned in the overview section, the calldata for the L2 proposal needs to be a call to the sendTxToL1 on the ArbSys precompiled contract. This call requires the L1 timelock contract address as the first parameter and the L1 timelock schedule calldata as the second parameter. The MockArbOutbox is employed to replicate off-chain actions. The bridge ensures that the schedule was initiated by the L2 timelock by calling l1ToL2Sender in the outbox contract. This ensures that the bridge verification is successful, even though no genuine off-chain actions have been executed. These two mock functionalities are crucial for the arbitrum proposal flow, and here, it can be observed how FPS simplifies the simulation of such a complex proposal flow.
/// @title MockArbSys/// @notice a mocked version of the Arbitrum pre-compiled system contract, add additional methods as neededcontract MockArbSys {uint256public ticketId;functionsendTxToL1(address_l1Target,bytesmemory_data ) externalpayablereturns (uint256) { (bool success, ) = _l1Target.call(_data);require(success,"Arbsys: sendTxToL1 failed");return++ticketId; }}// @title MockArbOutbox// @notice Mock arbitrum outbox to return L2 timelock on l2ToL1Sender callcontract MockArbOutbox {functionl2ToL1Sender() externalpurereturns (address) {return0x34d45e99f7D8c45ed05B5cA72D54bbD1fb3F98f0; }}/// @notice mock arb sys precompiled contract on L2/// mock outbox on mainnetfunctionpreBuildMock() publicoverride {// switch to mainnet fork to mock arb outbox vm.selectFork(ethForkId);address mockOutbox =address(newMockArbOutbox()); vm.store( addresses.getAddress("ARBITRUM_BRIDGE"),bytes32(uint256(5)),bytes32(uint256(uint160(mockOutbox))) ); vm.selectFork(primaryForkId);address arbsys =address(newMockArbSys()); vm.makePersistent(arbsys); vm.etch(addresses.getAddress("ARBITRUM_SYS"),address(arbsys).code);}
_validateActions(): Validates proposal actions. An arbitrum proposal should have a single action. This method checks that there is only one action in the proposal and the target contract is not the zero address. Furthermore, it also checks that there are no actions without arguments and value, and if the execution chain is L2, no ETH is transferred (value = 0).
/// @notice Arbitrum proposals should have a single actionfunction_validateActions() internalviewoverride {uint256 actionsLength = actions.length;require( actionsLength ==1,"Arbitrum proposals must have a single action" );require(actions[0].target !=address(0),"Invalid target for proposal");/// if there are no args and no eth, the action is not validrequire( (actions[0].arguments.length ==0&& actions[0].value >0) || actions[0].arguments.length >0,"Invalid arguments for proposal" );// Value is ignored on L2 proposalsif (executionChain == ProposalExecutionChain.ARB_ONE) {require(actions[0].value ==0,"Value must be 0 for L2 execution"); }}
getScheduleTimelockCaldata(): This function returns calldata to schedule proposal actions on the L1 timelock. Calldata is generated based on the execution chain where the proposal will be executed. If the execution chain is the L1, then the build target is the target contract but if the execution chain is L2 chain then target is RETRYABLE_TICKET_MAGIC contract and the build target is encoded in the calldata along with the inbox contract address.
/// @notice get the calldata to schedule the timelock on L1/// the L1 schedule calldata must be the calldata for all arbitrum proposalsfunctiongetScheduleTimelockCaldata()publicviewreturns (bytesmemory scheduleCalldata){// address only used if is a L2 proposaladdress inbox;if (executionChain == ProposalExecutionChain.ARB_ONE) { inbox = arbOneInbox; } elseif (executionChain == ProposalExecutionChain.ARB_NOVA) { inbox = arbNovaInbox; } scheduleCalldata = abi.encodeWithSelector( ITimelockController.schedule.selector,// if the action is to be executed on l1, the target is the actual// target, otherwise it is the magic value that tells that the// proposal must be relayed back to l2 executionChain == ProposalExecutionChain.ETH? actions[0].target: RETRYABLE_TICKET_MAGIC,// target actions[0].value,// value executionChain == ProposalExecutionChain.ETH? actions[0].arguments: abi.encode( // these are the retryable data params// the inbox contract used, should be arb one or nova inbox, addresses.getAddress("ARBITRUM_L2_UPGRADE_EXECUTOR"),// the upgrade executor on the l2 network0,// no value in this upgrade0,// max gas - will be filled in when the retryable is actually executed0,// max fee per gas - will be filled in when the retryable is actually executed actions[0].arguments // calldata created on the build function ),bytes32(0),// no predecessorkeccak256(abi.encodePacked(description())),// salt is prop description minDelay // delay for this proposal );}
getProposalActions(): This function returns proposal actions to propose on arbitrum governor. Arbitrum proposals must have a single action which must be a call to ArbSys address with the L1 timelock schedule calldata.
/// @notice get proposal actions/// @dev Arbitrum proposals must have a single action which must be a call/// to ArbSys address with the l1 timelock schedule calldatafunctiongetProposalActions()publicviewoverridereturns (address[] memory targets,uint256[] memory values,bytes[] memory arguments ){_validateActions();// inner calldata must be a call to schedule on L1Timelockbytesmemory innerCalldata =getScheduleTimelockCaldata(); targets =newaddress[](1); values =newuint256[](1); arguments =newbytes[](1);bytesmemory callData = abi.encodeWithSelector( MockArbSys.sendTxToL1.selector, addresses.getAddress("ARBITRUM_L1_TIMELOCK",1), innerCalldata );// Arbitrum proposals target must be the ArbSys precompiled address targets[0] = addresses.getAddress("ARBITRUM_SYS"); values[0] =0; arguments[0] = callData;}
simulate(): Executes the proposal actions outlined in the build() step. Initial steps to simulate a proposal are the same as simulate() method in OZ Governor Proposal as Arbitrum also uses OZ Governor with timelock for L2 governance. super.simulate() is called at the start of the method. Next, further steps for the proposal simulation are added. These steps can be understood by going through the overview section of this guide and the code snippet below with inline comments.
/// @notice override the OZGovernorProposal simulate function to handle/// the proposal L1 settlementfunctionsimulate() publicoverride {// First part of Arbitrum Governance proposal path follows the OZ// Governor with TimelockController extension super.simulate();// Second part of Arbitrum Governance proposal path is the proposal// settlement on the L1 networkbytesmemory scheduleCalldata =getScheduleTimelockCaldata();// switch fork to mainnet vm.selectFork(ethForkId);// prank as the bridge vm.startPrank(addresses.getAddress("ARBITRUM_BRIDGE"));address l1TimelockAddress = addresses.getAddress("ARBITRUM_L1_TIMELOCK" ); ITimelockController timelock =ITimelockController(l1TimelockAddress);address target;uint256 value;bytesmemory data;bytes32 predecessor; {// Start recording logs so we can create the execute calldata using the// CallSchedule log data vm.recordLogs();// Call the schedule function on the L1 timelock l1TimelockAddress.functionCall(scheduleCalldata);// Stop recording logs Vm.Log[] memory entries = vm.getRecordedLogs();// Get the execute parameters from schedule call logs (target, value, data, predecessor, ) = abi.decode( entries[0].data, (address,uint256,bytes,bytes32,uint256) );// warp to the future to execute the proposal vm.warp(block.timestamp + minDelay); } vm.stopPrank(); {// Start recording logs so we can get the TxToL2 log data vm.recordLogs();// execute the proposal timelock.execute( target, value, data, predecessor,keccak256(abi.encodePacked(description())) );// Stop recording logs Vm.Log[] memory entries = vm.getRecordedLogs();// If is a retriable ticket, we need to execute on L2if (target == RETRYABLE_TICKET_MAGIC) {// entries index 2 is TxToL2// topic with index 2 is the l2 target addressaddress to =address(uint160(uint256(entries[2].topics[2])));bytesmemory l2Calldata = abi.decode(entries[2].data, (bytes));// Switch back to primary fork, must be either Arb One or Arb Nova vm.selectFork(primaryForkId);// Perform the low-level call vm.prank(addresses.getAddress("ARBITRUM_ALIASED_L1_TIMELOCK"));bytesmemory returndata = to.functionCall(l2Calldata);if (DEBUG && returndata.length >0) { console.log("Target %s called on L2 and returned:", to); console.logBytes(returndata); } } }}
Proposal contract
Two proposals were added to the fps-example-repo. First, ArbitrumPorposal_01 which is an Arbitrum proposal that executes on L2. Second, ArbitrumPorposal_02 which is an arbitrum proposal that executes on L1. Let's first have a look at ArbitrumPorposal_02 by going through the code snippets:
constructor(): Execution chain is set to ethereum mainnet in constructor.
description(): Provides a detailed description of the proposal.
functiondescription() publicpureoverridereturns (stringmemory) {return"This proposal upgrades the L1 weth gateway";}
deploy(): This function deploys any necessary contracts. In this example the new weth gateway implementation contract and the GAC contract are deployed on L1. Once deployed, these contracts are added to the Addresses contract by calling addAddress().
build(): Add the needed actions to update the Arbitrum WETH Gateway on L1. For more in-depth information on how this process operates behind the scenes, please refer to the build function.
functionbuild()publicoverridebuildModifier(addresses.getAddress("ARBITRUM_L1_TIMELOCK",1)){// select etherem mainnet fork as this proposal upgrades weth gateway on L1 vm.selectFork(ethForkId); IUpgradeExecutor upgradeExecutor =IUpgradeExecutor( addresses.getAddress("ARBITRUM_L1_UPGRADE_EXECUTOR") ); upgradeExecutor.execute( addresses.getAddress("PROXY_UPGRADE_ACTION"), abi.encodeWithSelector( MockProxyUpgradeAction.perform.selector, addresses.getAddress("ARBITRUM_L1_PROXY_ADMIN"), addresses.getAddress("ARBITRUM_L1_WETH_GATEWAY_PROXY"), addresses.getAddress("ARBITRUM_L1_WETH_GATEWAY_IMPLEMENTATION") ) ); vm.selectFork(primaryForkId);}
run(): Serves as the entrypoint for executing the proposal by forge script. In this example, 'primaryForkId' is configured as 'Arbitrum' to execute the proposal on L2. Subsequently, 'ethForkId' is also set as every Arbitrum proposal must undergo a settlement process on L1, regardless of the final execution chain target. The address of the Arbitrum L2 governor's contract is set using 'setGovernor' to simulate the proposal. This address will be used later to verify on-chain calldata and simulate the proposal. For further reading, see the run function.
functionrun() publicoverride {stringmemory addressesFolderPath ="./addresses";uint256[] memory chainIds =newuint256[](2); chainIds[0] =1; chainIds[1] =42161; addresses =newAddresses(addressesFolderPath, chainIds); vm.makePersistent(address(addresses));setPrimaryForkId(vm.createFork("arbitrum"));setEthForkId(vm.createFork("ethereum"));/// select arbitrum fork to set governor address vm.selectFork(primaryForkId);setGovernor(addresses.getAddress("ARBITRUM_L2_CORE_GOVERNOR"));/// select ethereum mainnet fork as contracts are deployed on ethereum mainnet vm.selectFork(ethForkId); super.run();}
validate(): This final step validates the system in its post-execution state. It ensures that gateway proxy is upgraded to new implementation on L1. Only the proxy owner can call implementation() method to check its implementation address.
functionvalidate() publicoverride { vm.selectFork(ethForkId); IProxy proxy =IProxy( addresses.getAddress("ARBITRUM_L1_WETH_GATEWAY_PROXY") );// implementation() caller must be the owner vm.startPrank(addresses.getAddress("ARBITRUM_L1_PROXY_ADMIN"));require( proxy.implementation() == addresses.getAddress("ARBITRUM_L1_WETH_GATEWAY_IMPLEMENTATION"),"Proxy implementation not set" ); vm.stopPrank(); vm.selectFork(primaryForkId);}
Now let's have a look at ArbitrumProposal_02:
constructor(): Execution chain is set to arbitrum one chain in constructor.
description(): Provides a detailed description of the proposal.
functiondescription() publicpureoverridereturns (stringmemory) {return"This proposal upgrades the L2 weth gateway";}
deploy(): This function deploys any necessary contracts. In this example the new weth gateway implementation contract and the GAC contract are deployed on L2. Once deployed, these contracts are added to the Addresses contract by calling addAddress().
build(): Add the needed actions to update the Arbitrum WETH Gateway on L2. For more in-depth information on how this process operates behind the scenes, please refer to the build function.
run(): Serves as the entrypoint for executing the proposal by forge script. In this example, 'primaryForkId' is configured as 'Arbitrum' to execute the proposal on L2. Subsequently, 'ethForkId' is also set as every Arbitrum proposal must undergo a settlement process on L1, regardless of the final execution chain target. The address of the Arbitrum L2 governor's contract is set using 'setGovernor' to simulate the proposal. This address will be used later to verify on-chain calldata and simulate the proposal. For further reading, see the run function.
validate(): This final step validates the system in its post-execution state. It ensures that gateway proxy is upgraded to new implementation. Only proxy owner can call implementation() method to check implementation.
functionvalidate() publicoverride { IProxy proxy =IProxy( addresses.getAddress("ARBITRUM_L2_WETH_GATEWAY_PROXY") );// implementation() caller must be the owner vm.startPrank(addresses.getAddress("ARBITRUM_L2_PROXY_ADMIN"));require( proxy.implementation() == addresses.getAddress("ARBITRUM_L2_WETH_GATEWAY_IMPLEMENTATION"),"Proxy implementation not set" ); vm.stopPrank();}
Proposal Simulation
Setting Up the Addrsses JSON Files
Copy all address arbitrum address from 1.json. Your 1.json file should follow this structure:
==Logs==---------Addressesadded--------- {"addr":"0x6801E4888A91180238A8c36594EC65797eC2dDDf","isContract":true,"name":"ARBITRUM_L2_WETH_GATEWAY_IMPLEMENTATION"}, {"addr":"0xA98deC0C8e0326756C956033bbF091081986d0eD","isContract":true,"name":"PROXY_UPGRADE_ACTION"}----------------ProposalDescription----------------ThisproposalupgradestheL2wethgateway------------------ProposalActions------------------ 1). calling ARBITRUM_L2_UPGRADE_EXECUTOR @0xCF57572261c7c2BCF21ffD220ea7d1a27D40A827 with 0 eth and 0x1cff79cd000000000000000000000000a98dec0c8e0326756c956033bbf091081986d0ed00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000064e17f52e9000000000000000000000000d570ace65c43af47101fc6250fd6fc63d1c22a860000000000000000000000006c411ad3e74de3e7bd422b94a27770f5b86c623b0000000000000000000000006801e4888a91180238a8c36594ec65797ec2dddf00000000000000000000000000000000000000000000000000000000 data.
target:ARBITRUM_L2_UPGRADE_EXECUTOR@0xCF57572261c7c2BCF21ffD220ea7d1a27D40A827payload 0x1cff79cd000000000000000000000000a98dec0c8e0326756c956033bbf091081986d0ed00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000064e17f52e9000000000000000000000000d570ace65c43af47101fc6250fd6fc63d1c22a860000000000000000000000006c411ad3e74de3e7bd422b94a27770f5b86c623b0000000000000000000000006801e4888a91180238a8c36594ec65797ec2dddf00000000000000000000000000000000000000000000000000000000
-----------------ProposalChanges---------------ARBITRUM_L2_UPGRADE_EXECUTOR@0xCF57572261c7c2BCF21ffD220ea7d1a27D40A827:StateChanges:Slot:0x0000000000000000000000000000000000000000000000000000000000000097-0x0000000000000000000000000000000000000000000000000000000000000001+0x0000000000000000000000000000000000000000000000000000000000000002Slot:0x0000000000000000000000000000000000000000000000000000000000000097-0x0000000000000000000000000000000000000000000000000000000000000002+0x0000000000000000000000000000000000000000000000000000000000000001ARBITRUM_L2_WETH_GATEWAY_PROXY@0x6c411aD3E74De3E7Bd422b94A27770f5B86C623B:StateChanges:Slot:0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc-0x000000000000000000000000806421d09cdb253aa9d128a658e60c0b95effa01+0x0000000000000000000000006801e4888a91180238a8c36594ec65797ec2dddf------------------ProposalCalldata------------------ 0x7d5e81e2000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000004c00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006400000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000344928c169a000000000000000000000000e6841d92b0c345144506576ec13ecf5103ac7f49000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002c401d5062a000000000000000000000000a723c008e76e379c55599d2e4d93879beafda79c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000009c33e8e47a5438b554b45b782bed73248b78e26754b37292265f0b4a3ede7874000000000000000000000000000000000000000000000000000000000003f48000000000000000000000000000000000000000000000000000000000000001e00000000000000000000000004dbd4fc535ac27206064b68ffcf827b0a60bab3f000000000000000000000000cf57572261c7c2bcf21ffd220ea7d1a27d40a82700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000e41cff79cd000000000000000000000000a98dec0c8e0326756c956033bbf091081986d0ed00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000064e17f52e9000000000000000000000000d570ace65c43af47101fc6250fd6fc63d1c22a860000000000000000000000006c411ad3e74de3e7bd422b94a27770f5b86c623b0000000000000000000000006801e4888a91180238a8c36594ec65797ec2dddf00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a546869732070726f706f73616c20757067726164657320746865204c322077657468206761746577617900000000000000000000000000000000000000000000
A DAO member can check whether the calldata proposed on the governance matches the calldata from the script exeuction. It is crucial to note that two new addresses have been added to the Addresses.sol storage. These addresses are not included in the JSON files when proposal is run without the DO_UPDATE_ADDRESS_JSON flag set to true.
The proposal script will deploy the contracts in deploy() method and will generate actions calldata for each individual action along with proposal calldata for the proposal. The proposal can be proposed manually using cast send with the calldata generated above.