Guardian Operations

For the holder of an address in AccessControl.isGuardian. Who may do what, and why the guardian surface is shaped the way it is, belongs to Access Control, Roles & Emergency Powers §2 and §4. This page is the procedure: the calls, their arguments, the reads that tell you whether a call will work and whether it worked, and the point at which the job is no longer yours.


1. What the key is

Membership is the address itself. AccessControl.setGuardian(g, s) whitelists one address: an EOA before armQuorumPolicy, a Safe-compatible k-of-n after it. The signers of a guardian Safe are not guardians; the Safe address is the entry. guardianQuorumMax defaults to QuorumLib.GUARDIAN_QUORUM_DEFAULT = 1 and is hard-capped at GUARDIAN_QUORUM_CEILING = 2, because above that the lever stops being fast.

Three properties define the whole remit, and every entrypoint below obeys all three:

  1. Direction. Halt, tighten, cancel. Never un-halt, never widen, never grant.
  2. No value. No guardian entrypoint moves a token or changes a recipient.
  3. No reverse. No lever you pull is released by you. Most are released by the owner; UpgradeGate.pause() is the one that leaves a state to undo. ExternalOracleV4.updateFeed is released only by the owner’s timelocked requestFeedWidenexecuteFeedWiden, which you may veto but not call (§2).

One entrypoint, setFeedExpBias, sits outside properties 1 and 2 as a break-glass price write; it carries its own warning in §2.

The consequence is that a wrong guardian call costs downtime and a late one costs the pool. Size your hesitation accordingly.


2. The levers

Each call below runs requireGuardianOrOwner(AC) (or, on UpgradeGate, isGuardianOrAuth(msg.sender, authority)). The AC a target reads is its own immutable: a guardian on one deployment is nobody on another.

ContractCallEffectReleased by
AdminhaltAsset(address pool, address token, uint16 src)Sets halt bits on one legunhaltAsset, owner
AdminbatchRiskOp(address[] pools, address[] tokens, BatchOp op, uint16 src)Same, across pairs, one txbatchRiskOp(UNHALT), owner
AdmincollapseAnchor(address pool, address token, address newAnchor)Re-anchors one leg onto a strict ancestor and halts it. Unreachable on a depth-1 pool — see belowunhaltAsset + UPDATE_ANCHOR queue, owner
AdmincancelTimelock(address pool, uint8 opType, bytes32 subject)Vetoes any queued pool governance opOwner re-requests
Admin / Flashpause()Freezes executeUpgrade on that singletonunpause, owner
Admin / FlashcancelUpgrade()Discards the pending UUPS requestOwner re-requests
PoolFactorycancelReferenceUpgrade()Discards the pending beacon requestOwner re-requests
ExternalOracleV4pauseFeed(bytes32 feedId)Fail-closes one feed. Next release: also clears the lane and anchors the frozen mark, so the feed reads DEAD through the pause and across the release (§4)unpauseFeed, owner
ExternalOracleV4updateFeed(bytes32 feedId, uint16 maxDeviationBps, uint16 ttlSecs)Tightens the per-push band and the TTL. Tighten-or-equal on both; nothing widens either backNothing. Owner cannot loosen it either
ExternalOracleV4revokeSession()Kills the live push session; the granted relay can land nothing moreopenSession, k-of-n quorum
ExternalOracleV4revokeSigner(address signer)Removes one attester key immediatelyrequestSignerGrantBASE delay, owner
ExternalOracleV4setFeedExpBias(bytes32 feedId, int8 newBias)Break-glass, and a price write. See the warning belowAnother setFeedExpBias; no timelock either way
ExternalOracleV4cancelSignerGrant()Vetoes the pending signer additionOwner re-requests
ExternalOracleV4cancelSignerThresholdDecrease()Vetoes the pending quorum decreaseOwner re-requests
ExternalOracleV4cancelFeedWiden(bytes32 feedId)Vetoes a pending band/ttl widen or wedge release, live or already expiredOwner re-requests
AccessControlcancelRole(Role role)Vetoes a queued FACTORY / TREASURY rotationOwner re-queues

updateFeed is a ratchet, and its only inverse is an owner ceremony you can veto but not call. The owner’s updateFeed is the same tighten-or-equal function as yours. Shipping in the next release, a band or TTL tightened too far — or a feed wedged past 10·maxDeviationBps — is released by the owner’s requestFeedWiden(feedId, maxDeviationBps, ttlSecs)BASE delay → executeFeedWiden(feedId). narrowMaxDeviation still does not exist on V4 in any release.

