Skip to content

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/.

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.

ContractPatternHow it changesDelay before effect
NoteCore, NoteLegsImmutableNew core deployed, registered in SeriesRegistry, old one deprecated. Live series finish on the old core48 h (Timelock)
OracleAdapter, MarketCalendarImmutable, swappable modulesetOracle / setCalendar on the core with a 24-hour grace48 h + 24 h grace, at least 72 h visible on-chain
Fee sink, keeper rewardSwappable module / parametersetFeeSink, setKeeperReward with the same grace72 h
SeriesRegistryImmutable directoryregister(core), deprecate(version)48 h
NOTE, sNOTEImmutableNever; parameters only48 h for parameters
Treasury, BondDepository, RevenueRouter, DeskUUPS proxyupgradeToAndCall from UpgradeTimelock only1 d + 7 d vote + 7 d timelock
veNOTE, GaugeController, EmissionSchedule, Minter, gaugesImmutableParameters via the Timelock; replacement by registering new gauges or Minter.setSchedule48 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.

fact NoteCore and OracleAdapter keep {previous, current, effectiveAt} per slot.

SlotSetterKeyEffective for
Oracle adaptersetOracleKEY_ORACLEfinalizeStrike / observe with closeTs ≥ effectiveAt
Market calendarsetCalendarKEY_CALENDARcreateSeries validation after effectiveAt
Fee sinksetFeeSinkKEY_FEE_SINKsweepFees after effectiveAt
Keeper rewardsetKeeperRewardKEY_KEEPER_REWARDObservations with closeTs ≥ effectiveAt
Backup feed (OracleAdapter)setBackupFeedkeccak256("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.

fact SeriesRegistry is an immutable, Ownable directory owned by the Timelock. Versions are dense from 1; 0 means unknown.

FunctionWhoNotes
register(core)TimelockReturns the new version; ZeroAddress, AlreadyRegistered; emits CoreRegistered
deprecate(version)TimelockUnknownVersion, AlreadyDeprecated; emits CoreDeprecated; irreversible
coreOf, versionOf, isDeprecated, isActive, latestVersion, activeVersion, activeCoreanyoneactiveVersion 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.

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.

guardian or Timelock Timelock · whenPaused Timelock · 48 h later holders · never pausable pause() proposeUnwind unwind withdrawUnwound cancelUnwind stops deposits, strike, observations UnwindProposed(seriesId, at) Subscription or Live only status = Unwound SeriesUnwound(id, quote, stock) exact recorded entitlement UnwoundWithdrawn per holder guardian or Timelock veto, any time before unwind
Every step is evented and the 48-hour gap between proposal and execution is enforced by the Timelock's schedule and execute pattern, with the guardian able to cancel in between.
  1. Pause. The guardian or the Timelock calls NoteCore.pause(). Deposits, finalizeStrike and observe stop; claim, redeem, refund, settle and sweepFees continue.
  2. Propose. The Timelock calls proposeUnwind(seriesId). Requires whenPaused and status Subscription or Live; emits UnwindProposed(seriesId, at). Terminal series (Autocalled, Matured, Cancelled, Unwound) cannot be proposed.
  3. Veto window. The guardian or the Timelock may cancelUnwind(seriesId) at any time before execution; emits UnwindCancelled. A proposal that goes stale because the series autocalls in the meantime is cancelled this way. While any unwind proposal is open, sweepExcess reverts UnwindPending(count).
  4. Unwind. After the 48-hour delay the Timelock executes unwind(seriesId) (still whenPaused). Status becomes Unwound; the contract snapshots quoteEscrow − accruedFees and stockEscrow and emits SeriesUnwound(seriesId, quoteLiabilities, stockLiabilities). From here observe, finalizeStrike and deposits revert InvalidStatus.
  5. Withdraw. Each holder calls withdrawUnwound(seriesId); emits UnwoundWithdrawn. This function is never pausable. A second call reverts NothingToWithdraw.
  6. Fees. sweepFees(seriesId) still moves accrued fees to the fee sink; fees are excluded from the unwind snapshot.
Unwound fromCOUPON holderSHIELD holder
SubscriptionFull quote deposit refundedStock deposit and prefund refunded in full (identical to the cancel path)
LiveCoupons indexed so far (accruedCoupon) plus 1 quote per unit, or the matched stock at s0 if the series had already breachedMatched 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.

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.

fact Each upgradeable contract keeps mutable state in an ERC-7201 namespace, keccak256(abi.encode(uint256(keccak256(id)) − 1)) & ~0xff.

Namespace (notesystems.storage.*)Slot
Governed0x9b754ecdc6a116ee5c42b031be8fc8aae1f2e9fb3149e558326532a8da474900
UUPSModule0xa4ef89c32cd12ed286b8947b3ceb30fcd07f9256502e19e63c45c9c070e17600
Treasury0x218502c5c9809673ff2667aa25f547ecaa9ebc03ad64e1d564f0f2a09e414b00
BondDepository0x255e23a844e3e88df15b8fa1a5b974630ef7b1851c21c67443f3e541a1d43c00
RevenueRouter0x56d96fa3b089e4e71b1c2847b72c2239028892528a613e566b34665c38f0b200
Desk0x99147d1708cb98ff9a3f5f0721530e4f5d50deb06d4d775775d508d1ba1e5500

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:

  1. Never reorder, remove, retype or re-pack existing namespace members. Append only, or open a fresh namespace such as notesystems.storage.<Contract>V2Extension.
  2. New initialisation uses reinitializer(2) (then 3, and so on), never initializer. Re-running initialize reverts InvalidInitialization.
  3. Re-supply every immutable identically; bump version(); keep _disableInitializers() in the constructor.
  4. Do not add state variables to inherited non-upgradeable OpenZeppelin contracts (Desk’s ERC-20 slots).
  5. Run python3 audit/storage-layout/check_layout.py check --contract X --impl X=XV2 --probe X=XStorageProbeV2 and 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.

  • SeriesRegistry is single-step Ownable without pause; a wrong transferOwnership is 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.
  • renounceOwnership on modules is not disabled at the contract level; a scheduled renounce would make unpause impossible 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.