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.
Controls
Section titled “Controls”| Control | UUPS upgrade | Core replacement |
|---|---|---|
| Who can execute | Only UpgradeTimelock via _authorizeUpgrade; every other caller reverts NotUpgradeTimelock(caller) | Only the 48-hour Timelock may register and deprecate |
| Who can propose | UpgradeGovernor (veNOTE vote, quorum 8%, 7-day vote) | NoteGovernor (quorum 4%, 5-day vote) |
| Who can veto | Guardian, CANCELLER_ROLE on UpgradeTimelock | Guardian, CANCELLER_ROLE on Timelock |
| Minimum notice on-chain | 7 days queued, after a 7-day vote and 1-day delay | 48 hours queued |
| Blast radius | One proxy’s logic; state preserved in ERC-7201 namespaces | None for live series; only which core accepts createSeries |
| Rollback | Schedule the previous implementation through the same 7-day path | register the old core again as a new version (deprecation is irreversible per version) |
| What it cannot do | Move funds without a matching function in V2 that governance would also have to call; shorten its own delay without a 7-day setUpgradeTimelock | Change 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.
Storage-layout gate
Section titled “Storage-layout gate”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>.jsonpython3 audit/storage-layout/check_layout.py check # CI: fail if any existing slot changedpython3 audit/storage-layout/check_layout.py check --contract Desk --probe Desk=DeskStorageProbeV2Append --impl Desk=DeskV2 # must PASSpython3 audit/storage-layout/check_layout.py check --contract Desk --probe Desk=DeskStorageProbeV2Reorder # must FAILA 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.
Test gate
Section titled “Test gate”| Layer | What must pass before an upgrade proposal |
|---|---|
| Unit | Full token suite including GovernedUpgradeable.t.sol and DeployToken.t.sol |
| Upgrade | Upgrade.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 |
| Layout | check_layout.py check against the live snapshot with the V2 implementation and probe |
| Invariants | Escrow, Desk, Treasury and governance invariant suites unchanged or extended; see Invariants |
| Static | Slither 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.
Reviewer checklist
Section titled “Reviewer checklist”Before voting for or executing an upgrade, a reviewer should be able to tick every line from on-chain data and the published source.
- The proposal description begins with
# <title>and has a## Risksection naming the contracts touched and the version bump. - The queued operation on
UpgradeTimelocktargets the expected proxy address (see Addresses) and callsupgradeToAndCall(v2, calldata); thecalldatais either empty or areinitializer(2)call whose arguments are stated in the proposal. - 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 currentversion(). check_layout.py checkpasses against the committed snapshot with the V2 implementation and probe; the CI run is linked.- No state variable was added to inherited non-upgradeable OpenZeppelin contracts (
DeskERC-20 slots 0 to 4). - The operation does not call
setUpgradeTimelock,renounceOwnershiportransferOwnershipunless that is the stated purpose of the proposal. - Pause status: if the module is paused for the upgrade, the proposal states who unpauses and when. Exits remain live during a pause regardless.
- Timing: the operation’s
executetime is at least 7 days afterschedule, and the guardian’s veto window has been publicly announced.
Signals to watch
Section titled “Signals to watch”Upgraded(address implementation)on any proxy without a matching executed proposal.CallScheduledon either timelock whose target or selector is not in the proposal.RoleGrantedorRoleRevokedon either timelock; roles should only change through a proposal.CoreRegisteredorCoreDeprecatedonSeriesRegistry.ModuleUpdatedwith aneffectiveAtsooner 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.