Skip to main content
Across Protocol uses the UUPS (Universal Upgradeable Proxy Standard) pattern for upgradeability. This guide covers how to upgrade SpokePool contracts via cross-chain governance from the HubPool.

Understanding UUPS Upgradeability

SpokePool contracts are UUPSUpgradeable proxy contracts:
  • Proxy address remains constant - Users always interact with the same address
  • Implementation can be changed - Logic contracts can be upgraded without changing the proxy
  • Upgrade logic lives in implementation - Unlike Transparent Proxies, upgrade function is in the implementation contract
The UUPS pattern provides gas savings over Transparent Proxy pattern and prevents accidental upgrades via admin calls to implementation contracts.

Cross-Chain Ownership Model

All SpokePools have cross-chain ownership:
  • HubPool owns all SpokePools - The HubPool contract on Ethereum L1 is the admin of all L2/sidechain SpokePools
  • Chain adapters relay messages - Each chain has a specialized adapter that uses the native bridge to relay admin calls
  • Per-chain admin verification - Each SpokePool variant implements _requireAdminSender() to verify the caller using chain-specific logic

Admin Verification Examples

// Arbitrum_SpokePool.sol
function _requireAdminSender() internal view override {
    address l1Addr = msg.sender.toL1Addr();  // Reverse address aliasing
    require(l1Addr == crossDomainAdmin, "Not admin");
}

Upgrade Process

Upgrading a SpokePool involves several steps, from deploying a new implementation to executing the upgrade via HubPool.

1. Deploy New Implementation

1

Deploy the new implementation contract

Deploy a new implementation without upgrading the proxy:
forge script script/DeployArbitrumSpokePool.s.sol:DeployArbitrumSpokePool \
  --rpc-url arbitrum \
  --broadcast \
  --verify \
  -vvvv
Save the new implementation address from the deployment output.
2

Test the implementation

Before upgrading, thoroughly test the new implementation:
  • Run all test suites
  • Perform fork tests against mainnet state
  • Verify storage layout compatibility
  • Check for breaking changes

2. Generate Upgrade Calldata

Use the upgrade script to generate calldata for the HubPool:
forge script script/tasks/UpgradeSpokePool.s.sol:UpgradeSpokePool \
  --sig "run(address)" <NEW_IMPLEMENTATION_ADDRESS> \
  -vvvv
This script generates calldata that:
  1. Upgrades to the new implementation via upgradeToAndCall()
  2. Pauses deposits to verify the upgrade succeeded
  3. Unpauses deposits to confirm the proxy correctly delegates to the new implementation
The pause/unpause sequence is a safety mechanism. If the new implementation is broken, the upgrade will revert atomically during the unpause call.

Example Output

=======================================================
SpokePool Upgrade Calldata Generator
=======================================================

New Implementation Address: 0x1234...5678

To upgrade a SpokePool on chain <chainId>:
Call relaySpokePoolAdminFunction() on the HubPool with:
  - chainId: 42161
  - calldata: 0x4f1ef286000000000000000000000000...

=======================================================

3. Execute Cross-Chain Upgrade

1

Call HubPool.relaySpokePoolAdminFunction()

The HubPool owner calls relaySpokePoolAdminFunction() with:
function relaySpokePoolAdminFunction(
    uint256 chainId,
    bytes memory message
) external onlyOwner
Parameters:
  • chainId: Target chain ID (e.g., 42161 for Arbitrum)
  • message: Calldata from the upgrade script
2

HubPool relays message via adapter

The HubPool:
  1. Identifies the appropriate chain adapter for the target chain
  2. Calls the adapter via delegatecall
  3. Adapter bridges the message using the native bridge (e.g., Arbitrum Inbox, OP Messenger)
3

Message executes on destination chain

After the bridge delay:
  1. Message arrives at the SpokePool
  2. _requireAdminSender() verifies the caller
  3. upgradeToAndCall() executes, upgrading the implementation
  4. Post-upgrade multicall runs (pause/unpause)
4

Verify the upgrade

Check that the upgrade succeeded:
# Check implementation address
cast call $SPOKE_POOL "implementation()" --rpc-url arbitrum

# Verify deposits are not paused
cast call $SPOKE_POOL "pausedDeposits()" --rpc-url arbitrum

# Test a small deposit
cast send $SPOKE_POOL "depositV3(...) " --rpc-url arbitrum

Upgrade Script Deep Dive

The upgrade script generates safe upgrade calldata:
// script/tasks/UpgradeSpokePool.s.sol
contract UpgradeSpokePool is Script {
    function run(address implementation) external view {
        // Create multicall data to verify upgrade succeeded
        bytes[] memory multicallData = new bytes[](2);
        multicallData[0] = abi.encodeWithSelector(
            ISpokePoolUpgradeable.pauseDeposits.selector, 
            true
        );
        multicallData[1] = abi.encodeWithSelector(
            ISpokePoolUpgradeable.pauseDeposits.selector, 
            false
        );

        // Encode multicall as initialization data
        bytes memory data = abi.encodeWithSelector(
            ISpokePoolUpgradeable.multicall.selector, 
            multicallData
        );

        // Generate upgradeToAndCall calldata
        bytes memory calldata_ = abi.encodeWithSelector(
            ISpokePoolUpgradeable.upgradeToAndCall.selector,
            implementation,
            data
        );

        console.logBytes(calldata_);
    }
}

