Skip to content

Protocol Upgradeability Guide

Overview

The Textile Protocol v2.1 uses multiple upgradeability patterns to enable protocol evolution while preserving state and deployed addresses. This guide covers all upgradeable contracts and their upgrade procedures.

Upgradeable Contracts Summary

ContractPatternUpgrade ScopeTimelock
RegistryUUPS ProxySingle instance✅ 2 days
WithdrawalRegistryUUPS ProxySingle instance✅ 2 days
UnderwriterRegistryUUPS ProxySingle instance✅ 2 days
StructuredPoolBeaconProxyMass upgrade✅ 2 days
TrancheBeaconProxyMass upgrade✅ 2 days

Architecture Overview

UUPS Pattern (Infrastructure Registries)

┌─────────────────┐
│  Protocol/User  │
└────────┬────────┘


┌─────────────────┐
│  ERC1967Proxy   │ ◄── Proxy (unchanging address)
└────────┬────────┘
         │ delegatecall

┌─────────────────┐
│ Implementation  │ ◄── Registry (upgradeable)
│     Logic       │
└─────────────────┘

Used for: Single-instance infrastructure contracts

  • Registry: Central pool registry
  • WithdrawalRegistry: Centralized withdrawal approval management across all tranches
  • UnderwriterRegistry: Underwriter profile and fee registry

BeaconProxy Pattern (Pools, Tranches)

┌──────────────────────────────────────────┐
│              Users/Pools                 │
└────┬─────────────┬─────────────┬─────────┘
     │             │             │
     ▼             ▼             ▼
┌─────────┐  ┌─────────┐  ┌─────────┐
│ Proxy 1 │  │ Proxy 2 │  │ Proxy N │  ◄── BeaconProxies
└────┬────┘  └────┬────┘  └────┬────┘
     │            │             │
     └────────────┼─────────────┘


         ┌────────────────┐
         │     Beacon     │  ◄── UpgradeableBeaconWithTimelock
         └────────┬───────┘


         ┌────────────────┐
         │ Implementation │  ◄── Upgradeable logic
         └────────────────┘

Used for: Multiple instances that need mass upgrades

  • StructuredPool: All pool instances (includes integrated credit line)
  • Tranche: All tranche instances

Benefits:

  • Upgrade ALL instances with a single transaction
  • Gas-efficient deployment (similar to minimal clones)
  • Timelock protection on beacon upgrades
  • Separate admin roles for security

Access Control

Two-Party Authorization Model

All upgrades require cooperation between two separate roles:

PROTOCOL_ADMIN Role

  • Responsibilities:
    • Schedule upgrades (scheduleUpgrade())
    • Execute upgrades after timelock (executeUpgrade() or upgradeToAndCall())
    • Cancel pending upgrades (cancelUpgrade())
    • Manage protocol configuration
  • Recommended: 5/9 multi-sig with protocol team members
  • Self-administering: Can grant/revoke PROTOCOL_ADMIN to other addresses

TIMELOCK_ADMIN Role

  • Responsibilities:
    • Set upgrade delay period (setUpgradeDelay())
    • Adjust timelock between 1 hour and 30 days
  • Recommended: 3/5 multi-sig with DIFFERENT key holders than PROTOCOL_ADMIN
  • Self-administering: Can grant/revoke TIMELOCK_ADMIN to other addresses

Security Model

Single Admin Compromised: ✅ Protocol remains secure

  • Protocol admin alone cannot bypass timelock
  • Timelock admin alone cannot execute upgrades

Both Admins Compromised: ❌ Attacker can upgrade

  • Requires compromise of TWO separate multi-sigs
  • Significantly harder than single-admin systems

Registry Upgradeability (UUPS)

Overview

The Registry is the central infrastructure contract deployed as a UUPS proxy. It manages pool registration and protocol configuration.

Upgrade Process

1. Deploy New Implementation

bash
cd packages/protocol
npx hardhat run scripts/v2.1/deploy.ts --network <network>

Or programmatically:

