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, Oracle Keeper, Treasury. Live addresses: Contract Addresses.
1. Principals
| Principal | Storage | Scope |
|---|---|---|
owner() | AccessControl | Pool/Admin/Factory/oracle governance; halt and un-halt; fences; listing |
isGuardian(addr) | mapping whitelist | Halt / tighten / cancel only (never un-halt, widen, or write pool params). One exception: ExternalOracleV4.setFeedExpBias, Guardian |
isRiskSteward(addr) | mapping whitelist | Admin.setAssetParamsBounded under owner fences + relative risk-up clamp |
isKeeper(addr) | mapping whitelist | Keeper 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 / setKeeper; the steward grant is queueRole(RISK_STEWARD) → executeRole at the LISTING delay, revoke is instant revokeRiskSteward.
One governance principal. Pool deployment is permissionless (PoolFactory.createPool); pool administration always resolves to AccessControl.owner(), no per-pool curator. 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 a production-shaped deployment rather than making one.
| Condition | Check |
|---|---|
| Every governance lane gives usable notice | shortest tier delay >= Constants.MIN_ARMED_DELAY (1 h) |
owner() is a k-of-n multisig | QuorumLib.checkAdmin: n in [3, 16], k >= ceil(2n/3) |
At least MIN_GUARDIANS (1) guardian appointed | guardianCount >= MIN_GUARDIANS |
| The attested guardian array is the live set | length == guardianCount, strictly ascending, every entry isGuardian |
| 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: the point is a fast lever |
After arming, setGuardian(g, false) reverts ThresholdViolation once it would drop guardianCount to zero, and every newly granted guardian must 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. Halt authority
Halt is immediate (no timelock). Release is owner-only.
| Action | Who | Delay |
|---|---|---|
haltAsset / batchRiskOp halt | owner or isGuardian | None |
unhaltAsset / batchRiskOp un-halt | owner only | None |
collapseAnchor (re-anchor toward the root, halts the leg; reverts on a depth-1 pool, Guardian) | owner or isGuardian | None |
ExternalOracleV4.pauseFeed / updateFeed / revokeSigner / setFeedExpBias | owner or isGuardian | None |
ExternalOracleV4.revokeSession | any signer, owner or isGuardian | None |
ExternalOracleV4.unpauseFeed | owner only | None |
UpgradeGate.pause() on Admin / Flash | owner or isGuardian | None |
UpgradeGate.unpause() | owner only | None |
| Ownership handover | Solady 2-step completeOwnershipHandover | 48 h request expiry |
| Cancel beacon upgrade | owner or guardian | During timelock / grace |
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 above act on a feed 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 wallet chain before send: pool salt includes block.chainid (PoolFactory.sol).
2.2. Fleet sweep
| Mechanism | Behavior |
|---|---|
| Discovery | getOfficialPoolsForToken → getPoolTokens |
| Registration | initAsset → PoolFactory.registerTokens |
| Missing leg | Admin.haltAsset(pool, token, src) |
2.3. Contact
security@btr.markets (reasonable-best-efforts). Status / Discord channels announced pre-mainnet.
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, OracleBeacon, ExternalOracle) derives its own tier delays from that word at construction via Constants.delayOf(schedule, tier). No setter, 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 as not deployable. Public testnets pass Constants.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. TUNING does not scale with the
deployment: one hour is MIN_ARMED_DELAY, the floor the timelock arms at. Risk parameters are
bounded by PoolConfig 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 | Pool beacon swap, Admin / Flash UUPS swap, 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, the oracle implementation upgrade (OracleBeacon), signer grant / threshold decrease, guardian and risk-steward grants, PoolFactory.setOfficial post-arm |
TUNING | 1 hour | 1 hour | UPDATE_RISK, UPDATE_PROFILE, UPDATE_FEES, a weakening setAssetParams |
GRACE_PERIOD | 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 Timelock.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, 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
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 | executeUpdateRiskConfig, executeUpdateProfile, executeUpdateFeeParams | TUNING |
ADD_ASSET, UPDATE_CURVE, UPDATE_HOOK | executeAddAsset, executeSetCurve, executeSetAssetHook | LISTING |
UPDATE_TREASURY, SWEEP, MIGRATE_BASE_TOKEN, UPDATE_ANCHOR, BACKFILL_LEGS, UPDATE_POOL_ADMIN | executeTreasuryUpdate, executeSweep, executeBaseMigration, executeAnchorUpdate, executeBackfillLegs, acceptPoolAdmin | GOVERNANCE |
There is no oracle repoint op. Each pool reads one immutable oracle proxy address; the implementation behind it moves through OracleBeacon.requestUpgrade → LISTING → executeUpgrade, guardian-cancellable.
Three owner levers in the Immediate row bypass Admin and its timelock entirely. None 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. 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 10) 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, 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 executeSetAssetParams.
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 and raiseKappa land now, every other direction is a queued UPDATE_RISK (Admin §5.3).
| Class | Entry | Tier |
|---|---|---|
| Immediate | haltAsset / unhaltAsset / batchRiskOp; collapseAnchor; risk fences; steward-bounded params; raiseKappa; setRiskConfigTighten; Pool.adminSetDeadSeedPow10; PoolFactory.deregisterPool / setProtocolDeployer | none |
| Factory beacon | requestReferenceUpgrade / executeReferenceUpgrade | GOVERNANCE |
| Singleton UUPS | requestUpgrade / executeUpgrade on Admin, Flash | GOVERNANCE (owner) |
| Oracle beacon | OracleBeacon.requestUpgrade / executeUpgrade | LISTING |
| 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_PERIODor request expires - Cancel anytime before execute (
cancelReferenceUpgrade/cancelUpgrade): owner or guardian
Beacon swap re-points every live pool. Procedure: Admin.
6. 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 LISTING, tightening and revocation are immediate. Full ceremony and constants:
Oracle Keeper.
7. Related
| Page | Content |
|---|---|
| Deployer | Deploy order, CREATE3 addresses, handover |
| Admin | Beacon + UUPS 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 |