Three things follow for this key. Your tighten always wins. The request snapshots the live band and ttl and the execute reverts InvalidState unless both still match, so an updateFeed you land during the delay voids the pending widen rather than being silently undone by it. You hold the veto. cancelFeedWiden(feedId) is guardian-or-owner and works on a live request and on an expired one alike; the owner may re-request immediately, so a cancel buys review time, not an outcome. You cannot release. A wedged feed handed back to quoting is an owner action, and until the release ships the fallback is a fresh oracle plus a BASE-tier UPDATE_ORACLE repoint per leg — 37 legs on Arc. Treat your own tighten as irreversible from this key, because it is.

setFeedExpBias sits outside the three properties above, and it is the one lever on this page that writes a price. Decode is mark = mant << (exp + bias), so a bias change moves the published mark by a power of two. It is _onlyGuardianOrAdmin, untimelocked, and kept as break-glass because the guardian already holds pause and halt. Blast radius, exactly: the target lane’s price, σ and confidence are zeroed (the feed reads STALE until its next push), the whole slot’s clock is stamped to now, so the other seven lanes sharing that slot lose one push cycle, and the new bias takes effect for every subsequent decode of that lane. The routine path is the quorum-signed setFeedExpBiasSigned, which carries the same k-of-n as a push (Oracles §11.3). Whether the guardian arm should exist at all is an open design question: it is a price lever on a key whose stated remit is halt, tighten and cancel. Do not reach for it outside an incident where the alternative is a dark feed, and escalate to the owner the moment you do.

collapseAnchor needs a tree at least two deep, so it cannot fire on the live fleet. The walk starts at the leg’s current anchor and steps to that node’s anchor before its first comparison, so newAnchor must be a strict ancestor of the current anchor — a grandparent or higher. On the Arc pools every spoke anchors directly to the base and the base anchors to nothing, so the first step lands on address(0) and the call reverts InvalidAnchor for every leg, base included. It is a topology repair for a depth ≥ 2 pool, not an incident lever. During an incident on this fleet the leg-level lever is haltAsset / batchRiskOp; re-rooting a leg is the owner’s UPDATE_ANCHOR op at the CRITICAL tier.

cancelRole(TREASURY_OWNER) is the one cancel the guardian cannot reach: its authority set is owner or the incumbent treasuryOwner, deliberately, so that routing the treasury veto through an owner-appointed guardian cannot become an indirect seizure path.

Every oracle row above is _onlyGuardianOrAdmin, except revokeSession, which any one signer may also call. Which instance a deployment’s pools actually read is Oracles §11.9; pausing the idle one stops nothing. On Arc the live pair is the V4 primary 0x842c2736… and the V4 reference 0xC17920b2…; V3 is the rollback and holds no live leg.


3. Arguments that decide whether the call does what you meant

src on haltAsset / batchRiskOp. One or more bits of PoolConstantsLib.HALT_MASK: HALT_RISK_BIT = bit 0 (0x0001), HALT_GUARDIAN_BIT = bit 6 (0x0040). Sources refcount: unhaltAsset(pool, token, src) clears only the bits passed, so a leg stays halted while any other source still holds it. Halt under your own bit. Halting under HALT_RISK_BIT is permitted (authority is by edge, not by source), but it makes your action indistinguishable from an owner risk halt, and the owner clearing theirs will then relist a leg you meant to hold.

op on batchRiskOp. BatchOp.HALT = 0, BatchOp.UNHALT = 1. A guardian passing 1 reverts NotAuth before any leg is touched.

Per-leg outcomes on batchRiskOp. The loop is try/catch per leg, so one bad pair never bricks the sweep, and a failed leg is not halted. The receipt distinguishes them: BatchRiskOp per applied leg, BatchLegSkipped per skipped one. Count the events against the arrays you sent.

subject on cancelTimelock. The third key component. For token-keyed ops it is the asset address left-padded (bytes32(uint256(uint160(token)))), and for UPDATE_CURVE it is the preset id. MIGRATE_BASE_TOKEN, UPDATE_TREASURY and UPDATE_FEES are pool-level and ignore it. Cancel shares _keyOf with requestOp, so every key a request can create is reachable and a wrong subject reverts NoPending rather than cancelling something else. An unknown or NONE opType reverts InvalidInput.

Both arguments on updateFeed. maxDeviationBps and ttlSecs are written together, and each must be non-zero and less than or equal to the live value; anything else reverts InvalidInput. There is no way to pass one and leave the other alone, so read both off getFeed(feedId) first and pass the current TTL back verbatim unless you mean to shorten it. Passing the live values unchanged is accepted and is a loud no-op that still emits FeedUpdated. A shortened TTL is as irreversible as a narrowed band, and it fail-closes the leg sooner: at ttlSecs the feed reverts StaleData and the leg stops trading.

revokeSigner. Silently a no-op if the address is not a granted signer. Revoking below signerThreshold is deliberately unblocked: it halts pushing, which is the fail-safe response to a suspected key compromise, not an error.