typescript
const RegistryFactory = await ethers.getContractFactory('Registry')
const newImplementation = await RegistryFactory.deploy()
await newImplementation.waitForDeployment()
const newImplAddress = await newImplementation.getAddress()

2. Schedule Upgrade

typescript
const registry = RegistryFactory.attach(REGISTRY_PROXY_ADDRESS)

// Protocol admin schedules upgrade
await registry.connect(protocolAdmin).scheduleUpgrade(newImplAddress)

// Event emitted: UpgradeScheduled(newImplAddress, scheduledTime)

3. Wait for Timelock

Default: 2 days (172,800 seconds)

typescript
const upgradeInfo = await registry.getUpgradeInfo()
console.log('Scheduled time:', new Date(upgradeInfo.scheduledTime * 1000))
console.log('Delay:', upgradeInfo.upgradeDelay, 'seconds')

4. Execute Upgrade

typescript
// After timelock period passes
await registry.connect(protocolAdmin).upgradeToAndCall(newImplAddress, '0x')

// Or use the upgrade script
npx hardhat run scripts/v2.1/executeUpgrade.ts --network <network>

5. Verify

typescript
const version = await registry.version()
console.log('Version:', version) // Should reflect new version

// Verify state preserved
const totalPools = await registry.getTotalPools()
console.log('Total pools preserved:', totalPools)

Cancel Upgrade (if needed)

typescript
// Protocol admin can cancel before execution
await registry.connect(protocolAdmin).cancelUpgrade()

Adjust Timelock Delay

typescript
// Timelock admin can adjust delay (1 hour to 30 days)
const newDelay = 3 * 24 * 60 * 60 // 3 days
await registry.connect(timelockAdmin).setUpgradeDelay(newDelay)

BeaconProxy Upgradeability (Pools, Tranches)

Overview

StructuredPool and Tranche instances are deployed as BeaconProxies. Each contract type has its own beacon:

  • poolBeacon: Controls all StructuredPool instances (and integrated credit line logic)
  • trancheBeacon: Controls all Tranche instances

Mass Upgrade Process

Upgrading a beacon upgrades ALL instances simultaneously.

1. Deploy New Implementation

typescript
// Example: Upgrading all StructuredPool instances
const PoolFactory = await ethers.getContractFactory('StructuredPool')
const newImplementation = await PoolFactory.deploy()
await newImplementation.waitForDeployment()
const newImplAddress = await newImplementation.getAddress()

2. Get Beacon Address

typescript
const factory = await ethers.getContractAt('PoolFactory', FACTORY_ADDRESS)
const poolBeaconAddress = await factory.poolBeacon()
const beacon = await ethers.getContractAt(
  'UpgradeableBeaconWithTimelock',
  poolBeaconAddress
)

3. Schedule Upgrade

typescript
// Protocol admin schedules upgrade
await beacon.connect(protocolAdmin).scheduleUpgrade(newImplAddress)

// Event emitted: UpgradeScheduled(newImplAddress, scheduledTime)

4. Wait for Timelock

Default: 2 days (172,800 seconds)

typescript
const upgradeInfo = await beacon.getUpgradeInfo()
console.log('Pending implementation:', upgradeInfo.pendingImplementation)
console.log('Scheduled time:', new Date(upgradeInfo.scheduledTime * 1000))

5. Execute Upgrade

typescript
// After timelock period passes
await beacon.connect(protocolAdmin).executeUpgrade(newImplAddress)

// Event emitted: Upgraded(newImplAddress)

6. Verify Mass Upgrade

typescript
// All instances now use new implementation
const currentImpl = await beacon.implementation()
console.log('Current implementation:', currentImpl)

// Test a few instances
const pool1 = await ethers.getContractAt('StructuredPool', POOL1_ADDRESS)
const pool2 = await ethers.getContractAt('StructuredPool', POOL2_ADDRESS)

console.log('Pool 1 version:', await pool1.version())
console.log('Pool 2 version:', await pool2.version())

