Access Control, Roles & Emergency Powers

Single source of truth for authority in the deployed contract set: which principals exist, what each may do, how long each action takes to become executable, and which actions are immediate. Most residual power is fail-closed, so a hostile guardian can stop the protocol indefinitely while never moving value or widening a bound. Upgrade procedure: Deployment & Upgrades. Live addresses: Contract Addresses.


1. Principals

PrincipalStorageScope
owner()AccessControlPool/Admin/Factory/oracle governance; halt and un-halt; fences; listing
treasuryOwner()AccessControlCustody-split authority: YieldHook.setIncentivesReceiver, treasury / treasuryOwner rotation
isGuardian(addr)mapping whitelistHalt / tighten / cancel only (never un-halt, widen, or write pool params). One exception: ExternalOracleV4.setFeedExpBias, §2
isRiskSteward(addr)mapping whitelistAdmin.setAssetParamsBounded under owner fences + relative risk-up clamp
isKeeper(addr)mapping whitelistKeeper paths outside the oracle; oracle pushes authorize by signature, not by this whitelist

Not OpenZeppelin role bitmasks, and there is no PAUSER_ROLE. Owner sets whitelists via setGuardian / setRiskSteward / setKeeper.

owner and treasuryOwner are independent principals. Pool deployment is permissionless (PoolFactory.createPool); pool administration always resolves to AccessControl.owner() (no per-pool curator). AccessControl.Role is exactly NONE, FACTORY, TREASURY, TREASURY_OWNER.

transferOwnership and renounceOwnership both revert FeatureDisabled(TRANSFER) (AccessControl.sol). The only route to a new owner is Solady’s two-step handover with its 48 h request expiry, and completeOwnershipHandover is overridden to reject address(0) and, once the quorum policy is armed (§1.1), to require the incoming owner to satisfy the same k-of-n admin policy. That override is the complete enforcement point for the owner principal.

1.1. Arming the quorum policy

AccessControl.armQuorumPolicy(guardians) is a one-way latch: callable once, by the owner, never unset. It refuses to arm unless every condition below already holds, so arming asserts that the deployment is production-shaped rather than making it so.

ConditionCheck
Every governance lane gives usable noticeshortest tier delay >= Constants.MIN_ARMED_DELAY (1 h)
owner() is a k-of-n multisigQuorumLib.checkAdmin: n in [3, 16], k >= ceil(2n/3)
treasuryOwner likewisesame predicate
At least MIN_GUARDIANS (1) guardian appointedguardianCount >= MIN_GUARDIANS
The attested guardian array is the live setlength == guardianCount, strictly ascending, every entry isGuardian
Each guardian is itself a small multisigQuorumLib.checkGuardian(g, guardianQuorumMax): n in [1, 16] and 1 <= k <= guardianQuorumMax <= 2. Guardian policy is a ceiling, not a supermajority floor — the point is a fast lever

Two consequences follow arming:

  • setGuardian(g, false) reverts once it would drop guardianCount to zero (ThresholdViolation).
  • Every newly granted guardian must itself pass the quorum check.

quorumStatus() is the monitoring view: a multisig that lowers its own threshold post-arm is invisible to every on-chain gate but visible there.


2. Guardian surface (safe-direction)

DomainCanCannot
AssetshaltAsset / batchRiskOp halt legs; collapseAnchor (re-anchor one leg toward the root and halt it in the same write, and only where the leg has a grandparent — see below)unhaltAsset
TimelocksCancel pending ops on Admin (cancelTimelock), AccessControl (cancelRole, every role except TREASURY_OWNER), PoolFactory, UpgradeGateExecute or re-queue as owner
UpgradescancelUpgrade and pause() on Admin / Flash; PoolFactory.cancelReferenceUpgradeunpause, request, or execute an upgrade
OraclerevokeSigner, pauseFeed, updateFeed (tighten-or-equal on band and ttl), revokeSession, cancel signer-grant / threshold-decrease / feed-widenUnpause feed, widen band or ttl, release a wedged feed, grant signers

Guardians stop; they never restart or widen. Entry: Admin (_onlyGuardianOrAdmin), UpgradeGate (onlyGuardianOrUpgradeAuthority), AccessControl (isGuardianOrAuth).

