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.
| 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. |
Users lock liquid tokens (LT) on Ethereum Mainnet to receive non-transferable voting tokens (GT) on Arbitrum One.
- Escrow Deposit: The user approves
GovernanceEscrowon L1 and callsdeposit(), providing ETH to cover L2 execution fees. - Bridge Execution:
GovernanceEscrowlocks theLTtokens and creates a retryable ticket targetingGovernor.issue(_to, _amount)on L2 via the ArbitrumInbox. - Aliased Call Verification: The L2
Governorcontract receives the bridged call, validates thatmsg.senderequals the aliased address ofGovernanceEscrow, and instructsGovernanceTokento mintGT. - Auto-Delegation: If the user has not yet delegated their voting power on L2,
GovernanceTokenautomatically 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
Users burn their Governance Token (GT) and associated voting power on Arbitrum One to reclaim their underlying liquid tokens (LT) on Ethereum Mainnet.
- Burn & Outbox Message: The user calls
Governor.redeem()on L2. TheGovernorburns theirGTtokens and invokesArbSys.sendTxToL1()targetingGovernanceEscrow.release(_to, _amount). - Dispute Window: The transaction passes through the standard Arbitrum challenge period (~7 days on Mainnet).
- Outbox Execution: Once the L2 state root is confirmed on Ethereum,
IOutbox.executeTransaction()is called. TheOutboxverifies the Merkle proof and forwards the payload to the ArbitrumBridge, which performs the final call. - Escrow Validation:
GovernanceEscrowchecks thatmsg.senderis the ArbitrumBridge, then readsl2ToL1Sender()from theBridge's currently activeOutboxand requires it to be the L2Governor. Upon validation, it releasesLTto 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
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 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
OutboxMerkle 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
- 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 ArbitrumBridgeas the caller and validate thel2ToL1Sender()reported by its activeOutboxagainst the L2 Governor. - Non-Transferable Voting Power: The
GovernanceTokenoverrides 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
Governorcontract disallows proposals that target theGovernanceTokenorArbSysdirectly, 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.
- Foundry: Smart contract framework for compilation, testing, and deployment. Install via foundry-rs/foundry.
- Node.js & pnpm: Node.js
v25.0.0+andpnpm 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-testnodesubmodule). - jq: Used by e2e scripts to parse bridge addresses from
localNetwork.json.
- Install Foundry by following the instructions from their repository.
- Copy the
.env.examplefile to.envand fill in the variables. - Install rust dependencies with cargo,
cargo install lintspecandcargo 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, runfoundryupand try them again.
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- Ensure the relevant deployment constants have been updated in
script/Constants.s.sol, such as theETHEREUM_MAINNET_OWNERandETHEREUM_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 --interactiveandcast wallet import $ARBITRUM_MAINNET_DEPLOYER_NAME --interactive. - Add the
.envvariables and source them withsource .env(note thatETHEREUM_MAINNET_DEPLOYER_ADDRESSmust match theETHEREUM_MAINNET_DEPLOYER_NAME, and so too with the Arbitrum equivalents). - Deploy the contracts to both Ethereum and Arbitrum simultaneously with
pnpm deploy.