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:
- Direction. Halt, tighten, cancel. Never un-halt, never widen, never grant.
- No value. No guardian entrypoint moves a token or changes a recipient.
- 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.updateFeedis released only by the owner’s timelockedrequestFeedWiden→executeFeedWiden, 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.
| Contract | Call | Effect | Released by |
|---|---|---|---|
Admin | haltAsset(address pool, address token, uint16 src) | Sets halt bits on one leg | unhaltAsset, owner |
Admin | batchRiskOp(address[] pools, address[] tokens, BatchOp op, uint16 src) | Same, across pairs, one tx | batchRiskOp(UNHALT), owner |
Admin | collapseAnchor(address pool, address token, address newAnchor) | Re-anchors one leg onto a strict ancestor and halts it. Unreachable on a depth-1 pool — see below | unhaltAsset + UPDATE_ANCHOR queue, owner |
Admin | cancelTimelock(address pool, uint8 opType, bytes32 subject) | Vetoes any queued pool governance op | Owner re-requests |
Admin / Flash | pause() | Freezes executeUpgrade on that singleton | unpause, owner |
Admin / Flash | cancelUpgrade() | Discards the pending UUPS request | Owner re-requests |
PoolFactory | cancelReferenceUpgrade() | Discards the pending beacon request | Owner re-requests |
ExternalOracleV4 | pauseFeed(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 |
ExternalOracleV4 | updateFeed(bytes32 feedId, uint16 maxDeviationBps, uint16 ttlSecs) | Tightens the per-push band and the TTL. Tighten-or-equal on both; nothing widens either back | Nothing. Owner cannot loosen it either |
ExternalOracleV4 | revokeSession() | Kills the live push session; the granted relay can land nothing more | openSession, k-of-n quorum |
ExternalOracleV4 | revokeSigner(address signer) | Removes one attester key immediately | requestSignerGrant → BASE delay, owner |
ExternalOracleV4 | setFeedExpBias(bytes32 feedId, int8 newBias) | Break-glass, and a price write. See the warning below | Another setFeedExpBias; no timelock either way |
ExternalOracleV4 | cancelSignerGrant() | Vetoes the pending signer addition | Owner re-requests |
ExternalOracleV4 | cancelSignerThresholdDecrease() | Vetoes the pending quorum decrease | Owner re-requests |
ExternalOracleV4 | cancelFeedWiden(bytes32 feedId) | Vetoes a pending band/ttl widen or wedge release, live or already expired | Owner re-requests |
AccessControl | cancelRole(Role role) | Vetoes a queued FACTORY / TREASURY rotation | Owner 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.
setFeedExpBiassits outside the three properties above, and it is the one lever on this page that writes a price. Decode ismark = 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-signedsetFeedExpBiasSigned, 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.
| Situation | Lever | What it does not fix |
|---|---|---|
| One feed suspect, rest of the catalog healthy | pauseFeed(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 leaked | revokeSigner(addr), repeat per key | Pushing stops for every feed on that oracle once the set drops below signerThreshold |
| Band too wide for the observed tape | updateFeed (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 trust | The 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 trust | cancelFeedWiden(feedId), or an updateFeed tighten, which voids it by compare-and-swap | Neither 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 killed | pause() on Admin / Flash | Only executeUpgrade is blocked. The request survives and remains executable after unpause |
| One leg’s anchor chain suspect, on a pool at least two deep | collapseAnchor(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 now | haltAsset / batchRiskOp | Halting a leg does not stop the same asset elsewhere; enumerate (§4.2) |
Two scope notes are worth internalising before an incident:
Admin.haltAssetcallsIPool(pool).adminHaltAsset, which is gated on theAdminsingleton, 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’sAdmingoverns, 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.
pauseFeedno 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 afterunpauseFeeduntil a push lands — a consumer readinggetFeedwithoutgatesees 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 theACthe target reads:Admin.AC(),ExternalOracle.AC(),PoolFactorylikewise.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.pendingOpsis internal: there is no on-chain read for it, so the queue’s ETA comes from theTimelockRequestedevent or the indexer. A cancel with nothing pending revertsNoPendingand costs only gas. - The legs, before a halt. Discovery is
PoolFactory.getOfficialPoolsForToken(token)→getPoolTokens(pool)(§4.2).batchRiskOptakes the two arrays positionally; a length mismatch revertsInvalidInput. - Both current values, before a tighten.
getFeed(feedId).maxDeviationBpsand.ttlSecs:updateFeedreverts on anything above either, and a revert here is easy to misread as a role problem. CheckpendingFeedWiden(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 called | Verify | Event |
|---|---|---|
haltAsset / batchRiskOp | Pool.getAsset(token).flags carries your bit | AssetHalted / BatchRiskOp, BatchLegSkipped |
collapseAnchor | getAsset(token).anchor is the new anchor and the guardian bit is set | AnchorUpdated |
pauseFeed | getFeed(feedId) flags bit 0 set; isFeedFresh(feedId) false. Next release: the mark also reads 0 (DEAD), which is the lever working, not a failed write | FeedPaused |
updateFeed | getFeed(feedId).maxDeviationBps and .ttlSecs | FeedUpdated |
revokeSession | session() returns a zero relay | SessionRevoked |
setFeedExpBias | getFeed(feedId) reads stale; expHeadroom(feedId) is (0, 0) until the next push | FeedExpBiasUpdated |
revokeSigner | signers(addr) false; signerCount vs signerThreshold | SignerRevoked |
cancelTimelock / cancel* | The pending read from §5 is now zero | TimelockCancelled, SignerGrantCancelled, SignerThresholdDecreaseCancelled, FeedWidenCancelled, UpgradeCancelled, RoleCancelled |
pause() | paused() true on that singleton | Paused(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
srcbits 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 requestFeedWiden → BASE 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 everyACyou are expected to cover, and you know whichAdmin/PoolFactory/ExternalOracleinstances 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 withownerOkandtreasuryOwnerOktrue; 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:
srcbits,BatchOp.HALT,subjectpadding, and forupdateFeedboth 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.
BatchLegSkippedcount is zero, or every skipped leg is accounted for and retried.signerCountversussignerThresholdchecked if you revoked.feeds.tradableRatioreviewed for collateral damage.- Owner notified with the exact arguments used, because only the owner can undo them.
9. Related
| Page | Content |
|---|---|
| Access Control, Roles & Emergency Powers | Authority matrix, halt sources, timelock schedule |
| Oracle Keeper Operations | The push side of the same oracle, and the automated guardian |
| Risk Steward Operations | The other bounded key |
| Depeg Halt | The automated breakers that run without you |
| Observability | The reads named above, and where they are served |
| Deployment & Upgrades | Upgrade procedure a pause() interrupts |