Upgradeability and recovery
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 audit/storage-layout/.
The hybrid model
Section titled “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 timelock.
| 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 (Timelock) |
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 UpgradeTimelock only | 1 d + 7 d vote + 7 d timelock |
| veNOTE, GaugeController, EmissionSchedule, Minter, gauges | Immutable | Parameters via the Timelock; 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 reverts NotUpgradeTimelock(caller) for anyone except the UpgradeTimelock, including the governance owner, so a compromised 48-hour path cannot shorten the upgrade delay. setUpgradeTimelock(new) is callable only by the current UpgradeTimelock, so rotating it is itself a 7-day operation.
Module swaps with grace
Section titled “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
Section titled “Versioned series and the registry”fact SeriesRegistry is an immutable, Ownable directory owned by the Timelock. Versions are dense from 1; 0 means unknown.
| Function | Who | Notes |
|---|---|---|
register(core) | Timelock | Returns the new version; ZeroAddress, AlreadyRegistered; emits CoreRegistered |
deprecate(version) | Timelock | 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 Timelock. 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.
Recovery Unwind, step by step
Section titled “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 Timelock calls
NoteCore.pause(). Deposits,finalizeStrikeandobservestop;claim,redeem,refund,settleandsweepFeescontinue. - Propose. The Timelock calls
proposeUnwind(seriesId). RequireswhenPausedand statusSubscriptionorLive; emitsUnwindProposed(seriesId, at). Terminal series (Autocalled,Matured,Cancelled,Unwound) cannot be proposed. - Veto window. The guardian or the Timelock 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 Timelock executes
unwind(seriesId)(stillwhenPaused). 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.
What a holder receives
Section titled “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
Section titled “Excess sweep”fact sweepExcess(token, to) (Timelock, 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
Section titled “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 audit/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
Section titled “Known limitations”SeriesRegistryis single-stepOwnablewithout pause; a wrongtransferOwnershipis unrecoverable, so it is only ever executed through the Timelock.- Timelock 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 Timelock can unpause.