// Verify state preserved
const pool1Info = await pool1.getPoolInfo()
console.log('Pool 1 state preserved:', pool1Info)

Cancel Beacon Upgrade

typescript
// Protocol admin can cancel before execution
await beacon.connect(protocolAdmin).cancelUpgrade()

Adjust Beacon Timelock

typescript
// Timelock admin can adjust delay
const newDelay = 3 * 24 * 60 * 60 // 3 days
await beacon.connect(timelockAdmin).setUpgradeDelay(newDelay)

Storage Layout Rules

When creating new implementations, you MUST follow these rules:

✅ Safe Practices

  1. Keep existing variables in the same order
  2. Keep existing variable types unchanged
  3. Add new variables at the end only
  4. Reduce __gap size when adding new variables

Example: Safe Upgrade

solidity
// ✅ GOOD - Adding at the end
contract StructuredPool is ... {
  // Existing variables (DO NOT CHANGE ORDER)
  IERC20 public asset;
  string public poolName;
  address public manager;

  // New variable added at end
  uint256 public newFeature;

  // Reduced gap from 50 to 49
  uint256[49] private __gap;
}

❌ Unsafe Practices

solidity
// ❌ BAD - Reordering variables
contract StructuredPool is ... {
  uint256 public newFeature;    // ❌ Added at beginning
  IERC20 public asset;          // ❌ Moved from first position
  string public poolName;
}

// ❌ BAD - Changing types
contract StructuredPool is ... {
  address public asset;         // ❌ Changed from IERC20
  string public poolName;
}

// ❌ BAD - Removing variables
contract StructuredPool is ... {
  // IERC20 public asset;       // ❌ Removed
  string public poolName;
}

Upgrade Validation

Before Upgrading

Use the validation script to check compatibility:

bash
cd packages/protocol
npx hardhat run scripts/v2.1/validateUpgrade.ts --network <network>

The script performs 8 critical checks:

  1. ✅ Storage layout compatibility
  2. ✅ OpenZeppelin upgrade safety
  3. ✅ Interface compatibility
  4. ✅ Access control preservation
  5. ✅ Initialization protection
  6. ✅ Version tracking
  7. ✅ Gas usage analysis
  8. ✅ State variable validation

Checklist

Before upgrading on mainnet:

  • [ ] New implementation compiles without errors
  • [ ] Storage layout is compatible (run validation script)
  • [ ] All tests pass with new implementation
  • [ ] Upgrade validated with OpenZeppelin's validator
  • [ ] Upgrade tested on testnet first
  • [ ] Protocol admin and timelock admin roles verified
  • [ ] State persistence verified after upgrade
  • [ ] All view functions return expected values
  • [ ] All state-changing functions work correctly
  • [ ] Gas usage analyzed and acceptable
  • [ ] Security review completed
  • [ ] Stakeholders notified
  • [ ] Rollback plan prepared

Gas Costs (Celo Mainnet)

Costs calculated with 25 Gwei gas price and $0.24867 CELO price

Deployment Costs

ComponentGasCost (CELO)Cost (USD)
Registry Implementation2.5M62.5 CELO$15.54
Registry Proxy200k5.0 CELO$1.24
Pool Beacon1.5M37.5 CELO$9.33
Tranche Beacon1.5M37.5 CELO$9.33
BeaconProxy (pool)200k5.0 CELO$1.24
BeaconProxy (tranche)200k5.0 CELO$1.24

Operation Costs

OperationGasCost (CELO)Cost (USD)
Schedule Registry upgrade50k1.25 CELO$0.31
Execute Registry upgrade100k2.5 CELO$0.62
Schedule beacon upgrade50k1.25 CELO$0.31
Execute beacon upgrade100k2.5 CELO$0.62
Cancel upgrade30k0.75 CELO$0.19
Set upgrade delay40k1.0 CELO$0.25

Mass Upgrade Savings