collapseAnchor requires a strict ancestor of the leg’s current anchor: the walk steps to the anchor’s own anchor before its first comparison, so the target is a grandparent or higher. Every Arc leg anchors directly to the base and the base anchors to nothing, so the call reverts InvalidAnchor on the whole live fleet. It is a depth ≥ 2 topology repair, not an incident lever (Guardian Operations §2).

The only inverse of updateFeed is an owner ceremony. updateFeed is tighten-or-equal for the owner as well as the guardian, and narrowMaxDeviation does not exist on V4 in any release. Shipping in the next release, a band or ttl tightened too far — or a feed banded out past 10dmax — is released by the owner’s requestFeedWiden(feedId, maxDeviationBps, ttlSecs)BASE delay → executeFeedWiden(feedId), with a guardian-or-owner cancelFeedWiden veto that works on a live request and an expired one alike. The guardian keeps the safe direction on both halves: a tighten landed during the delay voids the pending widen by compare-and-swap on both fields, and a guardian can veto a widen but never execute one. Until the release ships, the recovery is a new oracle plus a BASE-tier UPDATE_ORACLE repoint per leg (Oracles §8.3).

One guardian entrypoint writes a price, and it is the exception to the row above: ExternalOracleV4.setFeedExpBias(feedId, newBias) is _onlyGuardianOrAdmin and untimelocked. Decode is mark = mant << (exp + bias), so a bias write moves the published mark by a power of two. It zeroes the target lane (the feed reads stale until its next push) and stamps the slot clock, costing the other seven lanes in that slot one push cycle. It is kept as break-glass on the argument that the guardian already holds pause and halt; the routine path is the quorum-signed setFeedExpBiasSigned, which clears the same k-of-n bar as a push. Whether the guardian arm should exist is an open design question, not a settled property of this surface. Runbook: Guardian Operations §2.

UpgradeGate.pause() freezes a matured upgrade request without cancelling it and is the one guardian lever with a state to undo; only the upgrade authority may unpause. TREASURY_OWNER rotation is the single cancel a guardian cannot reach; its distinct authority set is owner or the incumbent treasuryOwner.

Residual: a guardian can veto every queued governance op and can drop oracle quorum below threshold (fail-closed). Size the set accordingly.


3. Risk steward

setAssetParamsBounded only, and it never queues. Owner fences plus a relative maxDeltaBps clamp on risk-increasing moves; the tighten exemption covers minFeePbps and vegaBps only, because a haircutSuppressorBps drop is defensive for the pool but realizes LP loss on the spot, so it is always relatively clamped and additionally floored by haircutSuppressorHardMinBps on any strict decrease.

minLiquidity cannot move on this lane at all: setAssetParamsBounded reverts InvalidInput if the passed value differs from the live one.

Owner retains the unbounded setAssetParams, which is exempt from the relative clamp but not from an armed fee fence: _requireFeeFloor applies minFeeHardMinPbps on the owner lane too, and re-applies it at executeSetAssetParams in case the fence was armed while the op sat in the queue. Lowering below an armed fence therefore takes two transactions (setRiskFences first), by design. Hard protocol bounds (minFeePbps, haircut/kappa consistency, validateOracleMode) apply to every lane.

The fee floor is the only fence that binds the owner lane. setAssetParams calls _requireFeeFloor and nothing else: the vega hard bounds, the suppressor hard max and min, and maxDeltaBps are read by setAssetParamsBounded alone. Do not read an armed RiskFences row as a bound on the owner.


4. Halt authority

Halt is immediate (no timelock). Release is owner-only.

ActionWhoDelay
haltAsset / batchRiskOp haltowner or isGuardianNone
unhaltAsset / batchRiskOp un-haltowner onlyNone
collapseAnchor (re-anchor toward the root, halts the leg; reverts on a depth-1 pool, §2)owner or isGuardianNone
ExternalOracleV4.pauseFeed / updateFeed / revokeSigner / setFeedExpBiasowner or isGuardianNone
ExternalOracleV4.revokeSessionany signer, owner or isGuardianNone
ExternalOracleV4.unpauseFeedowner onlyNone
UpgradeGate.pause() on Admin / Flashowner or isGuardianNone
UpgradeGate.unpause()owner onlyNone
Ownership handoverSolady 2-step completeOwnershipHandover48 h request expiry
Cancel beacon upgradeowner or guardianDuring timelock / grace