4. What you are the last line of defence against

The automated breakers cover the cases that can be stated as a predicate: staleness TTL, confidence ceiling, base parity, the per-asset reference band (Depeg Halt, Oracle Price-Push Security §5). The guardian exists for what they do not cover:

  • A mark that is authentic, in band, fresh, and wrong.
  • A queued governance op that should not land.
  • A signer key believed leaked.
  • An upgrade that should not execute at its ETA.
SituationLeverWhat it does not fix
One feed suspect, rest of the catalog healthypauseFeed(feedId)The mark is not corrected, only withheld. Every consumer of that feed fail-closes, through the pause and until a push lands after it
Attester key believed leakedrevokeSigner(addr), repeat per keyPushing stops for every feed on that oracle once the set drops below signerThreshold
Band too wide for the observed tapeupdateFeed (band down, TTL passed back unchanged)Nothing already written; it bounds the next push only, and it cannot be widened back
Queued loosening you do not trustThe matching cancel*The owner may re-request immediately; a cancel buys review time, not a veto on the outcome
A queued band widen you do not trustcancelFeedWiden(feedId), or an updateFeed tighten, which voids it by compare-and-swapNeither reaches a widen already executed. A released feed reads DEAD until its next push, and that push is unbanded (§2)
Matured upgrade you want frozen, not killedpause() on Admin / FlashOnly executeUpgrade is blocked. The request survives and remains executable after unpause
One leg’s anchor chain suspect, on a pool at least two deepcollapseAnchor(pool, token, newAnchor)newAnchor must be a strict ancestor of the leg’s current anchor (PoolConfig.collapseAnchor walks up to AnchorTreeLib.MAX_DEPTH, else InvalidAnchor). On a depth-1 pool no such node exists and the call always reverts (§2). The write halts the leg in the same transaction
An asset must stop trading nowhaltAsset / batchRiskOpHalting a leg does not stop the same asset elsewhere; enumerate (§4.2)

Two scope notes are worth internalising before an incident:

  • Admin.haltAsset calls IPool(pool).adminHaltAsset, which is gated on the Admin singleton, not on the pool’s creator: pool deployment is permissionless, but administration resolves to the same singletons, so the lever reaches any pool the fleet’s Admin governs, official or not.
  • A paused feed is skipped, not reverted: reverting on one paused feed would starve every other feed in the same blob. On V4 the same fail-soft covers an unregistered lane, a sentinel write, a band breach and an over-cap σ; a skipped lane is reported in LanesSkipped(slotId, laneMask). Only framing, quorum, session and acceptance-window failures revert the whole push.
  • The pause is fail-closed on release too, shipping in the next release. pauseFeed no longer merely sets the bit and leaves the mark standing: it clears the lane’s price and confidence and anchors the mark it froze together with the observation second the slot then held. So the feed reads DEAD for the whole pause window and stays dead after unpauseFeed until a push lands — a consumer reading getFeed without gate sees the same thing — and that first push back is banded over the real gap since the frozen mark was observed, not over one cadence. Before this, the slot clock kept advancing through a pause (a paused lane’s entries still count as accepted, and its slot-mates keep pushing), so an unpaused feed reported its stale mark at age ~0 with a zero staleness premium, and the correcting push was banded so tightly that the release itself could wedge the feed. Two operational consequences: a pause is not a way to hold a mark, and the leg stays dark for one push cycle after the unpause. The slot clock is deliberately not stamped, so the pause costs the other seven lanes nothing (Oracles §8.1).

A guardian key that can pause feeds is also what the feed-liveness keeper holds; its automated pauseFeed path and the budget that bounds it are Oracle Keeper Operations §6.


5. Before you act

  • Chain. Confirm the wallet’s chain before signing. Pool salts include block.chainid, so the same nominal address is a different pool elsewhere.
  • Role. AccessControl.isGuardian(you) on the AC the target reads: Admin.AC(), ExternalOracle.AC(), PoolFactory likewise. quorumStatus() reports whether the policy is armed and whether the live multisig configuration still satisfies it.
  • That something is actually pending, before any cancel. pendingSigner / pendingSignerGrantOp, pendingSignerThresholdOp, pendingFeedWiden(feedId) (0 = none), AccessControl.pendingRole(role), UpgradeGate.pendingUpgrade. Admin.pendingOps is internal: there is no on-chain read for it, so the queue’s ETA comes from the TimelockRequested event or the indexer. A cancel with nothing pending reverts NoPending and costs only gas.
  • The legs, before a halt. Discovery is PoolFactory.getOfficialPoolsForToken(token)getPoolTokens(pool) (§4.2). batchRiskOp takes the two arrays positionally; a length mismatch reverts InvalidInput.
  • Both current values, before a tighten. getFeed(feedId).maxDeviationBps and .ttlSecs: updateFeed reverts on anything above either, and a revert here is easy to misread as a role problem. Check pendingFeedWiden(feedId) in the same read: a tighten on a feed with a request queued voids that request, which may be the point or may be an accident.

