---
title: "Access Control"
description: "Who may do what: principals, halt authority, timelock tiers, and owner-gated op classes."
audience: tech
type: reference
status: live
lang: en
updated: "2026-09-17"
publish: true
aliases: [3-1-access-control-roles-emergency-powers]
---
# 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](/docs/3-1-1-deployer), [Admin](/docs/3-1-2-admin),
[Risk Steward](/docs/3-1-3-risk-steward), [Guardian](/docs/3-1-4-guardian),
[Oracle Keeper](/docs/3-1-5-oracle-keeper), [Treasury](/docs/3-1-6-treasury). Live addresses:
[Contract Addresses](/docs/2-1-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](/docs/3-1-4-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](/docs/glossary#treasury) is a pointer the owner rotates, not a principal ([Treasury](/docs/3-1-6-treasury)).

Ownership handover and the `transferOwnership` / `renounceOwnership` reverts: [Admin](/docs/3-1-2-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](/docs/glossary#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](/docs/3-1-4-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](/docs/glossary#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](/docs/3-2-1-flow-guards).

### 2.1. Escalation

| Step | Action |
|---|---|
| 1 | Detect (monitor, keeper, [Bug Bounty](/docs/3-4-overview)) |
| 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`:

```solidity
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 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 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`](/docs/glossary#risk-config) follows the same asymmetry: `setRiskConfigTighten` and `raiseKappa` land now, every other direction is a queued `UPDATE_RISK` ([Admin §5.3](/docs/1-2-3-admin#53-update-risk-config)).

| 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

1. Queue: `PoolFactory.requestReferenceUpgrade`, or `requestUpgrade` on `Admin` / `Flash`
2. Wait the `GOVERNANCE` 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: [Admin](/docs/3-1-2-admin).

---

## 6. Oracle push roles

Signers are a granted set on the oracle and authorize by signature. The [relayer](/docs/glossary#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](/docs/3-1-5-oracle-keeper).

---

## 7. Related

| Page | Content |
|---|---|
| [Deployer](/docs/3-1-1-deployer) | Deploy order, CREATE3 addresses, handover |
| [Admin](/docs/3-1-2-admin) | Beacon + UUPS procedure, timelock mechanics |
| [Risk Steward](/docs/3-1-3-risk-steward) | The bounded risk lane |
| [Guardian](/docs/3-1-4-guardian) | Halt / tighten / cancel surface |
| [Treasury](/docs/3-1-6-treasury) | Fee sink and its rotation |
| [Flow Guards](/docs/3-2-1-flow-guards) | Automated halt trigger |
| [Admin](/docs/1-2-3-admin) | Per-op Admin API |
| [Contract Addresses](/docs/2-1-contract-addresses) | Live `AccessControl` address |