Why Pause/Unpause?

The pause/unpause sequence ensures:
  • The upgrade succeeds - If the new implementation is incompatible, the multicall reverts
  • The proxy delegates correctly - The unpause call proves the proxy routes calls to the new implementation
  • Atomicity - The entire upgrade reverts if any step fails
If the upgrade fails, the transaction reverts and the SpokePool remains on the old implementation. No partial upgrade is possible.

Solana/SVM Upgrades

Solana program upgrades follow a different process:

1. Write Upgrade Buffer

export PROGRAM=svm_spoke
export PROGRAM_ID=$(cat target/idl/$PROGRAM.json | jq -r ".address")
export RPC_URL=https://api.mainnet-beta.solana.com
export KEYPAIR=~/.config/solana/id.json

# Build verified binary
unset IS_TEST
yarn build-svm-solana-verify

# Write to buffer
solana program write-buffer \
  --url $RPC_URL \
  --keypair $KEYPAIR \
  --with-compute-unit-price 100000 \
  --max-sign-attempts 100 \
  --use-rpc \
  target/deploy/$PROGRAM.so
Save the buffer address from the output.

2. Transfer Buffer Authority

export BUFFER=<BUFFER_ADDRESS_FROM_ABOVE>
export MULTISIG=<YOUR_SQUADS_VAULT>

solana program set-buffer-authority \
  --url $RPC_URL \
  --keypair $KEYPAIR \
  $BUFFER \
  --new-buffer-authority $MULTISIG

3. Execute via Squads Multisig

1

Add program to Squads

In Squads UI (https://app.squads.so/):
  1. Go to DevelopersPrograms
  2. Add your program ID
2

Create upgrade transaction

  1. Click Upgrade
  2. Fill in buffer address and refund recipient
  3. Verify buffer authority (prompted)
  4. Submit upgrade proposal
3

Sign and execute

  1. All required signers approve
  2. Execute the upgrade from the transactions section

4. Upgrade IDL

After upgrading the program, update the IDL:
# Write IDL to buffer
anchor idl write-buffer \
  --provider.cluster $RPC_URL \
  --provider.wallet $KEYPAIR \
  --filepath target/idl/$PROGRAM.json \
  $PROGRAM_ID

export IDL_BUFFER=<IDL_BUFFER_ADDRESS>

# Transfer authority
anchor idl set-authority \
  --provider.cluster $RPC_URL \
  --provider.wallet $KEYPAIR \
  --program-id $PROGRAM_ID \
  --new-authority $MULTISIG \
  $IDL_BUFFER

# Generate multisig transaction
anchor run squadsIdlUpgrade -- \
  --programId $PROGRAM_ID \
  --idlBuffer $IDL_BUFFER \
  --multisig $MULTISIG \
  --closeRecipient $(solana address --keypair $KEYPAIR)
Import the printed base58 transaction into Squads for approval and execution.

Safety Checklist

Before upgrading:
  • Test extensively - Run full test suite and fork tests
  • Check storage layout - Verify no storage collisions with Foundry’s forge inspect
  • Audit changes - Review all code changes since last upgrade
  • Verify implementation - Use the verification guide to verify the new implementation on block explorers
  • Test on testnet first - Always upgrade testnet deployments before mainnet
  • Coordinate with relayers - Notify relayers of upgrade schedule
  • Monitor after upgrade - Watch for errors in the first hour post-upgrade
  • Have rollback plan - Be prepared to upgrade to previous implementation if issues arise
UUPS upgrades are one-way. Once upgraded, you can only “rollback” by deploying the old implementation again as a new upgrade. There is no built-in rollback mechanism.

Troubleshooting

Upgrade reverts with “Not admin”

The cross-chain message is not properly authenticated. Check:
  • HubPool address matches crossDomainAdmin on SpokePool
  • Chain adapter is correctly configured for the target chain
  • Native bridge is functioning (not paused/disabled)

Upgrade succeeds but contract is broken

This should not happen if the pause/unpause verification is working. If it does:
  1. Immediately prepare a rollback upgrade
  2. Test rollback implementation thoroughly
  3. Execute rollback upgrade ASAP
  4. Investigate why the verification didn’t catch the issue

IDL upgrade fails on Solana

Verify:
  • IDL buffer authority is set to multisig
  • Program ID is correct
  • Squads transaction was constructed properly
  • All signers have approved

Next Steps

Build docs developers (and LLMs) love