Upgradeability and Recovery
On this page
fact Implements the Upgradeability and recovery addendum of PROTOCOL_SPEC.md. Sources: contracts/src/core/{NoteCore,SeriesRegistry}.sol, contracts/src/token/upgradeable/{GovernedUpgradeable,UUPSModule}.sol, contracts/script/{DeployCore,DeployToken}.s.sol. Storage tooling in contracts/storage-layout/.
The Hybrid Model
User escrow is never subject to a code change. Everything that holds note escrow is immutable and can only be replaced; the periphery that holds protocol-owned or pooled assets is upgradeable behind a separate 7-day governance.
| Contract | Pattern | How it changes | Delay before effect |
|---|---|---|---|
NoteCore, NoteLegs | Immutable | New core deployed, registered in SeriesRegistry, old one deprecated. Live series finish on the old core | 48 h (governance) |
OracleAdapter, MarketCalendar | Immutable, swappable module | setOracle / setCalendar on the core with a 24-hour grace | 48 h + 24 h grace, at least 72 h visible on-chain |
| Fee sink, keeper reward | Swappable module / parameter | setFeeSink, setKeeperReward with the same grace | 72 h |
SeriesRegistry | Immutable directory | register(core), deprecate(version) | 48 h |
NOTE, sNOTE | Immutable | Never; parameters only | 48 h for parameters |
Treasury, BondDepository, RevenueRouter, Desk | UUPS proxy | upgradeToAndCall from upgrade governance only | 1 d + 7 d vote + 7 d governance |
| veNOTE, GaugeController, EmissionSchedule, Minter, gauges | Immutable | Parameters via the governance; replacement by registering new gauges or Minter.setSchedule | 48 h |
UUPS modules expose version() ("1.0.0" at launch), keep state in ERC-7201 namespaces and call _disableInitializers() in their constructors. _authorizeUpgrade rejects unauthorised calls for anyone except the upgrade governance, including the governance owner, so a compromised 48-hour path cannot shorten the upgrade delay. Governance-authority configuration is callable only by the current upgrade governance, so rotating it is itself a 7-day operation.
Module Swaps with Grace
fact NoteCore and OracleAdapter keep {previous, current, effectiveAt} per slot.
| Slot | Setter | Key | Effective for |
|---|---|---|---|
| Oracle adapter | setOracle | KEY_ORACLE | finalizeStrike / observe with closeTs ≥ effectiveAt |
| Market calendar | setCalendar | KEY_CALENDAR | createSeries validation after effectiveAt |
| Fee sink | setFeeSink | KEY_FEE_SINK | sweepFees after effectiveAt |
| Keeper reward | setKeeperReward | KEY_KEEPER_REWARD | Observations with closeTs ≥ effectiveAt |
| Backup feed (OracleAdapter) | setBackupFeed | keccak256("BACKUP_FEED", primary) | Resolutions with closeTs ≥ effectiveAt |
effectiveAt = block.timestamp + MODULE_GRACE (24 h). A second change while one is pending reverts ModuleChangePending(key, effectiveAt). An observation whose close is before effectiveAt, even if processed later after a deferral, resolves with previous, so a swap can never retroactively change a price that was already due. Events: ModuleUpdated(bytes32 key, address old, address new, uint40 effectiveAt), ParamUpdated(key, old, new, effectiveAt), BackupFeedSet(primary, backup, effectiveAt).
Governance should space swaps by more than the 72-hour deferralWindow, because one pending change per slot means a very old deferred observation could otherwise resolve with a newer module.
Versioned Series and the Registry
fact SeriesRegistry is an immutable, Ownable directory owned by the governance. Versions are dense from 1; 0 means unknown.
| Function | Who | Notes |
|---|---|---|
register(core) | governance | Returns the new version; ZeroAddress, AlreadyRegistered; emits CoreRegistered |
deprecate(version) | governance | UnknownVersion, AlreadyDeprecated; emits CoreDeprecated; irreversible |
coreOf, versionOf, isDeprecated, isActive, latestVersion, activeVersion, activeCore | anyone | activeVersion is the highest non-deprecated version, 0 if none |
NoteCore.createSeries reverts CoreDeprecated() unless registry.isActive(address(this)). No other core function consults the registry, so deprecation can never trap a series: every series on a deprecated core keeps observing, settling and paying out to term. Upgrading the core means deploying a new NoteCore that points at the same registry, register, then deprecate(oldVersion), all through the governance. The app’s Governance overview lists each module’s version and address, and series that have been unwound carry an Unwound status throughout the app.
Deprecating the only active version leaves activeCore() == 0; that is the intended “freeze new issuance” state, not an error. The registry does not verify that a registered address is a NoteCore; the deploy script and the proposal review process must.
Which Contracts Can and Cannot Be Upgraded in Place
Only Treasury, BondDepository, RevenueRouter and Desk inherit UUPSModule; they are the only contracts whose
code can change behind an existing address. NoteCore, NoteLegs, SeriesRegistry, OracleAdapter, MarketCalendar,
NOTE, sNOTE, veNOTE, GaugeController, EmissionSchedule, Minter, every gauge and both automation contracts
(NoteAutomation, RollPolicy) are plain deployments with no proxy. A fix to any of them is a redeploy, never an
upgrade, and any change plan that lists an “upgrade” for one of these contracts is wrong.
Core Replacement Path (a Fix to NoteCore)
A NoteCore change is a new core version. Live series stay on the old core to term; only new issuance moves. The
address of the core is an immutable in every contract that talks to it, so those contracts are redeployed as well:
fact The steps below follow from the immutables in source, not from a script; the deploy scripts cover a fresh deployment only.
- Deploy the new
NoteCoreagainst the sameSeriesRegistry,OracleAdapter,MarketCalendarandfeeSink. - Deploy a new
NoteLegsfor the new core:NoteLegs.coreis immutable and itsonlyCoremint and burn accept the one address given at construction, so legs cannot be re-pointed. Leg ids of the old core stay on the old legs contract and remain redeemable there. - Redeploy the periphery implementations whose constructors bake in
CORE,CORE_VIEWSandLEGS(Treasury,Desk,BondDepository) and schedule them through the 7-day upgrade governance as ordinary UUPS upgrades; state in the ERC-7201 namespaces is preserved. Old-core leg positions held by these modules are settled on the old core before the switch, or the new implementation keeps a read path to the old core for them. - Redeploy
NoteAutomationandRollPolicy(theircoreis immutable), fund the new automation contract and retire the old one once no old-core series still needs strikes or observations. - Redeploy
SeriesGaugeinstances for the new core (COREandLEGSare immutable),addGaugethem on theGaugeControllerandkillGaugethe old ones once their series have settled. SeriesRegistry.register(newCore)thendeprecate(oldVersion)through the 48-hour governance. From that pointcreateSerieson the old core revertsCoreDeprecated().- Update the addresses page and the core address configured in the app; the app’s governance overview reads
activeVersionandactiveCorefrom the registry and shows the switch.
Module swaps (OracleAdapter, MarketCalendar, feeSink) do not need a new core; they go through the grace-period
path described under Module swaps with grace.
Recovery Unwind, Step by Step
fact Per-series emergency exit for a Subscription or Live series when something outside the series’ own rules has gone wrong (a bug, a frozen Stock Token, a permanently dead feed). Holders receive exactly their recorded entitlement; governance sets no amounts and no address other than the holder can receive value.
- Pause. The guardian or the governance calls
NoteCore.pause(). Deposits,finalizeStrikeandobservestop;claim,redeem,refund,settleandsweepFeescontinue. - Propose. The governance calls
proposeUnwind(seriesId). RequireswhenPausedand statusSubscriptionorLive; emitsUnwindProposed(seriesId, at). Terminal series (Autocalled,Matured,Cancelled,Unwound) cannot be proposed. - Veto window. The guardian or the governance may
cancelUnwind(seriesId)at any time before execution; emitsUnwindCancelled. A proposal that goes stale because the series autocalls in the meantime is cancelled this way. While any unwind proposal is open,sweepExcessrevertsUnwindPending(count). - Unwind. After the 48-hour delay the governance executes
unwind(seriesId)(stillwhenPaused). The core enforces the delay on its own:unwindrevertsTooEarly(proposedAt + UNWIND_GRACE)untilUNWIND_GRACE = 48 hourshas elapsed sinceproposeUnwind, whatever the governance schedule. Status becomesUnwound; the contract snapshotsquoteEscrow − accruedFeesandstockEscrowand emitsSeriesUnwound(seriesId, quoteLiabilities, stockLiabilities). From hereobserve,finalizeStrikeand deposits revertInvalidStatus. - Withdraw. Each holder calls
withdrawUnwound(seriesId); emitsUnwoundWithdrawn. This function is never pausable. A second call revertsNothingToWithdraw. - Fees.
sweepFees(seriesId)still moves accrued fees to the fee sink; fees are excluded from the unwind snapshot. An Unwound series is terminal, so its keeper reserve is zero and the whole ofaccruedFeesis sweepable. - Dust. Once every holder has withdrawn, the governance may call
writeOffDust(seriesId, to)on a terminal series (Cancelled,Autocalled,MaturedorUnwound) whose remaining quote escrow is at mostdustLimitQuote()(one hundredth of a quote unit, 0.01 USDG) and whose remaining stock escrow is at mostDUST_LIMIT_STOCK(1e-6 of a Stock Token). Larger residues revertNotDust(quoteAmount, stockAmount); every bucket of the series is zeroed andDustWrittenOff(seriesId, to, quoteAmount, stockAmount)is emitted. Integrators can read the resulting status withcrank(seriesId), which runs any due strike or observation and returns theStatusafterwards without reverting on an existing series.
What a Holder Receives
| Unwound from | COUPON holder | SHIELD holder |
|---|---|---|
Subscription | Full quote deposit refunded | Stock deposit and prefund refunded in full (identical to the cancel path) |
Live | Coupons indexed so far (accruedCoupon) plus 1 quote per unit, or the matched stock at s0 if the series had already breached | Matched stock (or quote if breached) plus all remaining prefund per unit (shieldUnusedPerUnit frozen at unwind) plus any booked refunds |
Partial leg transfers before the unwind move the entitlement with the leg. The invariant suite asserts that for every Unwound series Σ paid since unwind + (quoteEscrow − accruedFees) == unwoundQuote and Σ stock paid + stockEscrow == unwoundStock, that the snapshot never changes, and that only fees plus floor-rounding dust remain after every holder has withdrawn. See Invariants.
Excess Sweep
fact sweepExcess(token, to) (governance, nonReentrant) transfers IERC20(token).balanceOf(core) − recordedLiabilities(token), where recordedLiabilities(quote) = totalQuoteEscrow and recordedLiabilities(underlying) = totalStockEscrow[underlying]; for any unrelated token the full balance is excess. Emits ExcessSwept.
- Reverts
NoExcess(token)when nothing sits above liabilities, so the call can never take from escrow. - Reverts
UnwindPending(count)while any unwind proposal is open, because paused-state accounting is unfinished. - Rounding dust is part of the recorded escrow and is therefore never sweepable.
Storage-layout Policy for UUPS Modules
fact Each upgradeable contract keeps mutable state in an ERC-7201 namespace, keccak256(abi.encode(uint256(keccak256(id)) − 1)) & ~0xff.
Namespace (notesystems.storage.*) | Slot |
|---|---|
Governed | 0x9b754ecdc6a116ee5c42b031be8fc8aae1f2e9fb3149e558326532a8da474900 |
UUPSModule | 0xa4ef89c32cd12ed286b8947b3ceb30fcd07f9256502e19e63c45c9c070e17600 |
Treasury | 0x218502c5c9809673ff2667aa25f547ecaa9ebc03ad64e1d564f0f2a09e414b00 |
BondDepository | 0x255e23a844e3e88df15b8fa1a5b974630ef7b1851c21c67443f3e541a1d43c00 |
RevenueRouter | 0x56d96fa3b089e4e71b1c2847b72c2239028892528a613e566b34665c38f0b200 |
Desk | 0x99147d1708cb98ff9a3f5f0721530e4f5d50deb06d4d775775d508d1ba1e5500 |
Desk inherits OpenZeppelin’s non-upgradeable ERC20 and ERC4626, so its ERC-20 balances, allowances, supply, name and symbol occupy plain slots 0 to 4 (recorded as top_level in the snapshot). External dependencies (QUOTE, NOTE_TOKEN, CORE, CORE_VIEWS, LEGS, TREASURY, SNOTE, ERC-4626 asset) are immutables baked into each implementation and must be re-supplied identically to a V2 constructor.
Rules for a V2:
- Never reorder, remove, retype or re-pack existing namespace members. Append only, or open a fresh namespace such as
notesystems.storage.<Contract>V2Extension. - New initialisation uses
reinitializer(2)(then 3, and so on), neverinitializer. Re-runninginitializerevertsInvalidInitialization. - Re-supply every immutable identically; bump
version(); keep_disableInitializers()in the constructor. - Do not add state variables to inherited non-upgradeable OpenZeppelin contracts (Desk’s ERC-20 slots).
- Run
python3 contracts/storage-layout/check_layout.py check --contract X --impl X=XV2 --probe X=XStorageProbeV2and the full token suite; regenerate the snapshot only after the upgrade has executed.
The checker runs forge inspect for the top-level layout and for each namespace struct via probe contracts, and fails (exit 1) if any existing member changes its (slot, offset, type, label), if members are not append-only, or if a namespace base slot differs from the snapshot or the *_STORAGE_LOCATION constant. Upgrade.t.sol additionally asserts the constants against the ERC-7201 formula on-chain. The procedure is summarised for reviewers under Upgrade safety.
Known Limitations
SeriesRegistryis single-stepOwnablewithout pause; a wrongtransferOwnershipis unrecoverable, so it is only ever executed through the governance.- governance executors are open by design; the delay and the guardian veto are the only protections against a compromised proposer.
renounceOwnershipon modules is not disabled at the contract level; a scheduled renounce would makeunpauseimpossible and is treated as forbidden by process; reviewers should reject any queued operation that calls it.- The guardian can pause indefinitely (a liveness risk for strikes and observations, never for exits); only the governance can unpause.
Keep exploring, or revisit this topic from the navigation.