There is one asset-level lever, not two. haltAsset(pool, token, src) sets one or more source bits in PoolConstantsLib.HALT_MASK (HALT_RISK_BIT = bit 0, HALT_GUARDIAN_BIT = bit 6); unhaltAsset(pool, token, src) clears only the bits passed, so lifting a fleet-wide guardian halt cannot relist a leg an owner risk halt still holds down. Authority is by edge, not by source: halting is guardian-or-owner for every src, un-halting is owner-only for every src. The other levers in the table act on a feed or on an upgrade request, not on an asset.

A queued timelock (MIGRATE_BASE_TOKEN / setBaseToken included) is not a pause.

Depeg is one trigger among many; its detection and auto-halt path is Depeg Halt.

4.1. Escalation

StepAction
1Detect (monitor, keeper, Bug Bounty)
2Halt via Safety Control Center → Admin.batchRiskOp over enumerated (pool, asset) legs
3Fix + review
4Owner unhaltAsset, or timelocked upgrade if code/params change

Confirm wallet chain before send: pool salt includes block.chainid (PoolFactory.sol).

4.2. Fleet sweep

MechanismBehavior
DiscoverygetOfficialPoolsForTokengetPoolTokens
RegistrationinitAssetPoolFactory.registerTokens
Missing legAdmin.haltAsset(pool, token, src)

4.3. Contact

security@btr.markets (reasonable-best-efforts). Status / Discord channels announced pre-mainnet.


5. Timelock parameters

Timelock durations SSoT. The whole fleet’s schedule is one deploy-time word: up to 8 tiers x uint32 seconds, indexed by ConstantsLib.Tier (7 members), passed to the AccessControl constructor and exposed as the immutable AccessControl.GOV_DELAYS(). Every governed contract (Admin, PoolFactory, ExternalOracle) derives its own tier delays from that word at construction via Constants.delayOf(schedule, tier). There is no setter and no proxy: the word cannot change after construction, owner included. Nothing branches on block.chainid.

Production passes Constants.PROD_DELAYS, the schedule below. No zero-delay schedule exists: the deploy path rejects any schedule whose smallest tier delay is below Constants.MIN_ARMED_DELAY (1 h), and Constants.sol marks ZERO_DELAYS explicitly as not a deployable schedule. Public testnets do not pass a throwaway word either; they pass Constants.TESTNET_DELAYS.

LOW is the one tier that does not scale with the deployment. Liquidity shape is a risk instrument rather than a constitutional change: a curve or profile that cannot be refitted inside a market session is wrong for most of that session, and the risk steward’s continuous lane already moves the fee floor, vega and haircut suppressor with no delay at all, so a day-long queue on the shape those parameters price against was the slowest part of an otherwise live loop. One hour is MIN_ARMED_DELAY, the floor the timelock will arm at, so this is the fastest a queued op can be rather than a number someone picked. ADD_ASSET, UPDATE_RISK and UPDATE_FEES ride the same tier and accelerate with it; all four are quorum-gated and bounded by PoolConfig constants at execute, so the delay is a public review window and not the safety property.

Rows are ordered by duration for reading. The Ordinal column is the ConstantsLib.Tier enum index, which is what delayOf and every ABI-level uint8 tier argument use.

TierOrdinalPROD_DELAYSTESTNET_DELAYSNotes
LOW31 hour1 hourAdd asset, risk/profile/curve/fee. Same on both: see below
BASE22 days2 hoursOracle config; signer-grant loosen
HIGH13 days3 hoursCustody pointers (treasury, hook)
CRITICAL07 days6 hoursBase-token migration, re-anchor
UPGRADE47 days6 hoursAdmin / Flash UUPS swap + pool beacon swap
ROTATION57 days6 hoursAC role rotation
FACTORY614 days12 hoursAC factory rotation
GRACE_PERIOD7 days (both schedules)7 days (both schedules)Execute window after eta; then expire. Not a tier and not tier-gated: a constant, never zeroable (0 would mean “never expires” in Timelock.validate)

6. Owner-gated op classes

Every pool op queues through ONE generic entrypoint, requestOp(pool, uint8 opType, bytes32 subject, bytes payload), and cancels through ONE, cancelTimelock(pool, opType, subject). opType is the IPool.OpType enum ordinal, so the declaration order below is load-bearing for any caller encoding a uint8:

enum OpType { NONE, // 0 MIGRATE_BASE_TOKEN, // 1 UPDATE_ANCHOR, // 2 UPDATE_TREASURY, // 3 UPDATE_HOOK, // 4 UPDATE_ORACLE, // 5 ADD_ASSET, // 6 UPDATE_RISK, // 7 UPDATE_FEES, // 8 UPDATE_PROFILE, // 9 UPDATE_CURVE, // 10 UPDATE_ASSET_PARAMS // 11 }

requestOp selects the tier from one exhaustive table and reverts InvalidInput on any value that table does not name; there is no default arm that would hand an unknown op the shortest delay. Execute is one named function per op. Entry points on Admin unless noted.

opTypeExecuteTier
ADD_ASSET, UPDATE_RISK, UPDATE_PROFILE, UPDATE_CURVE, UPDATE_FEESexecuteAddAsset, executeUpdateRiskConfig, executeUpdateProfile, executeSetCurve, executeUpdateFeeParamsLOW
UPDATE_ORACLEexecuteOracleUpdate; signer grant / threshold decrease on ExternalOracleBASE
UPDATE_TREASURY, UPDATE_HOOK (custody pointers)executeTreasuryUpdate, executeSetAssetHookHIGH
MIGRATE_BASE_TOKEN, UPDATE_ANCHORexecuteBaseMigration, executeAnchorUpdateCRITICAL

Three owner levers in the Immediate row bypass Admin and its timelock entirely, and they are worth naming because none of them is a pool-param write:

  • Pool.adminSetDeadSeedPow10(token, pow10) is gated directly on AccessControl.owner(), not on the Admin singleton. It sets the dead-seed exponent used to price the first depositor’s index pin, is bounded at decimals + 3, and takes effect only while the leg is still unseeded.
  • PoolFactory.deregisterPool(pool) evicts a pool from every discovery index. Pool deployment is permissionless, so this is the de-pollution lever for griefing clones; it does not touch the pool’s funds or its ability to trade.
  • PoolFactory.setProtocolDeployer(newDeployer) changes which address mints pools that count as official. Untimelocked, so a compromised owner key can point “official” at itself for as long as it holds the key.

UPDATE_ASSET_PARAMS (ordinal 11) is deliberately absent from that table and cannot be passed to requestOp; the call reverts. Whether a params write queues or applies instantly is the defensive-tighten policy, decided inside setAssetParams, which is the only entrypoint that may queue one: it writes immediately before bootstrapSealed[pool] or on a defensive tighten, and otherwise queues itself at LOW for executeSetAssetParams. Any minLiquidity change queues in either direction, because a raise halts outflow instantly while its reversal would wait a day, and haltAsset already covers that need on both edges.

ClassEntryTier
ImmediatehaltAsset / unhaltAsset / batchRiskOp; collapseAnchor; risk fences; steward-bounded params; Pool.adminSetDeadSeedPow10; PoolFactory.deregisterPool / setProtocolDeployernone
Factory beaconrequestReferenceUpgrade / executeReferenceUpgradeUPGRADE
Singleton UUPSrequestUpgrade / executeUpgrade on Admin, FlashUPGRADE (owner)
AC rotationstreasury / treasuryOwner / factoryROTATION / FACTORY

7. Upgrade sequence

  1. Queue: PoolFactory.requestReferenceUpgrade, or requestUpgrade on Admin / Flash
  2. Wait the UPGRADE tier delay (7 d under PROD_DELAYS)
  3. Execute inside GRACE_PERIOD or request expires
  4. Cancel anytime before execute (cancelReferenceUpgrade / cancelUpgrade): owner or guardian

Beacon swap re-points every live pool. Procedure: Deployment & Upgrades §4.


8. Oracle push roles

Signers are a granted set on the oracle and authorize by signature. The relayer that lands the batch is unpermissioned on the signed path (pushSignedV4); V4’s session path (pushV4) pins msg.sender to the single relay named in a quorum-signed SessionGrant, capped at one hour and revocable by any one signer. Either way the relay holds no price authority. Quorum loosening is timelocked at BASE, tightening and revocation are immediate. Full ceremony, constants and rationale: Oracle Price-Push Security §4.


PageContent
Deployment & UpgradesBeacon + UUPS procedure
Depeg HaltAutomated halt trigger
AdminPer-op Admin API
Contract AddressesLive AccessControl address