Scenario: Upgrade 100 pools

  • BeaconProxy: ~3.75 CELO ($0.93) total

    • Schedule: 1.25 CELO
    • Execute: 2.5 CELO
    • All 100 pools upgraded simultaneously
  • Individual UUPS: ~375 CELO ($93+) total

    • Each pool: ~3.75 CELO
    • 100 separate transactions required

Savings: 99% cost reduction for mass upgrades! 🎉

Emergency Procedures

If Issues Are Discovered

Option 1: Cancel Pending Upgrade

typescript
// Before timelock expires
await contract.connect(protocolAdmin).cancelUpgrade()

Option 2: Upgrade to Fixed Version

typescript
// Deploy fixed implementation
const fixedImpl = await Factory.deploy()

// Schedule new upgrade
await contract.connect(protocolAdmin).scheduleUpgrade(
  await fixedImpl.getAddress()
)

// Wait timelock and execute

Option 3: Adjust Settings (StructuredPool)

typescript
// Reduce draw limits via settings change
await pool.proposeCreditLineSettingsChange({
  ...currentSettings,
  drawLimit: reducedAmount
})

Option 4: Deactivate Pool (Registry)

typescript
// Emergency pool deactivation
await registry.connect(protocolAdmin).deactivatePool(poolAddress)

Rollback Procedure

To rollback to a previous implementation:

typescript
// Redeploy old implementation (if not saved)
const oldImplementation = await OldFactory.deploy()

// Schedule rollback
await contract.connect(protocolAdmin).scheduleUpgrade(
  await oldImplementation.getAddress()
)

// Wait timelock
// Execute rollback
await contract.connect(protocolAdmin).executeUpgrade(
  await oldImplementation.getAddress()
)

⚠️ Rollback is only safe if:

  • No new state variables were added
  • No incompatible state changes were made
  • The old implementation is compatible with current state
  • All instances can safely use the old logic

Version Tracking

All upgradeable contracts include a version() function:

solidity
function version() external pure returns (string memory) {
  return "2.1.0";
}

Track versions across the protocol:

typescript
// Registry
const registryVersion = await registry.version()

// Get beacon addresses
const factory = await ethers.getContractAt('PoolFactory', FACTORY_ADDRESS)
const poolBeacon = await factory.poolBeacon()
const trancheBeacon = await factory.trancheBeacon()

// Get current implementations
const { poolImplementation, trancheImplementation } =
  await factory.getCurrentImplementations()

// Check versions
const pool = await ethers.getContractAt('StructuredPool', poolImplementation)
const poolVersion = await pool.version()

console.log('Protocol versions:')
console.log('- Registry:', registryVersion)
console.log('- Pool:', poolVersion)

Best Practices

Security

  1. Use separate multi-sigs for PROTOCOL_ADMIN and TIMELOCK_ADMIN
  2. Never share keys between the two admin roles
  3. Keep admin keys secure with hardware wallets
  4. Monitor upgrade events with alerts
  5. Have incident response plan ready

Testing

  1. Always test on testnet first before mainnet upgrades
  2. Run upgrade simulation on a fork before production
  3. Test state preservation thoroughly
  4. Verify all functions work after upgrade
  5. Load test if significant logic changes

Documentation

  1. Keep implementation addresses for potential rollback
  2. Document all state changes in upgrade notes
  3. Track version history in deployment records
  4. Notify stakeholders before upgrades
  5. Post-mortem after each upgrade

Monitoring

  1. Monitor closely for 24-48 hours after upgrades
  2. Check event logs for unexpected behavior
  3. Verify gas costs match expectations
  4. Monitor user transactions for failures
  5. Have rollback plan ready if issues arise

Complete Upgrade Example

Upgrading All StructuredPool Instances

typescript
import { ethers } from 'hardhat'

