The framework is designed to be flexible and loosely coupled, as explained at the end of . 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.
Read more about Arbitrum Governance .
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 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 id
function setEthForkId(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 needed
contract MockArbSys {
uint256 public ticketId;
function sendTxToL1(
address _l1Target,
bytes memory _data
) external payable returns (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 call
contract MockArbOutbox {
function l2ToL1Sender() external pure returns (address) {
return 0x34d45e99f7D8c45ed05B5cA72D54bbD1fb3F98f0;
}
}
/// @notice mock arb sys precompiled contract on L2
/// mock outbox on mainnet
function preBuildMock() public override {
// switch to mainnet fork to mock arb outbox
vm.selectFork(ethForkId);
address mockOutbox = address(new MockArbOutbox());
vm.store(
addresses.getAddress("ARBITRUM_BRIDGE"),
bytes32(uint256(5)),
bytes32(uint256(uint160(mockOutbox)))
);
vm.selectFork(primaryForkId);
address arbsys = address(new MockArbSys());
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 action
function _validateActions() internal view override {
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 valid
require(
(actions[0].arguments.length == 0 && actions[0].value > 0) ||
actions[0].arguments.length > 0,
"Invalid arguments for proposal"
);
// Value is ignored on L2 proposals
if (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 proposals
function getScheduleTimelockCaldata()
public
view
returns (bytes memory scheduleCalldata)
{
// address only used if is a L2 proposal
address inbox;
if (executionChain == ProposalExecutionChain.ARB_ONE) {
inbox = arbOneInbox;
} else if (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 network
0, // no value in this upgrade
0, // max gas - will be filled in when the retryable is actually executed
0, // 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 predecessor
keccak256(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 calldata
function getProposalActions()
public
view
override
returns (
address[] memory targets,
uint256[] memory values,
bytes[] memory arguments
)
{
_validateActions();
// inner calldata must be a call to schedule on L1Timelock
bytes memory innerCalldata = getScheduleTimelockCaldata();
targets = new address[](1);
values = new uint256[](1);
arguments = new bytes[](1);
bytes memory 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 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 settlement
function simulate() public override {
// 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 network
bytes memory 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;
bytes memory 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 L2
if (target == RETRYABLE_TICKET_MAGIC) {
// entries index 2 is TxToL2
// topic with index 2 is the l2 target address
address to = address(uint160(uint256(entries[2].topics[2])));
bytes memory 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"));
bytes memory returndata = to.functionCall(l2Calldata);
if (DEBUG && returndata.length > 0) {
console.log("Target %s called on L2 and returned:", to);
console.logBytes(returndata);
}
}
}
}
Proposal contract
constructor(): Execution chain is set to ethereum mainnet in constructor.
function name() public pure override returns (string memory) {
return "ARBITRUM_PROPOSAL_02";
}
description(): Provides a detailed description of the proposal.
function description() public pure override returns (string memory) {
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().
function deploy() public override {
if (
!addresses.isAddressSet("ARBITRUM_L1_WETH_GATEWAY_IMPLEMENTATION")
) {
address mockUpgrade = address(new MockUpgrade());
addresses.addAddress(
"ARBITRUM_L1_WETH_GATEWAY_IMPLEMENTATION",
mockUpgrade,
true
);
}
if (!addresses.isAddressSet("PROXY_UPGRADE_ACTION")) {
address gac = address(new MockProxyUpgradeAction());
addresses.addAddress("PROXY_UPGRADE_ACTION", gac, true);
}
}
function build()
public
override
buildModifier(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);
}
function run() public override {
string memory addressesFolderPath = "./addresses";
uint256[] memory chainIds = new uint256[](2);
chainIds[0] = 1;
chainIds[1] = 42161;
addresses = new Addresses(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.
function validate() public override {
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.
function name() public pure override returns (string memory) {
return "ARBITRUM_PROPOSAL_01";
}
description(): Provides a detailed description of the proposal.
function description() public pure override returns (string memory) {
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().
function deploy() public override {
if (
!addresses.isAddressSet("ARBITRUM_L2_WETH_GATEWAY_IMPLEMENTATION")
) {
address mockUpgrade = address(new MockUpgrade());
addresses.addAddress(
"ARBITRUM_L2_WETH_GATEWAY_IMPLEMENTATION",
mockUpgrade,
true
);
}
if (!addresses.isAddressSet("PROXY_UPGRADE_ACTION")) {
address gac = address(new MockProxyUpgradeAction());
addresses.addAddress("PROXY_UPGRADE_ACTION", gac, true);
}
}
function run() public override {
string memory addressesFolderPath = "./addresses";
uint256[] memory chainIds = new uint256[](2);
chainIds[0] = 1;
chainIds[1] = 42161;
addresses = new Addresses(addressesFolderPath, chainIds);
vm.makePersistent(address(addresses));
setPrimaryForkId(vm.createFork("arbitrum"));
setEthForkId(vm.createFork("ethereum"));
vm.selectFork(primaryForkId);
setGovernor(addresses.getAddress("ARBITRUM_L2_CORE_GOVERNOR"));
super.run();
}
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.
function validate() public override {
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();
}
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.
Two proposals were added to the fps-example-repo. First, which is an Arbitrum proposal that executes on L2. Second, which is an arbitrum proposal that executes on L1. Let's first have a look at ArbitrumPorposal_02 by going through the code snippets:
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 .
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 .
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 .
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 .
Copy all address arbitrum address from . Your 1.json file should follow this structure:
Copy all arbitrum address from . Your 42161.json file should follow this structure: