Appearance
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
| Contract | Pattern | Upgrade Scope | Timelock |
|---|---|---|---|
| Registry | UUPS Proxy | Single instance | ✅ 2 days |
| WithdrawalRegistry | UUPS Proxy | Single instance | ✅ 2 days |
| UnderwriterRegistry | UUPS Proxy | Single instance | ✅ 2 days |
| StructuredPool | BeaconProxy | Mass upgrade | ✅ 2 days |
| Tranche | BeaconProxy | Mass 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()orupgradeToAndCall()) - Cancel pending upgrades (
cancelUpgrade()) - Manage protocol configuration
- Schedule upgrades (
- 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
- Set upgrade delay period (
- 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
- Keep existing variables in the same order
- Keep existing variable types unchanged
- Add new variables at the end only
- Reduce
__gapsize 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:
- ✅ Storage layout compatibility
- ✅ OpenZeppelin upgrade safety
- ✅ Interface compatibility
- ✅ Access control preservation
- ✅ Initialization protection
- ✅ Version tracking
- ✅ Gas usage analysis
- ✅ 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
| Component | Gas | Cost (CELO) | Cost (USD) |
|---|---|---|---|
| Registry Implementation | 2.5M | 62.5 CELO | $15.54 |
| Registry Proxy | 200k | 5.0 CELO | $1.24 |
| Pool Beacon | 1.5M | 37.5 CELO | $9.33 |
| Tranche Beacon | 1.5M | 37.5 CELO | $9.33 |
| BeaconProxy (pool) | 200k | 5.0 CELO | $1.24 |
| BeaconProxy (tranche) | 200k | 5.0 CELO | $1.24 |
Operation Costs
| Operation | Gas | Cost (CELO) | Cost (USD) |
|---|---|---|---|
| Schedule Registry upgrade | 50k | 1.25 CELO | $0.31 |
| Execute Registry upgrade | 100k | 2.5 CELO | $0.62 |
| Schedule beacon upgrade | 50k | 1.25 CELO | $0.31 |
| Execute beacon upgrade | 100k | 2.5 CELO | $0.62 |
| Cancel upgrade | 30k | 0.75 CELO | $0.19 |
| Set upgrade delay | 40k | 1.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 executeOption 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
- Use separate multi-sigs for PROTOCOL_ADMIN and TIMELOCK_ADMIN
- Never share keys between the two admin roles
- Keep admin keys secure with hardware wallets
- Monitor upgrade events with alerts
- Have incident response plan ready
Testing
- Always test on testnet first before mainnet upgrades
- Run upgrade simulation on a fork before production
- Test state preservation thoroughly
- Verify all functions work after upgrade
- Load test if significant logic changes
Documentation
- Keep implementation addresses for potential rollback
- Document all state changes in upgrade notes
- Track version history in deployment records
- Notify stakeholders before upgrades
- Post-mortem after each upgrade
Monitoring
- Monitor closely for 24-48 hours after upgrades
- Check event logs for unexpected behavior
- Verify gas costs match expectations
- Monitor user transactions for failures
- 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
- OpenZeppelin UUPS Documentation
- OpenZeppelin Beacon Documentation
- EIP-1967: Proxy Storage Slots
- Proxy Upgrade Patterns
- Writing Upgradeable Contracts
- Textile Protocol Documentation
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