Skip to content

Repository files navigation

Cross-Chain Governance

CI Solidity Foundry License: MIT

A cross-chain governance protocol leveraging the native Arbitrum bridge and OpenZeppelin primitives.

This repository pairs liquid ERC-20 tokens on Ethereum Mainnet (L1) with non-transferable voting power and an OpenZeppelin Governor on Arbitrum One (L2) for high-throughput, low-cost governance.


Contracts

Contract Chain Pattern & Standards Primary Responsibility
LiquidToken Ethereum Mainnet (L1) ERC-20, ERC-2612 (Permit), UUPS Transferable liquid token (LT) representing economic ownership.
GovernanceEscrow Ethereum Mainnet (L1) ReentrancyGuard, ERC-7201, UUPS Custodies LT tokens on L1 and triggers retryable tickets via Arbitrum Inbox.
GovernanceToken Arbitrum One (L2) ERC-20 Votes, ERC-7201, UUPS Non-transferable voting token (GT). Minting/burning gated to the Governor.
Governor Arbitrum One (L2) OZ Governor, ERC-7201, UUPS Manages proposals, voting, cross-chain mint requests, and redemptions.

Cross-Chain Flows

1. Deposit & Voting Token Issuance (L1 → L2)

Users lock liquid tokens (LT) on Ethereum Mainnet to receive non-transferable voting tokens (GT) on Arbitrum One.

  1. Escrow Deposit: The user approves GovernanceEscrow on L1 and calls deposit(), providing ETH to cover L2 execution fees.
  2. Bridge Execution: GovernanceEscrow locks the LT tokens and creates a retryable ticket targeting Governor.issue(_to, _amount) on L2 via the Arbitrum Inbox.
  3. Aliased Call Verification: The L2 Governor contract receives the bridged call, validates that msg.sender equals the aliased address of GovernanceEscrow, and instructs GovernanceToken to mint GT.
  4. Auto-Delegation: If the user has not yet delegated their voting power on L2, GovernanceToken automatically self-delegates upon minting.
sequenceDiagram
    autonumber
    actor User
    participant LT as LiquidToken (L1)
    participant Escrow as GovernanceEscrow (L1)
    participant Inbox as Arbitrum Inbox (L1)
    participant Gov as Governor (L2)
    participant GT as GovernanceToken (L2)

    User->>LT: approve(Escrow, amount)
    User->>Escrow: deposit(to, refundAddress, amount, maxSubmissionCost, gasLimit, maxFeePerGas) [ETH]
    Escrow->>LT: safeTransferFrom(User, Escrow, amount)
    Escrow->>Inbox: createRetryableTicket{value: ETH}(Governor, 0, maxSubmissionCost, ...)
    Note over Inbox,Gov: Arbitrum Bridge Cross-Chain Execution (L1 -> L2)
    Inbox->>Gov: issue(to, amount) [msg.sender = Escrow.applyL1ToL2Alias()]
    Gov->>Gov: Validate msg.sender == Escrow.applyL1ToL2Alias()
    Gov->>GT: mint(to, amount)
    alt Recipient has no delegate
        GT->>GT: _delegate(to, to) [Auto-delegate]
    end
    GT-->>Gov: Minted
    Gov-->>User: TokensIssued Event Emitted
Loading

2. Redemption & Liquid Token Release (L2 → L1)

Users burn their Governance Token (GT) and associated voting power on Arbitrum One to reclaim their underlying liquid tokens (LT) on Ethereum Mainnet.

  1. Burn & Outbox Message: The user calls Governor.redeem() on L2. The Governor burns their GT tokens and invokes ArbSys.sendTxToL1() targeting GovernanceEscrow.release(_to, _amount).
  2. Dispute Window: The transaction passes through the standard Arbitrum challenge period (~7 days on Mainnet).
  3. Outbox Execution: Once the L2 state root is confirmed on Ethereum, IOutbox.executeTransaction() is called. The Outbox verifies the Merkle proof and forwards the payload to the Arbitrum Bridge, which performs the final call.
  4. Escrow Validation: GovernanceEscrow checks that msg.sender is the Arbitrum Bridge, then reads l2ToL1Sender() from the Bridge's currently active Outbox and requires it to be the L2 Governor. Upon validation, it releases LT to the target L1 address.
sequenceDiagram
    autonumber
    actor User
    participant Gov as Governor (L2)
    participant GT as GovernanceToken (L2)
    participant ArbSys as ArbSys Precompile (L2)
    participant Relayer as User or Relayer (L1)
    participant Outbox as Arbitrum Outbox (L1)
    participant Bridge as Arbitrum Bridge (L1)
    participant Escrow as GovernanceEscrow (L1)
    participant LT as LiquidToken (L1)

    User->>Gov: redeem(to, amount)
    Gov->>GT: burn(User, amount)
    Gov->>ArbSys: sendTxToL1(Escrow, abi.encodeCall(release, (to, amount)))
    ArbSys-->>Gov: Returns L2-to-L1 Ticket ID
    Note over ArbSys,Outbox: Dispute Period / State Validation (~7 Days on Mainnet)
    Relayer->>Outbox: executeTransaction(...)
    Note over Outbox: Verifies Merkle proof, sets l2ToL1Sender context
    Outbox->>Bridge: executeCall(Escrow, 0, data)
    Note over Bridge: Records the calling Outbox as activeOutbox
    Bridge->>Escrow: release(to, amount)
    Escrow->>Escrow: Validate msg.sender == inbox.bridge()
    Escrow->>Escrow: Validate IOutbox(bridge.activeOutbox()).l2ToL1Sender() == Governor
    Escrow->>LT: safeTransfer(to, amount)
    LT-->>User: Liquid Tokens Transferred