6. After you act

Read state, not the transaction. Halt and pause events are snapshot triggers rather than indexed topics, so the indexer will not answer “what is halted right now” (Observability §12).

You calledVerifyEvent
haltAsset / batchRiskOpPool.getAsset(token).flags carries your bitAssetHalted / BatchRiskOp, BatchLegSkipped
collapseAnchorgetAsset(token).anchor is the new anchor and the guardian bit is setAnchorUpdated
pauseFeedgetFeed(feedId) flags bit 0 set; isFeedFresh(feedId) false. Next release: the mark also reads 0 (DEAD), which is the lever working, not a failed writeFeedPaused
updateFeedgetFeed(feedId).maxDeviationBps and .ttlSecsFeedUpdated
revokeSessionsession() returns a zero relaySessionRevoked
setFeedExpBiasgetFeed(feedId) reads stale; expHeadroom(feedId) is (0, 0) until the next pushFeedExpBiasUpdated
revokeSignersigners(addr) false; signerCount vs signerThresholdSignerRevoked
cancelTimelock / cancel*The pending read from §5 is now zeroTimelockCancelled, SignerGrantCancelled, SignerThresholdDecreaseCancelled, FeedWidenCancelled, UpgradeCancelled, RoleCancelled
pause()paused() true on that singletonPaused(true)

Then check the blast radius you did not intend. After a revokeSigner sweep, signerCount below signerThreshold means no feed on that oracle can be pushed and every one of them will age into its TTL; that is a correct fail-safe, and it is also a deadline. After a halt, the fraction of the book still tradable is feeds.tradableRatio and bot_tradable_ratio (Observability §6, §7).


7. Escalate

You cannot reverse anything you just did, so the owner is not an optional follow-up; it is the second half of the action. Hand over as soon as the lever has landed:

  • What you halted or paused, with the src bits and feed ids, so the release clears exactly those.
  • Why, with the read that triggered it.
  • What is now blocked that the incident did not require blocking.

Intake and contact are Access Control §4.1 and security@btr.markets. A suspected vulnerability rather than an operational fault goes through Bug Bounty.

Escalate rather than act when the remedy is a widen, an un-halt, a grant, a param write or a value movement. None of those are reachable from this key. A feed wedged outside its band is the clearest case: the remedy is the owner’s requestFeedWidenBASE delay → executeFeedWiden (shipping in the next release; until then, an oracle redeploy plus a per-leg BASE-tier repoint). Hand it over with the feed ids grouped by slot, because the execute stamps one clock per slot and every wedged lane of a stalled slot has to be released in the same batch — a lane left out is fail-open on its own leg. pauseFeed is the instant lever for any lane that batch will not cover, and it is yours (Oracles §8.3). Attempting any of the rest from here burns gas and time during an incident.


8. Checklist

On grant, before you are needed

  • isGuardian(you) is true on every AC you are expected to cover, and you know which Admin / PoolFactory / ExternalOracle instances read each one.
  • The signing address is the whitelisted address (post-arm: the Safe itself, not its signers), and its threshold is within guardianQuorumMax.
  • quorumStatus() reads armed with ownerOk and treasuryOwnerOk true; if not, raise it now rather than during an incident (§1.1).
  • You can produce a feed id and an asset address for every leg you cover, from a source that is not the front-end.
  • You have signed a no-op transaction from this key on this chain at least once.

Before signing

  • Chain id matches the target deployment.
  • The lever is in §2: halt, tighten or cancel. If it is not, it is an owner action.
  • Arguments checked against §3: src bits, BatchOp.HALT, subject padding, and for updateFeed both a band and a TTL at or below the live ones.
  • For a cancel: the pending read from §5 is non-zero.
  • For a halt: the leg list is enumerated, and the two arrays are the same length.

After the transaction lands

  • The §6 state read confirms the change, not just a successful receipt.
  • BatchLegSkipped count is zero, or every skipped leg is accounted for and retried.
  • signerCount versus signerThreshold checked if you revoked.
  • feeds.tradableRatio reviewed for collateral damage.
  • Owner notified with the exact arguments used, because only the owner can undo them.

PageContent
Access Control, Roles & Emergency PowersAuthority matrix, halt sources, timelock schedule
Oracle Keeper OperationsThe push side of the same oracle, and the automated guardian
Risk Steward OperationsThe other bounded key
Depeg HaltThe automated breakers that run without you
ObservabilityThe reads named above, and where they are served
Deployment & UpgradesUpgrade procedure a pause() interrupts