Guardian
For the holder of an address in AccessControl.isGuardian. Who may do what, and why the surface is
shaped this way, belongs to Access control. This page is the authority and the
semantics: the calls, their arguments, and the failures they exist to cover. The procedure around
them is Guardian routines.
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, above
which the lever stops being fast.
Three properties define the 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; its
warning is in §2.
A wrong guardian call costs downtime; a late one costs the pool. Size your hesitation accordingly.
2. The levers
Each call 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. Since the 2026-09-03 source (not on the Arc V4 pair) it 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. Not a durable relay cutoff on V4: an open session IS in-band mark authorship (pushV4 checks only msg.sender == relay, expiry and seq <= maxSeq, no signature), and the honest keeper re-grants within ~30 s. Permanent only after revokeSigner drops signerCount < signerThreshold, which makes every future openSession unsatisfiable | openSession, k-of-n quorum |
ExternalOracleV4 | revokeSigner(address signer) | Removes one attester key immediately | requestSignerGrant → LISTING 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. A band or TTL tightened too
far, or a feed wedged past 10·maxDeviationBps, is released by the owner’s
requestFeedWiden(feedId, maxDeviationBps, ttlSecs) → LISTING delay → executeFeedWiden(feedId).
narrowMaxDeviation does not exist on V4 in any release. Three consequences for this key:
- Your tighten always wins. The request snapshots the live band and ttl and the execute reverts
InvalidStateunless both still match, so anupdateFeedyou 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 an expired one alike. The owner may re-request immediately, so a cancel buys review time, not an outcome. - You cannot release. Handing a wedged feed back to quoting is an owner action, and on the Arc
V4 pair, which predates the widen, the fallback is the V5 implementation upgrade through
OracleBeacon(LISTINGtier, 1 day); there is no per-leg repoint. Treat your own tighten as irreversible from this key.
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 (Oracle keeper). Whether the guardian arm should exist at all is an open design question: it is a price lever on a key whose 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 GOVERNANCE tier.
cancelRole(GUARDIAN_REVOKE) is the one cancel the guardian cannot reach: owner-only by design, so
a guardian facing removal cannot veto its own revocation.
Every oracle row above is _onlyGuardianOrAdmin, except revokeSession, which any one signer may
also call. Which instance a deployment’s pools actually read is
Oracle keeper; 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 unblocked by design: it halts pushing, 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 what can be stated as a predicate: staleness TTL, the
MAX_CONFIDENCE_HALT_BPS = 1000 ceiling, base parity, the per-asset reference band
(Flow guards, Oracle keeper).
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 |
| Relay or session key believed leaked | revokeSession(), then revokeSigner(addr) to drop the set below signerThreshold; owner rotates the NXR roster and the keeper Secret | revokeSession alone: the keeper re-grants the same address within ~30 s, so while signerCount >= signerThreshold the leaked relay authors marks bounded only by the per-lane deviation band |
| 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 (Access control) |
Three scope facts to internalise 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.
pauseFeeddoes not merely set the bit and leave 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. 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.- On the Arc V4 pair, deployed 2026-09-01 and not upgradeable, the slot clock still advances through a pause (a paused lane’s entries still count as accepted, and its slot-mates keep pushing), so an unpaused feed reports its stale mark at age ~0 with a zero staleness premium, and the correcting push is banded so tightly that the release itself can 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 not stamped by design, so the pause costs the other seven lanes nothing (Oracle keeper).
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.
5. Related
| Page | Content |
|---|---|
| Guardian routines | Before, after, escalation and the checklist for every lever above |
| Access control | Authority matrix, halt sources, timelock schedule |
| Oracle keeper | The push side of the same oracle, and the automated guardian |
| Risk steward | The other bounded key |
| Flow guards | The automated breakers that run without you |
| Observability | The reads named above, and where they are served |
| Admin | Upgrade procedure a pause() interrupts |