Access Control
SSoT for authority in the deployed contract set: principals, what each may do, and how long each action takes to become executable. Residual power is fail-closed: a hostile guardian can stop the protocol indefinitely while never moving value or widening a bound. Per-role detail lives in the children: Deployer, Admin, Risk Steward, Guardian, Mark Relayer, Treasury. Live addresses: Contract Addresses.
1. Principals
| Principal | Storage | Scope |
|---|---|---|
owner() | AccessControl | Pool deployment (PoolFactory.createPool, owner-only); Pool/Admin/Factory/oracle governance; halt and un-halt; fences; listing |
guardian (perms bit 17) | AccessControl.perms word | Halt / tighten / cancel only (never un-halt, widen, or write pool params), Guardian |
risk steward (perms bit 18) | AccessControl.perms word | Admin.setAssetParamsBounded under owner fences + relative risk-up clamp; the one-way tighten lane, Risk Steward §1 |
keeper (perms bit 16) | AccessControl.perms word | Gates the yield hook’s invest, trim and sync only; mark pushes authorize by signature and the store’s relayer list, not by this bit |
Roles are bits of one perms word per address: bits 0-15 are the instant per-leg gate lanes, 16 keeper, 17 guardian, 18 risk steward. There is no PAUSER_ROLE. setPerms(who, mask, on) grants lanes and the keeper bit instantly and clears any bit instantly; a risk-steward grant, and a guardian grant once armed, queue at LISTING (queueRole → executeRole). Once armed, removing the last guardian queues at GOVERNANCE (GUARDIAN_REVOKE).
One governance principal. Pool deployment is owner-gated: PoolFactory.createPool is onlyAdmin and resolves to the chain’s single AccessControl.owner(), because every pool reservation shares one CREATE3 namespace (the factory is the CREATE3 sender), so any other caller could spend a mined reservation on a pool of its own. The owner names each pool’s admin seat in the initdata it passes; seat 0 is the protocol sentinel resolving back to AccessControl.owner(). AccessControl.Role is exactly NONE (0), FACTORY (1), TREASURY (2), OWNER (3), RISK_STEWARD (4), GUARDIAN (5), GUARDIAN_REVOKE (6). The treasury is a pointer the owner rotates, not a principal (Treasury).
Ownership handover and the transferOwnership / renounceOwnership reverts: Admin.
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; it does not make it so.
| Condition | Check |
|---|---|
| Every governance lane gives usable notice | shortest tier delay >= ConstantsLib.MIN_ARMED_DELAY_SECS (1 h) |
owner() is a k-of-n multisig | QuorumLib.checkAdmin: n in [3, 16], k >= ceil(2n/3) |
At least MIN_ARM_GUARDIANS (1) guardian appointed | guardianCount >= MIN_ARM_GUARDIANS |
| The attested guardian array is the live set | length == guardianCount, strictly ascending, every entry holds the guardian bit |
| Each guardian is itself a small multisig | QuorumLib.checkGuardian(g, guardianQuorumMax): n in [1, 16] and 1 <= k <= guardianQuorumMax <= 2. Guardian policy is a ceiling, not a supermajority floor, so the lever stays fast |
After arming, an instant guardian clear reverts ThresholdViolation once it would drop guardianCount to zero, and every newly granted guardian must pass the quorum check.
There is no on-chain monitoring view for this. A multisig that lowers its own threshold post-arm is invisible to every on-chain gate; the residual is detectable only off-chain, by reading getOwners() / getThreshold() against QuorumLib.admin(n) directly.
2. Halt authority
Halt is immediate (no timelock). On a pool without a seated admin, release is owner-only.
| Action | Who | Delay |
|---|---|---|
haltAsset | owner, guardian, or the pool’s seat; a guardian or owner halt stamps the seat’s release clock | None |
unhaltAsset | owner, always; on a seated pool also the seat, once LISTING has passed since the first unreleased guardian or owner halt (at once if only the seat halted) | None |
batchRiskOp | halt: owner or guardian; un-halt: owner; legs of seated pools are skipped (BatchLegSkipped) | None |
collapseAnchor (re-anchor toward the root, halts the leg; reverts on a depth-1 pool and on a seated pool, Guardian) | owner or guardian | None |
Mark store halt(lane, tierMask) | owner, guardian or risk steward | None |
Mark store unhalt(lane, tierMask) | owner only | None |
Mark store cancel(op) | owner or guardian | None |
UpgradeGate.pause() on Admin / Flash | owner or guardian | None |
UpgradeGate.unpause() | owner only | None |
| Ownership handover | pre-arm: Solady completeOwnershipHandover; post-arm: queueRole(OWNER) → executeRole | pre-arm: 48 h request expiry; post-arm: GOVERNANCE, guardian-cancellable |
Cancel fleet upgrade (cancelReferenceUpgrade) | owner or guardian | During timelock / grace |
One asset-level lever. haltAsset(pool, token, HALT_BIT) sets bit 0 of Asset.flags (layout: Guards §1); unhaltAsset(pool, token, HALT_BIT) clears it. Who may clear it is the table above; the seat’s side: Pool Deployment & Curation §2. The other levers above act on an oracle lane or 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 Flow Guards.
2.1. Escalation
| Step | Action |
|---|---|
| 1 | Detect (monitor, keeper, Bug Bounty) |
| 2 | Halt via Safety Control Center → Admin.batchRiskOp over enumerated (pool, asset) legs |
| 3 | Fix + review |
| 4 | Owner unhaltAsset, or timelocked upgrade if code/params change |
Confirm the wallet’s chain before sending: Guardian Routines §1.
2.2. Fleet sweep
| Mechanism | Behavior |
|---|---|
| Discovery | getOfficialPoolsForToken → getPoolTokens |
| Registration | Pool.adminInitAsset → PoolFactory.registerTokens |
| Missing leg | Admin.haltAsset(pool, token, src) |
2.3. Contact
security@btr.markets (reasonable-best-efforts).
3. 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 (3 members), passed to the AccessControl constructor and exposed as the immutable AccessControl.GOV_DELAYS(). Every governed contract (Admin, PoolFactory, Flash, the mark store logic) derives its own tier delays from that word at construction via ConstantsLib.delayOf(schedule, tier). No setter, no proxy: the word cannot change after construction, owner included. Nothing branches on block.chainid.
Production passes ConstantsLib.PROD_DELAYS, the schedule below. No zero-delay schedule exists: the deploy path rejects any schedule whose smallest tier delay is below ConstantsLib.MIN_ARMED_DELAY_SECS (1 h), and ConstantsLib.sol marks ZERO_DELAYS as not deployable. Public testnets pass ConstantsLib.TESTNET_DELAYS.
Tiers split by what an op changes: structure (code, who governs, protocol money, re-rooting) waits a
week, listing and pricing waits a day, bounded numbers wait an hour. Direction splits them again: a
risk-reducing steward write executes instantly and unbounded, while the same field in the loosening
direction keeps its tier (Parametrization §2). TUNING does not scale with the
deployment: one hour is MIN_ARMED_DELAY_SECS, the floor the timelock arms at. Risk parameters are
bounded by PoolConfigLib constants at execute, so the delay is a public review window and not the
safety property. De-risking stays instant on every tier: halt, tighten, cancel, unhalt and unpause
live outside the schedule.
Tier | PROD_DELAYS | TESTNET_DELAYS | Covers |
|---|---|---|---|
GOVERNANCE | 7 days | 6 hours | Fleet upgrade (Pool implementation and its mark store), Admin / Flash UUPS swap, mark-store roster (setAuth), AC role and factory rotation, treasury, sweep, base-token migration, re-anchor, leg-roster backfill, GEN-1 seat handover |
LISTING | 1 day | 2 hours | ADD_ASSET, UPDATE_CURVE, UPDATE_HOOK, mark-store lane register, guardian and risk-steward grants, PoolFactory.requestOfficial post-arm |
TUNING | 1 hour | 1 hour | UPDATE_RISK, UPDATE_PROFILE, UPDATE_FEES, a weakening requestAssetParams, mark-store reanchor |
GRACE_PERIOD_SECS | 7 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 TimelockLib.validate) |
4. Owner-gated op classes
Every pool op queues through one generic entrypoint, requestOp(pool, uint8 opType, bytes32 subject, bytes payload), and cancels through one, cancelOp(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
ADD_ASSET, // 5
UPDATE_RISK, // 6
UPDATE_FEES, // 7
UPDATE_PROFILE, // 8
UPDATE_CURVE, // 9
UPDATE_ASSET_PARAMS, // 10
SWEEP, // 11
BACKFILL_LEGS, // 12
UPDATE_POOL_ADMIN // 13
}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.
opType | Execute | Tier |
|---|---|---|
UPDATE_RISK, UPDATE_PROFILE, UPDATE_FEES | executeUpdateRisk, executeUpdateProfile, executeUpdateFees | TUNING |
ADD_ASSET, UPDATE_CURVE, UPDATE_HOOK | executeAddAsset, executeUpdateCurve, executeUpdateHook | LISTING |
UPDATE_TREASURY, SWEEP, MIGRATE_BASE_TOKEN, UPDATE_ANCHOR, BACKFILL_LEGS, UPDATE_POOL_ADMIN | executeUpdateTreasury, executeSweep, executeMigrateBaseToken, executeUpdateAnchor, executeBackfillLegs, acceptPoolAdmin | GOVERNANCE |
There is no oracle repoint op. Each leg names a lane of the chain’s one mark store, which lives in the Pool implementation; the store changes only with the fleet upgrade (GOVERNANCE), and a feed id maps to its lane through PoolFactory.setFeedLane.
Two owner levers in the Immediate row bypass Admin and its timelock entirely. Neither is a
pool-param write:
Pool.adminSetDeadSeedPow10(token, pow10)is gated directly onAccessControl.owner(), not on theAdminsingleton. It sets the dead-seed exponent used to price the first depositor’s index pin, is bounded atdecimals + 3, and takes effect only while the leg is still unseeded.PoolFactory.deregisterPool(pool)evicts a pool from every discovery index. It is the delisting lever for a retired or misconfigured deployment; it does not touch the pool’s funds or its ability to trade.
UPDATE_ASSET_PARAMS (ordinal 10) is 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 requestAssetParams, the only entrypoint that may queue one. It writes immediately before bootstrapSealed[pool] or on a defensive tighten, and otherwise queues itself at TUNING for executeUpdateAssetParams.
Any minLiquidity change queues in either direction. A raise halts outflow instantly while its reversal would wait out the TUNING delay (1 hour under PROD_DELAYS), and haltAsset already covers that need on both edges.
RiskConfig follows the same asymmetry: setRiskConfigTighten lands now, every other direction is a queued UPDATE_RISK. So does the quiet-tape band: raiseMinDispersion lands now at the leg’s live curveId, a cut and every curve move queue on UPDATE_PROFILE (Admin §5.3).
| Class | Entry | Tier |
|---|---|---|
| Immediate | haltAsset / unhaltAsset / batchRiskOp; collapseAnchor; the pre-seal bootstrap writes (addAsset, setCurve, requestAssetParams) and sealBootstrap; setFlowCooldown (raise-only); risk fences; PoolFactory.setFeedLane; requestOfficial(false) and cancelOfficial; steward-bounded params; raiseMinDispersion; setRiskConfigTighten; narrowRefBand; clearAssetHook; Pool.adminSetDeadSeedPow10; PoolFactory.deregisterPool | none |
| Fleet upgrade | PoolFactory.requestReferenceUpgrade / executeReferenceUpgrade (moves the mark store by migrateFrom) | GOVERNANCE |
| Singleton UUPS | requestUpgrade / executeUpgrade on Admin, Flash | GOVERNANCE (owner) |
| Mark store | setAuth / register / reanchor | GOVERNANCE / LISTING / TUNING |
| AC rotations | owner / treasury / factory; guardian and risk-steward grants at LISTING | GOVERNANCE |
5. Upgrade sequence
- Queue:
PoolFactory.requestReferenceUpgrade, orrequestUpgradeonAdmin/Flash - Wait the
GOVERNANCEtier delay (7 d underPROD_DELAYS) - Execute inside
GRACE_PERIOD_SECSor request expires - Cancel anytime before execute (
cancelReferenceUpgrade/cancelUpgrade): owner or guardian
PoolFactory.upgradeTimelock is the eta of the pending upgrade, not a configured delay: it reads zero when nothing is queued, and the notice period is the immutable DELAY_UPGRADE (GOVERNANCE tier).
The fleet upgrade migrates the mark store into the new implementation, then calls upgradeTo on every pool proxy; one failure reverts it all. Procedure: Admin.
6. Oracle push roles
Signers are a committed roster per tier on the mark store and authorize by signature.
publishMarks is the only mark path; the relayer that lands a segment
must be on the tier’s relayer list and holds no price authority, only choice of when a validly
signed segment lands, within 120 s of its source second. Roster, quorum and relayers change
together through setAuth at GOVERNANCE; the instant lever is the lane halt. Full ceremony and
constants: Mark Relayer.
7. Related
| Page | Content |
|---|---|
| Deployer | Deploy order, CREATE3 addresses, handover |
| Admin | Fleet and UUPS upgrade procedure, timelock mechanics |
| Risk Steward | The bounded risk lane |
| Guardian | Halt / tighten / cancel surface |
| Treasury | Fee sink and its rotation |
| Flow Guards | Automated halt trigger |
| Admin | Per-op Admin API |
| Contract Addresses | Live AccessControl address |