Loading

Retryable Tickets & Handling Bridging Failures

Inbound Deposits (L1 → L2 Retryable Tickets)

Inbound L1 → L2 calls rely on Arbitrum Retryable Tickets. If an L1 deposit succeeds but execution on L2 fails (e.g., due to an L2 gas price spike), the transaction is not lost.

  • Auto-Retry & Manual Redemption: The deposit payload remains queued in the L2 Inbox. Anyone (the user or a relayer) can trigger execution on Arbitrum using the L1 transaction hash within 7 days.
  • Lifetime Extension: If a ticket remains unexecuted near the end of the 7-day window, anyone can call ArbRetryableTx.keepAlive() on L2 to extend its lease time.

Outbound Redemptions (L2 → L1 Messages)

Outbound token releases rely on the ArbSys precompile and the Ethereum L1 Outbox.

  • Challenge Window: L2 → L1 messages require the standard Arbitrum dispute period (~7 days on Mainnet) for state root finalisation before they can be claimed on L1.
  • Manual Claiming: L2-to-L1 messages do not execute automatically on L1. Once finalised, the user or a relayer must submit an inclusion proof to Outbox.executeTransaction().
  • Re-Execution Safety: If the L1 execution reverts (e.g., due to insufficient gas provided by the caller), the message remains recorded as unspent in the Outbox Merkle tree and can be safely re-triggered without losing funds.

For more details on cross-chain message lifecycles and precompiles, refer to the official Arbitrum L1-to-L2 Docs, L2-to-L1 Messaging Guide, and the ArbSys Specification


Key Design Features

  • Cross-Chain Authentication: Secure messaging without custom bridges. Inbound L1 deposits are verified on L2 via Arbitrum's applyL1ToL2Alias() check, while L1 redemptions authenticate the Arbitrum Bridge as the caller and validate the l2ToL1Sender() reported by its active Outbox against the L2 Governor.
  • Non-Transferable Voting Power: The GovernanceToken overrides standard _update() logic to restrict transfers exclusively to Governor mint and burn calls (onlyGovernor), ensuring governance utility remains bound to locked L1 deposits.
  • Proposal Guardrails: The Governor contract disallows proposals that target the GovernanceToken or ArbSys directly, preventing malicious or broken proposals from altering token mechanics or minting permissions.
  • Collision-Resistant Storage: All upgradeable proxy contracts implement ERC-7201 namespaced storage slots (erc7201:cross-chain.storage...), eliminating storage layout collision risks during UUPS implementation upgrades.

Setup

Prerequisites

  • Foundry: Smart contract framework for compilation, testing, and deployment. Install via foundry-rs/foundry.
  • Node.js & pnpm: Node.js v25.0.0+ and pnpm v11.0.0+, matching the versions pinned in CI.
  • Rust Toolchain: Required for CLI helper utilities. Install via rustup.
  • Docker: Required for local end-to-end tests (boots the lib/nitro-testnode submodule).
  • jq: Used by e2e scripts to parse bridge addresses from localNetwork.json.

Installation

  • Install Foundry by following the instructions from their repository.
  • Copy the .env.example file to .env and fill in the variables.
  • Install rust dependencies with cargo, cargo install lintspec and cargo install bulloak.
  • Ensure submodules are added with git submodule update --init --recursive.
  • Install the dependencies by running: pnpm install. In case there is an error with the commands, run foundryup and try them again.

Commands

All script commands can be reviewed in the package.json.

Common and useful commands:

pnpm build
pnpm coverage
pnpm deploy
pnpm format
pnpm test
pnpm test:unit
pnpm test:integration
pnpm test:invariant
pnpm test:e2e

Deployment

  • Ensure the relevant deployment constants have been updated in script/Constants.s.sol, such as the ETHEREUM_MAINNET_OWNER and ETHEREUM_MAINNET_TOTAL_SUPPLY_RECIPIENT.
  • Import private keys for both Ethereum and Arbitrum mainnets into Foundry's encrypted keystore with cast wallet import $ETHEREUM_MAINNET_DEPLOYER_NAME --interactive and cast wallet import $ARBITRUM_MAINNET_DEPLOYER_NAME --interactive.
  • Add the .env variables and source them with source .env (note that ETHEREUM_MAINNET_DEPLOYER_ADDRESS must match the ETHEREUM_MAINNET_DEPLOYER_NAME, and so too with the Arbitrum equivalents).
  • Deploy the contracts to both Ethereum and Arbitrum simultaneously with pnpm deploy.

About

Featured Foundry Project: Cross-chain governance using native Arbitrum bridges and Openzeppelin primitives

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages