Oracle Price-Push Security
How BTR gets a trustworthy mark and why a compromised price source cannot drain a pool. Mechanical per-guard rules and the feed struct are in Oracles; this page owns the trust chain around them.
1. Threat: oracle-manipulation classes
EXTERNAL (recommended) quotes any IOracle mark (on-chain adapter or push). INTERNAL is the cash-collateral peg helper only. See Oracles §1. This ceremony is the NX Rates off-chain push, one EXTERNAL option, not a protocol-wide prerequisite. A pool using EXTERNAL with a fully on-chain adapter carries none of the trust below. What follows is what a pool opts into for NX Rates’ aggregated mark. The failure mode to defend is the one that has repeatedly drained oracle-priced venues:
- Single-key compromise. One stolen producer key pushes an arbitrary mark, the pool prices off it, an attacker swaps the drained side.
- Gradual walk. No single push looks unreasonable, but a compromised producer walks the mark many small steps until collateral is gone. A per-push sanity band alone does not stop this: the sum is unbounded.
- Future-dated report. A producer stamps a far-future timestamp that clears the replay guard once, then permanently freezes the feed at a stale price to be picked off.
- Volatility understatement. A producer signs to collapse the pricing spread, then round-trips against a spread-free mark.
- Source manipulation. No key is stolen and no producer misbehaves: an attacker moves a majority of the venues the composite is built from, and the quorum faithfully signs an aggregate of manipulated inputs. Nothing downstream can distinguish it from a real move: the guards below bound how fast that mark walks the pool, never whether it is right. The defenses are off-chain and economic; see Security Overview §9.2.
The design goal: track a real crash faithfully, never brick, and never let one (or few) compromised keys one-shot or slow-walk the mark.
2. Where prices come from
BTR does not acquire or aggregate prices. It consumes attested marks from high-frequency off-chain providers and re-verifies them on chain. Two supply the roster today, NX Rates and Pyth, and they do the same job: aggregate venue tape off chain at high frequency and attest the result. The oracle keeper relays either through the same signed path, and they are used interchangeably, chosen per feed on coverage and cadence rather than by rank.
This section describes the NX Rates path in detail because it is the one whose quorum BTR governs on chain. NX Rates is a generic multi-exchange market-data provider that owns every venue connection, aggregates a multi-venue mark plus Parkinson volatility and a confidence interval, and co-signs the result. BTR is a client of it: it consumes signed quotes and re-verifies them on-chain.
The quorum that signs a quote lives inside NX Rates. Each signer replica holds its own key on its own node. The replica serving a quote builds the blob from its live aggregated view, self-signs, and proposes it to its peers. A peer countersigns only after re-validating every record against its own independent market view (price tolerance, timestamp skew, and volatility/confidence understatement guards). Below the quorum of signatures, no quote is served at all. So a signed batch reaching the chain means k independent processes, holding k independent keys, each independently agreed this price was correct at this time.
Price acquisition, the aggregation method, the signing scheme, the /v1/quote/signed endpoint, blob field semantics, key management, and plan tier are NX Rates internals, documented and maintained by NX Rates (NXR); BTR docs do not reproduce them - see NXR’s signed-quote specification (provided to integrators on request).
That provenance pointer is one-way: BTR docs point to NX Rates; NX Rates does not depend on BTR. What follows is on-chain BTR only: verification, the quorum registry, and the ceremony that governs it.
3. The on-chain quorum is the consensus
There is no separate consensus protocol between BTR and NX Rates. The consensus is the on-chain signature check. ExternalOracle holds the authoritative registry:
| State | Role |
|---|---|
mapping(address => bool) signers | The NX Rates attester whitelist. Only granted addresses count toward a quorum. |
uint8 signerThreshold | : the number of distinct granted signatures a batch must carry (2 in current deployments). |
uint8 signerCount | : live granted-signer count (executeSignerGrant / revokeSigner bookkeeping). |
batchPushSigned(bytes blob, bytes sigs) is the sole push path. Authorization:
sigsis concatenated 65-byte recoverable ECDSA signatures over one EIP-712 digest (the digest of ), with .Each signature is recovered against the same digest. Every recovered address must be a granted signer and strictly greater than the previous recovered address:
address rec = ECDSA.recoverCalldata(digest, sigs[off:off + 65]); if (rec <= prev || !signers[rec]) revert Err.NotAuth(); prev = rec;ExternalOracle.sol. Strict increase is the k-of-n deduplication check, not a convenience: a repeated signature failsrec <= prevon its second appearance, and an unsorted set fails at the first inversion. Both are caught by the same comparison, with no set structure and no second pass. So accepted signatures prove distinct granted keys.The stride is a fixed 65 bytes with no EIP-2098 compact form (
ExternalOracle.sol). The signature count is the quorum claim, so its encoding must be unambiguous.All signatures are verified before any state write (checks-effects).
The relayer (msg.sender, the keeper that lands the transaction) is unpermissioned on the signed path. Authority is in the signatures, not the sender. This decouples price authority (the NX Rates key set) from push liveness (any relayer can land the freshest signed blob), and it removes the single-signer failure mode: one stolen key can push nothing on its own.
V4 keeps that path as pushSignedV4(blob, sigs) and adds a second, cheaper one: pushV4(blob) pins msg.sender to the single relay named in a quorum-signed SessionGrant, valid for at most one hour and revocable by any one signer. The session path is where the routine cadence runs; the signed path is the permissionless fallback, so liveness never depends on the granted relay (Oracles §10.3).
Replay is defended by the per-feed monotonic sourceTs, never by signature uniqueness (a malleable still recovers the same signer; no on-chain state is keyed on signature bytes). See Oracles §4.2.
3.1. What the digest binds
The digest is _hashTypedData(keccak256(BATCH_TYPEHASH || keccak256(blob))). Solady’s EIP-712 domain separator commits to a name, a version, chainId and verifyingContract = address(this). The name and typehash are per generation: V1 signs BatchQuote(bytes32 blobHash) under BTR ExternalOracle / 1; the live V4 signs BatchQuoteV4(bytes32 blobHash) under BTR ExternalOracleV4 / 1 (ExternalOracleV4.sol). Anything re-recovering a signature off-chain must use the domain of the instance that accepted the push (§7).
Both domain fields carry real weight for this threat model:
| Field | What it prevents |
|---|---|
chainId | Cross-chain and post-fork replay. A batch signed for one chain’s oracle is not valid on another deployment of the same contract, and solady recomputes the separator when chainId changes rather than serving a cached one. This becomes load-bearing the moment the same contract is live on more than one chain. |
verifyingContract | Cross-instance replay, which is the one that matters here. A blob signed for the primary oracle cannot verify on the reference oracle, and vice versa, even when the two instances share a signer set. Without this binding the independent-reference cumulative bound (§5.1) would be defeated by pure message reuse rather than by key compromise: the same signatures would move both feeds in lockstep and the ref band would never open. |
The signature covers , the exact packed calldata, so no record can be substituted, reordered or truncated after signing. V1’s wire is an 8-byte header plus 22-byte ticker-keyed records resolved through the append-only feedIdOf[tickerId] map. Wire v5, the live one, is an 11-byte header plus positional entries — 5 B price, 5 B σ, 3 B confidence — each keyed by a gi:u8 global index rather than a ticker, with sections in strictly-ascending gi (Oracles §11.5). An entry naming an unregistered lane is skipped fail-soft, not reverted; a descending or repeated gi fails the whole blob with BadBlobHeader.
4. The whitelisting ceremony
Governance of the signer set is deliberately asymmetric: tighten now, loosen later. Anything that could loosen price authority (adding a signer, lowering the quorum) is timelocked and vetoable. Anything that hardens or halts (revoking a signer, raising the quorum, pausing a feed) is immediate. A rogue owner cannot fast-add a malicious signer; a guardian can instantly retire a leaked one.
4.1. Genesis: no 1-of-1 bootstrap
The constructor installs the quorum atomically. It requires 3 to 16 distinct non-zero signers with signerThreshold between 2 and n (MAX_SIGNERS = 16). A deployment can never pass through a single-signer or single-key state: there is no post-deploy “add the second signer” window during which one key is authoritative.
// V4 (live). NxrSignerSet carries the genesis bounds for every generation.
constructor(address ac_, address[] initialSigners_, uint8 signerThreshold_, bool express_)
// reverts unless: 3 <= initialSigners_.length <= 16, all distinct + nonzero, 2 <= signerThreshold_ <= lengthThere is no maxRelayLagSecs on V4. The absolute past bound is the contract constant MAX_RECON_AGE = 6 h, shared by every feed and applied on both the push and the read side; express_ is false on every deploy path (Oracles §11.8).
ac_ is an existing AccessControl address, so which governance root an oracle answers to is a deploy-script decision. The primary oracle on a chain shares that chain’s AccessControl with the pool fleet; a reference oracle should not (§4.6).
4.2. Adding a signer (timelocked, loosening)
Adding price authority is a loosening, so it cannot take effect in the same transaction or block as the request:
- Off-chain: generate a standalone key on its own node (independent failure domain, not derived from an existing signer).
requestSignerGrant(signer)(owner-only) queues one pending grant. Only one grant may be pending at a time, so a compromised owner cannot queue-farm many candidates that guardians must veto one by one.- Wait out the
BASEtier delay. executeSignerGrant()(owner-only) inside the grace window. Execution revalidates membership and the hard cap, because emergency revocations may have changed the live set while the request waited.- Any time before execution, guardian or owner may
cancelSignerGrant()(veto, including an expired request).
4.3. Removing a signer (immediate, hardening)
revokeSigner(signer) is callable by guardian or owner and takes effect immediately: the fast removal of a leaked NX Rates key. Revoking below signerThreshold is deliberately allowed. Dropping under halts pushing (no quorum can form), which is the fail-safe response to a suspected compromise: feeds go stale and the pool fail-closes. A halt is never a loosening, so it needs no timelock.
4.4. Changing the quorum
| Direction | Path | Timing |
|---|---|---|
Raise (setSignerThreshold, current, signerCount) | Owner-only | Immediate (hardening) |
Lower (requestSignerThresholdDecrease → executeSignerThresholdDecrease, floored at 2) | Owner-only, one pending at a time | Timelocked, guardian/owner cancellable |
A decrease can never go below 2. Execution revalidates that the target is still a strict decrease and reachable by the live set.
4.5. Ceremony parameters
| Constant | Value | Meaning |
|---|---|---|
MAX_SIGNERS | 16 | Hard cap on . |
| min signers / min at genesis | 3 / 2 | No 1-of-1 or 2-of-2 bootstrap. |
| Signer-governance delay | The BASE tier of AccessControl.GOV_DELAYS(), read into ExternalOracle.DELAY_BASE at construction (schedule) | Delay on grant and quorum decrease. |
SIGNER_GOV_GRACE | 7 days | Execution window after maturity; then the request must be cancelled before re-queuing. |
4.6. Independent reference: the deploy disjointness preflight
The cumulative bound (§5) only works if the reference oracle is operationally independent of the primary. Address inequality is the on-chain floor (PoolConfig.validateOracleConfig requires refPrimary != primary).
Signer disjointness is not implemented. The only shipped preflight is a deploy-time check that the reference address differs from the primary and holds code. Nothing checks that the two instances have disjoint keys, admins or owners.
Required configuration. The reference oracle takes a signer set disjoint from the primary’s, and its own AccessControl, which is the one place a second governance root on a chain is correct, precisely because the reference exists to police the primary. Sharing either is configurable and advised against: it collapses the two quorums below into one.
Per-deployment status. This is a property of a deployment, not of the protocol, so it must be checked per chain. Arc shares a signer set today for operational simplicity, and the cumulative bound does not hold there. Every chain added later inherits nothing from that: verify each one before relying on the bound, by comparing SignerGranted logs on both oracle addresses. Live addresses per chain: Contract Addresses.
Operating obligation. The reference is not push-and-forget: every armed spoke band fails closed once the reference goes stale, so a keeper instance must relay to the reference oracle on the same /heartbeat discipline as the primary (separate oracle-reference.*.toml manifest, its own address-bound blob - the EIP-712 domain means one oracle’s blob can never verify on the other). A parked reference oracle bricks spoke swaps after TTL.
Consequence, stated plainly. Where a deployment shares a signer set, one compromised k-of-n quorum signs both the mark and the reference, so the refBand walks in lockstep and never trips.
Note what this does and does not concede. The verifyingContract binding (§3.1) already prevents the same signed blob from moving both feeds: the attacker must produce a second, separately signed batch for the reference oracle. Sharing a signer set means they can, at the same key-compromise cost. Disjointness is what makes the second batch require a second, independent compromise. Address inequality buys nothing here; key and admin separation is the whole property.
PoolConfig.validateOracleConfig (PoolConfig.sol) enforces what a contract can: an armed refBandBps requires a non-zero refFeedId, a non-zero refPrimary, refPrimary != primary, and a successful getFeed on both. It cannot inspect signer sets, so operational independence stays a deployment obligation and belongs in the launch checklist, not in the contract.
5. How a push is guarded (defense in depth)
A verified quorum is necessary but not sufficient. Even a legitimately-signed batch, or a batch from a compromised quorum, passes through a fail-closed guard chain before it writes a mark. Each guard maps to a §1 vector. Exact rules and formulas: Oracles §4.2 and §8.3.
| Guard | Bounds which vector | One-line rule |
|---|---|---|
| k-of-n quorum (§3) | Single-key compromise | distinct granted signatures per batch, or the push reverts. |
Monotonic sourceTs | Replay / reorder | The reconstructed source second must strictly advance, per slot on V4 (8 lanes share one clock); the timestamp is the nonce. A slot that fails it is stepped over silently, with no event. |
Future-dated bound (SOURCE_TS_FUTURE_SKEW_SECS = 5) | Future-dated freeze | Reject a sourceTs leading wall-clock by more than 5 s, which would clear the monotonic guard once and then freeze the feed forever. |
Absolute freshness (MAX_RECON_AGE = 6 h, constant) | Withheld stale blob | Reject a blob whose reconstruction is older than the bound, on the read side as well as the push side. V1’s per-instance maxRelayLagSecs does not exist on V4. |
| One write per slot per source-second | Batched walk | A slot accepts at most one write per source second — not per block. Several pushes may land in one block if their source seconds differ. A repeated or descending gi inside a section fails the blob closed. |
| Volatility-adaptive band | Per-push spike | A push may move the mark at most , and normally far less: standard deviations of Brownian motion over the attested source-time gap. Formula and terms: Oracles §8.3. |
| floor | Volatility understatement | Stored is floored at the realized , so any move forces a proportional spread; a signed cannot buy a spread-free round trip. On V4 the floor runs only where the σ word is already loaded — the band slow path, or a slot the blob carries σ entries for. |
Independent reference band (refPrimary + refBandBps) | Gradual walk (cumulative) | Halt swaps once the mark diverges from a separately-keyed reference by the band (Depeg Halt §2.4). |
| Base-token depeg halt | Numeraire depeg | Halt the hub when the base mark leaves parity by the halt band (Depeg Halt §2.2). |
| Guardian fast-freeze | Any suspected incident | Guardian or owner pauseFeed or updateFeed (tighten-or-equal on band and ttl), both immediate (Access Control & Roles §2). Next release: pauseFeed is fail-closed on release as well — it clears the lane and anchors the frozen mark, so the feed reads DEAD through the pause and the re-entry push is banded over the real gap instead of over one cadence (Oracles §8.1). updateFeed’s only inverse is the owner-timelocked widen (§5.1). |
| ceiling | Overflow / absurd vol | Every stored is capped at MAX_SIGMA_PBPS, on both the signed sample and the realized-move floor. |
5.1. Per-push band vs cumulative band
The signatures authorize authenticity, not magnitude. Two independent magnitude bounds run in series.
Per-push (sanity). The volatility-adaptive band lets a push move at most a Brownian step over the attested source-time gap, and never more than whatever says. This bounds a single compromised push to a monitorable step, never a one-tx drain.
maxDeviation names the floor, not the maximum. The band’s real ceiling is exactly , so at the shipped stable floor of bps a single push may move a stable’s mark by up to 500 bps, the entire width of the base parity band (Depeg Halt §2.2). Read the field as “the band’s base term” and size it against the 10× ceiling. This interaction is why the two numbers must be chosen together rather than independently.
A wedged feed self-clears, up to that ceiling. The source-time gap grows while a feed is quiet, so the band widens with it and a feed that fell behind during an outage clears itself once the true move fits. Past it does not: the band rejects the very push that would close the gap, updateFeed is tighten-or-equal for the owner as well as the guardian, and registerFeed refuses an existing id.
Shipping in the next release, that wedge has an in-contract release. requestFeedWiden(feedId, maxDeviationBps, ttlSecs) → BASE delay → executeFeedWiden(feedId), both owner-only, with a guardian-or-owner cancelFeedWiden veto. Two properties matter for this threat model. First, it loosens nothing by default: the execute clears the lane and the rebias band anchor, so the next push is unbanded whatever the gap was, and passing the live band and ttl back verbatim is a valid pure release. Second, a tighten always wins: the request snapshots both fields and the execute reverts InvalidState unless both still match, so a guardian tighten taken during the delay voids the pending widen instead of being undone by it. Until the release lands the fallback stays a fresh oracle deployment plus a BASE-tier UPDATE_ORACLE repoint per affected leg — 37 legs on Arc today. Mechanics and the one-batch-per-slot operator rule: Oracles §8.3.
Cumulative bound. A per-push band alone does not bound the total excursion: a compromised producer can walk the mark in many in-band steps. The independent reference feed closes that. refPrimary points the depeg band at a separately-keyed oracle instance (§4.6); the pool halts once the mark diverges from the reference past refBandBps. This is magnitude-based and push-rate-independent: no number of small in-band steps evades it, and a reference co-signed by the same quorum would bound nothing.
Given disjoint signer sets, walking the mark to drain a pool takes two simultaneously-compromised independent quorums (primary and reference), not one, and even then only within the reference band before swaps halt. Where a deployment shares a set (Arc today), that reduces to one quorum.
6. Signature scheme rationale (k ECDSA, not aggregation)
The quorum is concatenated 65-byte ECDSA signatures over one shared digest, deliberately not a BLS or Schnorr aggregate. On BSC this is the correct trade:
- Aggregation is a gas regression on BSC. BLS verification needs a BN254 pairing; Schnorr (Scribe-style) verification does an on-chain per-signer public-key SLOAD (~5,100 gas/signer) plus an off-chain nonce round, exceeding independent ECDSA recovery (~4,040 gas/signer). At the sizes this contract can express (
MAX_SIGNERS = 16, and 2-of-3 in practice), plainecrecovercalls are cheaper and simpler than any aggregate. - It keeps add-a-signer trivial. Adding a signer is a registry write (§4.2). Schnorr/BLS aggregation with a fixed committee key would turn every membership change into a key-ceremony re-share, which is exactly the flow the timelocked-registry design avoids.
- It keeps the sub-50 ms quorum path. Concatenated ECDSA needs no interactive nonce round between signers. Each replica signs the shared digest independently and the relayer sorts and concatenates. This matches the NX Rates quorum latency target.
- The quorum cost amortizes once per batch. All signatures cover the same digest (the hash of the whole blob), so a batch of feeds pays the k-signature verification once, not per feed. Packed 22-byte records (header carries the shared
sourceTsMsonce) keep the marginal per-feed cost minimal.
The gas comparison above is measured on BSC; the conclusion is expected to hold on the other EVM targets but has not been re-measured per chain. The scheme is kept swappable: the contract treats sigs as opaque bytes over an EIP-712 digest, so a future move to aggregation (should a large fixed signer set ever justify it) does not change the feed layout or the consumer path. Byte-exact wire format: decodeBlob in @btr-protocol/sdk plus ExternalOracle.batchPushSigned.
7. Transparency: verify it yourself
Nothing in the trust chain is taken on faith from a BTR server. The Oracle Transparency page in the BTR front-end reconstructs and re-verifies the whole model client-side:
- Signer roster. Rebuilt from
SignerGranted/SignerRevokedlogs, then cross-checked againstsigners(addr)RPC reads before display. Pending timelocked governance (a queued grant or quorum decrease) is shown with its ETA, so a loosening is visible before it lands. - Quorum badge. The live
signerThresholdofsignerCount, read on-chain. - Feed table. Mark, , confidence, age,
maxDeviation, and freshness/paused status, straight fromgetFeed(). - Push log. Recent push transactions fetched from a public block explorer, decoded from calldata, and their signatures re-recovered in the browser against the on-chain granted-signer set — under the domain of the instance that accepted them (
BTR ExternalOracleV4/1andBatchQuoteV4on the live tiers, §3.1). The green “k/n verified” badge is computed locally; a lying explorer cannot forge a passing proof. V1 emitted nothing, so transparency there is calldata plusgetFeed()state plus governance logs; V4 emitsSlotsPushed(seq, sourceTsDs, acceptedMask, blobHash)on both push paths andLanesSkipped(slotId, laneMask)whenever a lane fail-softs, so a V4 push log can be indexed from events and the accepted mask read directly. Only a session push carries amsg.senderbound to the granted relay; a signed push carries none. - Relayers seen. Listed as informational only, labelled “relayer, not price authority”: any address may land a signed batch.
- Per-price redirect into NX Rates. Each decoded record deep-links to the NX Rates public verifier for the same
(symbol, sourceTs)attestation, so an auditor can re-check the price against NX Rates’ own signed-quote view. This is the same one-way citation as §2: BTR shows what it verified on-chain, NX Rates shows what it attested off-chain, and the two are checked to agree.
8. Related
- Oracles: feed struct, per-guard mechanics, LVR/OEV, consumer reads
- Feed Oracle: oracle modes, FeedData, push API
- Depeg Halt: base-token and spoke depeg circuit breakers
- Security Overview: defense-in-depth, threat model, trust assumptions
- NX Rates (NXR): price acquisition, aggregation, and signing are documented by NX Rates and not reproduced here (one-way citation); see NXR’s signed-quote specification (provided to integrators on request)