Skip to content

Upgrade safety

fact Two kinds of code change exist. A UUPS upgrade replaces the implementation behind one of the four periphery proxies (Treasury, BondDepository, RevenueRouter, Desk). A core replacement deploys a new immutable NoteCore and moves new issuance to it through SeriesRegistry. Neither can touch note escrow held by an existing series. The design is described under Upgradeability and recovery; this page is the safety view.

ControlUUPS upgradeCore replacement
Who can executeOnly UpgradeTimelock via _authorizeUpgrade; every other caller reverts NotUpgradeTimelock(caller)Only the 48-hour Timelock may register and deprecate
Who can proposeUpgradeGovernor (veNOTE vote, quorum 8%, 7-day vote)NoteGovernor (quorum 4%, 5-day vote)
Who can vetoGuardian, CANCELLER_ROLE on UpgradeTimelockGuardian, CANCELLER_ROLE on Timelock
Minimum notice on-chain7 days queued, after a 7-day vote and 1-day delay48 hours queued
Blast radiusOne proxy’s logic; state preserved in ERC-7201 namespacesNone for live series; only which core accepts createSeries
RollbackSchedule the previous implementation through the same 7-day pathregister the old core again as a new version (deprecation is irreversible per version)
What it cannot doMove funds without a matching function in V2 that governance would also have to call; shorten its own delay without a 7-day setUpgradeTimelockChange terms of a live series; withdraw escrow

The floors in UpgradeGovernor (MIN_QUORUM_NUMERATOR = 8, MIN_VOTING_PERIOD = 7 days, MIN_TIMELOCK_DELAY = 7 days) are enforced at construction so a misconfigured deployment reverts with QuorumTooLow, VotingPeriodTooShort or TimelockDelayTooShort.

Every UUPS module stores state in a named ERC-7201 namespace; the slots are listed under Upgradeability and recovery. audit/storage-layout/check_layout.py is the gate:

python3 audit/storage-layout/check_layout.py snapshot # regenerate audit/storage-layout/<Contract>.json
python3 audit/storage-layout/check_layout.py check # CI: fail if any existing slot changed
python3 audit/storage-layout/check_layout.py check --contract Desk --probe Desk=DeskStorageProbeV2Append --impl Desk=DeskV2 # must PASS
python3 audit/storage-layout/check_layout.py check --contract Desk --probe Desk=DeskStorageProbeV2Reorder # must FAIL

A layout is compatible only if every existing member keeps its (slot, offset, type, label), new members are appended, and each namespace base slot equals both the snapshot and the *_STORAGE_LOCATION constant in source. Exit code 1 on any violation. test/upgrade/token/Upgrade.t.sol::test_storageLocations_matchErc7201Formula asserts the constants against the ERC-7201 formula on-chain, and the same suite performs a full V1 to V2 upgrade through a real TimelockController, checking every namespace member survives.

LayerWhat must pass before an upgrade proposal
UnitFull token suite including GovernedUpgradeable.t.sol and DeployToken.t.sol
UpgradeUpgrade.t.sol: direct upgradeToAndCall from owner, guardian and EOA reverts NotUpgradeTimelock; initialize re-run reverts InvalidInitialization; non-UUPS target reverts ERC1967InvalidImplementation or UUPSUnsupportedProxiableUUID; state intact after upgrade
Layoutcheck_layout.py check against the live snapshot with the V2 implementation and probe
InvariantsEscrow, Desk, Treasury and governance invariant suites unchanged or extended; see Invariants
StaticSlither and the configured detectors, see Static analysis

For a core replacement the gate is the full core suite on the new NoteCore including Governance.t.sol (schedule, warp 48 h, execute) and the NoteCore invariant run including invariant_unwoundClaimsMatchSnapshot.

Before voting for or executing an upgrade, a reviewer should be able to tick every line from on-chain data and the published source.

  1. The proposal description begins with # <title> and has a ## Risk section naming the contracts touched and the version bump.
  2. The queued operation on UpgradeTimelock targets the expected proxy address (see Addresses) and calls upgradeToAndCall(v2, calldata); the calldata is either empty or a reinitializer(2) call whose arguments are stated in the proposal.
  3. The V2 implementation is verified on the explorer at the commit named in the proposal, its constructor arguments equal V1’s immutables, and version() on the new implementation is higher than the proxy’s current version().
  4. check_layout.py check passes against the committed snapshot with the V2 implementation and probe; the CI run is linked.
  5. No state variable was added to inherited non-upgradeable OpenZeppelin contracts (Desk ERC-20 slots 0 to 4).
  6. The operation does not call setUpgradeTimelock, renounceOwnership or transferOwnership unless that is the stated purpose of the proposal.
  7. Pause status: if the module is paused for the upgrade, the proposal states who unpauses and when. Exits remain live during a pause regardless.
  8. Timing: the operation’s execute time is at least 7 days after schedule, and the guardian’s veto window has been publicly announced.
  • Upgraded(address implementation) on any proxy without a matching executed proposal.
  • CallScheduled on either timelock whose target or selector is not in the proposal.
  • RoleGranted or RoleRevoked on either timelock; roles should only change through a proposal.
  • CoreRegistered or CoreDeprecated on SeriesRegistry.
  • ModuleUpdated with an effectiveAt sooner than 24 hours from the event, which cannot happen and would indicate a different contract than the audited one.

Indexers can subscribe to these through the Events reference.