async function upgradeAllPools() {
  const [protocolAdmin] = await ethers.getSigners()

  console.log('🔄 Upgrading All StructuredPool Instances')

  // 1. Deploy new implementation
  const PoolFactory = await ethers.getContractFactory('StructuredPool')
  const newImpl = await PoolFactory.deploy()
  await newImpl.waitForDeployment()
  const newImplAddress = await newImpl.getAddress()

  console.log('✅ New implementation:', newImplAddress)

  // 2. Get beacon
  const factory = await ethers.getContractAt('PoolFactory', FACTORY_ADDRESS)
  const poolBeaconAddress = await factory.poolBeacon()
  const beacon = await ethers.getContractAt(
    'UpgradeableBeaconWithTimelock',
    poolBeaconAddress
  )

  // 3. Check current state
  const currentImpl = await beacon.implementation()
  console.log('Current implementation:', currentImpl)

  // 4. Schedule upgrade
  const scheduleTx = await beacon
    .connect(protocolAdmin)
    .scheduleUpgrade(newImplAddress)
  await scheduleTx.wait()

  console.log('✅ Upgrade scheduled')

  // 5. Get upgrade info
  const upgradeInfo = await beacon.getUpgradeInfo()
  const scheduledTime = new Date(Number(upgradeInfo.scheduledTime) * 1000)
  console.log('Scheduled for:', scheduledTime)
  console.log('Delay:', upgradeInfo.upgradeDelay, 'seconds')

  // 6. Wait for timelock (in production, this would be 2 days)
  console.log('⏳ Waiting for timelock...')
  await ethers.provider.send('evm_increaseTime', [
    Number(upgradeInfo.upgradeDelay) + 1
  ])
  await ethers.provider.send('evm_mine', [])

  // 7. Execute upgrade
  const executeTx = await beacon
    .connect(protocolAdmin)
    .executeUpgrade(newImplAddress)
  await executeTx.wait()

  console.log('✅ Upgrade executed')

  // 8. Verify upgrade
  const newCurrentImpl = await beacon.implementation()
  console.log('New implementation:', newCurrentImpl)
  console.log('Match:', newCurrentImpl === newImplAddress)

  // 9. Test a few pool instances
  const pools = await registry.getActivePools(0, 5)
  console.log(`\n📊 Testing ${pools.pools.length} pool instances:`)

  for (const poolAddress of pools.pools) {
    const pool = PoolFactory.attach(poolAddress)
    const version = await pool.version()
    const poolInfo = await pool.getPoolInfo()

    console.log(`- ${poolAddress}:`)
    console.log(`  Version: ${version}`)
    console.log(`  Name: ${poolInfo.name}`)
    console.log(`  State preserved: ✅`)
  }

  console.log('\n🎉 Mass upgrade successful!')
  console.log(`All ${pools.total} pools upgraded simultaneously`)
}

Monitoring & Alerts

Events to Monitor

solidity
// Registry & Beacons
event UpgradeScheduled(address indexed implementation, uint256 scheduledTime)
event UpgradeCanceled(address indexed implementation)
event Upgraded(address indexed implementation)
event UpgradeDelayUpdated(uint256 newDelay)

// Access Control
event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender)
event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender)

Monitoring Script Example

typescript
// Listen for upgrade events
beacon.on('UpgradeScheduled', (implementation, scheduledTime) => {
  console.log('⚠️ UPGRADE SCHEDULED')
  console.log('Implementation:', implementation)
  console.log('Scheduled:', new Date(scheduledTime * 1000))

  // Send alerts to team
  sendAlert('Upgrade scheduled', { implementation, scheduledTime })
})

beacon.on('Upgraded', (implementation) => {
  console.log('✅ UPGRADE EXECUTED')
  console.log('New implementation:', implementation)

  // Verify and alert
  verifyUpgrade(implementation)
  sendAlert('Upgrade executed', { implementation })
})

Resources

Support

For questions or issues related to upgradeability:

  • Review this documentation
  • Check test files in test/v2.1/*/Upgrade.test.ts
  • Review upgrade scripts in scripts/v2.1/
  • Consult OpenZeppelin documentation
  • Contact the protocol team

Last Updated: 2026-01-20 Protocol Version: v2.1.0 Status: Production Ready