Three oracle defects. The first is the reason this issue exists, and it is a correction rather than a new report: issues #3 (item 3) and #9 (item 1) both establish that outlier rejection is inoperative, and both propose the same remedy — raise PRICE_MIN_SOURCES from 2 to 3 so trimOutliers always runs. That remedy does not fix the defect. The 2σ band is computed from the standard deviation of the full sample set including the outlier, and at n = 3 that band is median ± 1.155·d for an outlier at distance d — scale-invariant, so a lone outlier is inside the band for any magnitude and can never be trimmed. At n = 4 the band edge lands exactly on the outlier and the keep test is inclusive, so it is kept. Rejection first works at n = 5. Raising the floor to 3 therefore lands the feed squarely in the regime where the trim provably cannot reject, and the observable symptom — dispersion blowing the 50 bps gate and freezing refPrice — persists unchanged. The second finding is a livelock in the jump circuit breaker that freezes the mark for the whole duration of any move faster than about 1 %/min, not the documented ~30 s. The third is that three USD-quoted and five USDT-quoted venues are pooled into one median and one dispersion figure, with the XRC anchor on the USDT side, so a stablecoin depeg both vetoes the primary and is invisible to the divergence alarm.
Found and verified with Claude Opus 5. All line numbers and the venue census were re-checked against source; where the numbers in the internal findings had drifted they are corrected here silently.
1. The 2σ outlier trim is mathematically incapable of rejecting a single outlier at three or four sources — so the fix proposed in #3.3 and #9.1 does not resolve the defect
Where. src/backend/lib/PriceFeed.mo:199-213 (trimOutliers), keep test at :209; consumed by aggregate at :248-251. Gate constant PRICE_MAX_STDDEV_BPS = 50.0 at src/backend/main.mo:12909; enforced at :13205 and :13292; "price frozen" log at :13303.
What is wrong. The trim bands at median ± sigmaTrim · sd, where sd is the Bessel-corrected sample standard deviation of the whole set — the outlier included. That is textbook masking, and at small n it is total.
public func trimOutliers(xs : [Float], sigmaTrim : Float) : [Float] {
let n = xs.size();
if (n < 3) { return xs };
let m = switch (median(xs)) { case null { return xs }; case (?v) { v } };
let sd = switch (stddev(xs)) { case null { return xs }; case (?v) { v } };
if (sd < 0.0000001) { return xs };
let lo = m - sigmaTrim * sd;
let hi = m + sigmaTrim * sd;
var kept : [Float] = [];
for (x in xs.vals()) {
if (x >= lo and x <= hi) { kept := appendFloat(kept, x) };
};
Work the algebra with sigmaTrim = 2.0, the value aggregate passes.
*n = 3, samples (a, a, b), d = |b − a|.* Mean is a + d/3; the squared deviations are (d/3)² + (d/3)² + (2d/3)² = 2d²/3; divided by n − 1 = 2and square-rooted,sd = d/√3 ≈ 0.5774·d. The band half-width is 2·sd ≈ 1.1547·d. The outlier's distance from the median ais exactlyd. Since 1.1547·d > dfor everyd > 0, the outlier is strictly inside the band. The relation is homogeneous in d`, so this is scale-invariant: a 0.1 % outlier and a 100 % outlier are both kept. At three sources the trim can never reject anything.
n = 4, samples (a, a, a, b). Mean is a + d/4; squared deviations 3·(d/4)² + (3d/4)² = 12d²/16 = 3d²/4; over n − 1 = 3 gives d²/4, so sd = d/2 and 2·sd = d exactly. The outlier sits precisely on the band edge, and the keep test at :209 is inclusive (x <= hi), so it is kept. Any real scatter among the three good sources inflates sd further and pushes the outlier strictly inside.
n = 5, samples (a, a, a, a, b). sd = d·√(4/5)/2 ≈ 0.4472·d, band half-width ≈ 0.8944·d < d. The outlier is finally outside and is dropped. Rejection begins at five sources.
Why it matters. aggregate computes both the price and the dispersion over the kept set:
let kept = trimOutliers(okPrices, 2.0);
let finalPrice = Option.get(median(kept), 0.0);
let sd = Option.get(stddev(kept), 0.0);
let stddevBps = if (finalPrice > 0.0) { sd / finalPrice * 10000.0 } else { 0.0 };
When nothing is trimmed, stddevBps is the outlier-inflated figure, and both quality gates veto on it:
let primaryOk = agg.sourceCount >= minSources() and agg.price > 0.0 and agg.stddevBps <= PRICE_MAX_STDDEV_BPS;
Putting numbers on the veto: at n = 3 the median is a, so stddevBps = (d/√3)/a · 10⁴ ≈ 57.7 · (d/a in %) — the 50 bps gate is crossed at a 0.87 % outlier. At n = 4 the median is also a and stddevBps = 50 · (d/a in %) — crossed at 1.0 %. So one rogue source roughly 0.9–1 % off the cluster freezes refPrice instead of being rejected, which is the exact failure the trim was added to prevent (the header comment on aggregate at :222-234 cites the live incident it was written for). Note the code's own comment at main.mo:13287-13288 treats "4↔6 sources as a rate-limited one drops in/out" as routine, so n = 3–4 is a normal operating state, not an edge case.
This is why #3.3 and #9.1 are not fixed by their proposed remedy. PRICE_MIN_SOURCES is currently 2 (main.mo:12901); raising it to 3 moves the feed from "trim never runs" to "trim runs and cannot reject", which produces the same frozen mark. The two failures are genuinely distinct and both real:
Raising the floor closes the first and opens the second. The floor is not the defect; the dispersion estimator is.
Proof. No committed proof script covers this. The following is self-contained and runs in a few seconds against the real PriceFeed.trimOutliers. Write it to the repo root as trim_probe.mo (it is deliberately not committed) and delete it afterwards:
import Debug "mo:core/Debug";
import Float "mo:core/Float";
import PriceFeed "./src/backend/lib/PriceFeed";
func show(tag : Text, xs : [Float]) {
let kept = PriceFeed.trimOutliers(xs, 2.0);
let sd = switch (PriceFeed.stddev(xs)) { case (?v) v; case null 0.0 };
var s = "";
for (x in kept.vals()) { s #= Float.toText(x) # " " };
Debug.print(tag # " sd=" # Float.toText(sd)
# " 2sd=" # Float.toText(2.0 * sd)
# " kept(" # debug_show (kept.size()) # ")= " # s);
};
show("n=3 (100,100,101) ", [100.0, 100.0, 101.0]);
show("n=4 (100,100,100,101) ", [100.0, 100.0, 100.0, 101.0]);
show("n=5 (100,100,100,100,101)", [100.0, 100.0, 100.0, 100.0, 101.0]);
show("n=3 (100,100,200) ", [100.0, 100.0, 200.0]);
Run it with the pinned compiler, from the repo root:
MOC=$(mops toolchain bin moc | tail -1)
$MOC $(mops sources) --implicit-package=core -r trim_probe.mo
Verbatim output (moc 1.9.0, per mops.toml):
n=3 (100,100,101) sd=0.577_350_269_189_625_73 2sd=1.154_700_538_379_251_5 kept(3)= 100 100 101
n=4 (100,100,100,101) sd=0.5 2sd=1 kept(4)= 100 100 100 101
n=5 (100,100,100,100,101) sd=0.447_213_595_499_957_93 2sd=0.894_427_190_999_915_86 kept(4)= 100 100 100 100
n=3 (100,100,200) sd=57.735_026_918_962_575 2sd=115.470_053_837_925_15 kept(3)= 100 100 200
Rows one and two are the n = 3 and n = 4 cases with a 1 % outlier: nothing is trimmed, and 2sd is 1.1547 and 1.0 against a deviation of 1.0, matching the algebra. Row three is the same outlier at n = 5, where kept drops to four. Row four is the scale-invariance check: a 100 % outlier at n = 3 is still kept.
Correct behaviour. The trim must band against a dispersion estimate that the outlier cannot inflate (e.g. MAD × 1.4826, or a re-trim on the kept set), and stddevBps must be computed after that trim so the 50 bps gate judges the surviving cluster.
2. Circuit-breaker confirmation livelocks on a sustained move
Where. src/backend/main.mo:13036-13042 (acceptOrPendPrice, replacement branch). Constants: PRICE_JUMP_BPS = 250.0 at :12939, PRICE_JUMP_CONFIRM_BPS = 50.0 at :12940, PRICE_JUMP_MIN_CONFIRM_GAP_NS = 30 s at :12960. Confirm test at :13014-13015; independence guard at :13019-13029; TTL restart at :13005-13012.
What is wrong. Confirming a pended jump requires a later sample that is both within 50 bps of the pended candidate and at least 30 s after pending.firstSeenNs. A sample outside 50 bps takes the replacement branch, which overwrites the candidate and resets the clock:
// Doesn't confirm — replace pending with this new candidate.
Map.add(pendingPriceJumps, Text.compare, asset, {
proposedPrice = newPrice;
firstSeenNs = now;
});
_priceRefreshSuspended += 1;
false;
The 30 s guard immediately above it is careful not to rewrite firstSeenNs for exactly this reason ("resetting the clock on each would push the deadline out forever"), but the replacement branch does the reset unconditionally. During a sustained trend at v bps/s, the live price leaves the candidate's 50 bps neighbourhood after 50/v seconds. If 50/v < 30 — i.e. v > 1.67 bps/s, about 1 %/min — every candidate is replaced before it is old enough to be confirmable, and the confirmation clock restarts indefinitely. There is no forced-acceptance escape: the TTL branch at :13005-13012 also resets and returns false.
Two qualifications. Entry into the breaker path still requires an initial inter-sample move greater than PRICE_JUMP_BPS (2.5 %), and the livelock then holds as long as the live price stays more than 2.5 % from the frozen mark — which is automatic once a larger move is underway. And the freeze is the fail-safe direction by design (the comment at :12955-12959 notes that a stale mark skips liquidations rather than triggering them); the defect is the duration, not the direction.
Why it matters. The comment at :12955-12956 states the cost of the 30 s gap explicitly: "a GENUINE >2.5 % move now holds the mark for 30s rather than ~1s." The replacement-and-reset interaction breaks that bound. A 10 % move over five minutes (3.3 bps/s, an unremarkable flash-crash pace) freezes refPrice at the pre-move level for the whole five minutes, then gaps the entire distance in one tick about 30 s after the move decelerates below ~1 %/min. Margin marks, liquidation triggers, and vault valuation all run against the stale mark for the duration; positions that should have been called at intermediate prices are called at the bottom in a single step. The condition is self-resolving — it clears as soon as the move slows — which is why it will not show up as a stuck state in monitoring.
Overlap with #9.1, and why neither fix helps the other. This line range sits inside the range cited by #9 item 1, but it is the opposite failure mode. #9.1 concerns steps at or under 2.5 % being accepted one after another with no absolute anchor, so a large cumulative drift passes unchallenged. This is a genuine move greater than 2.5 % that is never confirmed. A cumulative-drift breaker of the kind #9.1 proposes would, if anything, lengthen this freeze; conversely, letting a trending candidate confirm would do nothing about unanchored small steps. Both need fixing, separately.
How to confirm. Read :13014-13042 as a state machine and note that the only path returning true requires now − firstSeenNs ≥ 30 s while |new − pending| ≤ 50 bps, and that the > 50 bps path unconditionally sets firstSeenNs = now. With samples arriving roughly every second (the cadence documented at :12946-12948) and a drift rate above 1.67 bps/s, no sample can satisfy both conditions. refPrice is written only through paths gated on acceptOrPendPrice returning true, so the freeze is complete; _priceRefreshSuspended increments once per replacement and is the observable signature.
Correct behaviour. A replacement that continues in the same direction should preserve the original firstSeenNs (or track it per direction), so 30 s of consistent displacement confirms the move rather than restarting the clock.
3. USD-quoted and USDT-quoted venues are pooled into one sample set, and the anchor is on the USDT side
Where. src/backend/main.mo:12421-12470 (PRICE_SOURCES), with symbol mapping at src/backend/lib/PriceFeed.mo:104-146. XRC anchor request at main.mo:13162; justifying comment at :13140-13141. Divergence alarm at :13208-13219; fallback application at :13229-13240.
What is wrong. The venue census, re-read from source and confirmed:
| Source |
URL / symbol |
Quote |
| coinbase |
/v2/prices/{asset}-USD/spot |
USD |
| coingecko |
vs_currencies=usd |
USD |
| kraken |
#krakenLike → XBTUSD, {a}USD |
USD |
| okx |
instId={asset}-USDT |
USDT |
| kucoin |
symbol={asset}-USDT |
USDT |
| htx |
#htx → btcusdt etc. |
USDT |
| cryptocom |
#cryptocom → {asset}_USDT |
USDT |
| binance |
#binance → {asset}USDT |
USDT |
Three USD, five USDT. All eight readings go into a single okPrices array and one median and one standard deviation in PriceFeed.aggregate, as if they shared a quote currency. The pool's quote token is ICPUSD, USD-pegged.
The XRC anchor is requested against USDT:
let res = await (with cycles = XRC_CALL_CYCLES) xrc.get_exchange_rate({
base_asset = { symbol = baseTok; class_ = #Cryptocurrency };
quote_asset = { symbol = "USDT"; class_ = #Cryptocurrency };
timestamp = null; // XRC's freshest normalized minute (now−30s, floored)
});
The comment immediately above it justifies that choice as follows:
// never to a wrong price. Quote = USDT (#Cryptocurrency), matching the
// Kraken primary leg and avoiding the daily-granular forex path.
Verified against PriceFeed.assetSymbol's #krakenLike branch (PriceFeed.mo:124-132), the Kraken leg is configured in USD (XBTUSD, ETHUSD, SOLUSD, ICPUSD), so the stated justification does not hold. The forex-granularity half of the reasoning stands on its own; the "matching the Kraken primary leg" half does not.
Why it matters. At a 5 % depeg with a 3-versus-5 split, the two clusters are ~5 % apart and both survive the trim — finding 1 above is the reason: the band is derived from a standard deviation that the split itself inflates, so it is wide enough to contain both clusters. Pooled stddevBps lands around 260 bps, far above the 50 bps gate, so primaryOk is false on every tick for every market. Control then falls to the XRC fallback at :13229-13240, which supplies a USDT-quoted rate — the depegged price — and applies it to refPrice. Margin, liquidation, and vault valuation all run against a USDT price for a USD-pegged quote token, and the divergence alarm at :13208-13219 cannot catch it: it compares the primary against the same USDT-denominated anchor, so the anchor agrees with the error by construction. The log emitted on that path attributes the degradation to source count, not to quote-currency skew. The alarm is structurally blind here, which is the part that makes this worse than a visible outage.
The gate is crossed much earlier than a 5 % depeg. With the 3-versus-5 split the pooled dispersion reaches 50 bps at roughly a 0.97 % depeg, so a ~1 % depeg parks the feed on the gate boundary and flaps between primary and fallback tick by tick. And if two of the three USD sources are rate-limited on the day — routine per :13287-13288 — the remaining USD print is a lone outlier in a set of six, which at n ≥ 5 the trim does reject, leaving the exchange marking at the full USDT price with stddevBps near zero and every health signal green.
How to confirm. Read PRICE_SOURCES at :12421-12470 alongside PriceFeed.assetSymbol at :104-146 and tabulate the quote currency per source; the split is 3/5 as above. Then read aggregate at :235-260 and confirm there is no per-quote grouping — every Reading that passes the finite-magnitude gate is appended to one array. Then read :13162 for the anchor's quote asset.
Correct behaviour. Readings must be normalised to a single quote currency (or tagged with their quote and aggregated per-currency and cross-checked) before a median and dispersion are taken, and the anchor must be denominated on the same side as the pool's quote token.
Three oracle defects. The first is the reason this issue exists, and it is a correction rather than a new report: issues #3 (item 3) and #9 (item 1) both establish that outlier rejection is inoperative, and both propose the same remedy — raise
PRICE_MIN_SOURCESfrom 2 to 3 sotrimOutliersalways runs. That remedy does not fix the defect. The 2σ band is computed from the standard deviation of the full sample set including the outlier, and at n = 3 that band ismedian ± 1.155·dfor an outlier at distanced— scale-invariant, so a lone outlier is inside the band for any magnitude and can never be trimmed. At n = 4 the band edge lands exactly on the outlier and the keep test is inclusive, so it is kept. Rejection first works at n = 5. Raising the floor to 3 therefore lands the feed squarely in the regime where the trim provably cannot reject, and the observable symptom — dispersion blowing the 50 bps gate and freezingrefPrice— persists unchanged. The second finding is a livelock in the jump circuit breaker that freezes the mark for the whole duration of any move faster than about 1 %/min, not the documented ~30 s. The third is that three USD-quoted and five USDT-quoted venues are pooled into one median and one dispersion figure, with the XRC anchor on the USDT side, so a stablecoin depeg both vetoes the primary and is invisible to the divergence alarm.Found and verified with Claude Opus 5. All line numbers and the venue census were re-checked against source; where the numbers in the internal findings had drifted they are corrected here silently.
1. The 2σ outlier trim is mathematically incapable of rejecting a single outlier at three or four sources — so the fix proposed in #3.3 and #9.1 does not resolve the defect
Where.
src/backend/lib/PriceFeed.mo:199-213(trimOutliers), keep test at:209; consumed byaggregateat:248-251. Gate constantPRICE_MAX_STDDEV_BPS = 50.0atsrc/backend/main.mo:12909; enforced at:13205and:13292; "price frozen" log at:13303.What is wrong. The trim bands at
median ± sigmaTrim · sd, wheresdis the Bessel-corrected sample standard deviation of the whole set — the outlier included. That is textbook masking, and at smallnit is total.Work the algebra with
sigmaTrim = 2.0, the valueaggregatepasses.*n = 3, samples
(a, a, b),d = |b − a|.* Mean isa + d/3; the squared deviations are(d/3)² + (d/3)² + (2d/3)² = 2d²/3; divided byn − 1 = 2and square-rooted,sd = d/√3 ≈ 0.5774·d. The band half-width is2·sd ≈ 1.1547·d. The outlier's distance from the medianais exactlyd. Since1.1547·d > dfor everyd > 0, the outlier is strictly inside the band. The relation is homogeneous ind`, so this is scale-invariant: a 0.1 % outlier and a 100 % outlier are both kept. At three sources the trim can never reject anything.n = 4, samples
(a, a, a, b). Mean isa + d/4; squared deviations3·(d/4)² + (3d/4)² = 12d²/16 = 3d²/4; overn − 1 = 3givesd²/4, sosd = d/2and2·sd = dexactly. The outlier sits precisely on the band edge, and the keep test at:209is inclusive (x <= hi), so it is kept. Any real scatter among the three good sources inflatessdfurther and pushes the outlier strictly inside.n = 5, samples
(a, a, a, a, b).sd = d·√(4/5)/2 ≈ 0.4472·d, band half-width≈ 0.8944·d < d. The outlier is finally outside and is dropped. Rejection begins at five sources.Why it matters.
aggregatecomputes both the price and the dispersion over the kept set:When nothing is trimmed,
stddevBpsis the outlier-inflated figure, and both quality gates veto on it:Putting numbers on the veto: at n = 3 the median is
a, sostddevBps = (d/√3)/a · 10⁴ ≈ 57.7 · (d/a in %)— the 50 bps gate is crossed at a 0.87 % outlier. At n = 4 the median is alsoaandstddevBps = 50 · (d/a in %)— crossed at 1.0 %. So one rogue source roughly 0.9–1 % off the cluster freezesrefPriceinstead of being rejected, which is the exact failure the trim was added to prevent (the header comment onaggregateat:222-234cites the live incident it was written for). Note the code's own comment atmain.mo:13287-13288treats "4↔6 sources as a rate-limited one drops in/out" as routine, so n = 3–4 is a normal operating state, not an edge case.This is why #3.3 and #9.1 are not fixed by their proposed remedy.
PRICE_MIN_SOURCESis currently 2 (main.mo:12901); raising it to 3 moves the feed from "trim never runs" to "trim runs and cannot reject", which produces the same frozen mark. The two failures are genuinely distinct and both real:if (n < 3) { return xs }early return at:201. At thePRICE_MIN_SOURCES = 2floor the trim is bypassed entirely. Correct as filed.Raising the floor closes the first and opens the second. The floor is not the defect; the dispersion estimator is.
Proof. No committed proof script covers this. The following is self-contained and runs in a few seconds against the real
PriceFeed.trimOutliers. Write it to the repo root astrim_probe.mo(it is deliberately not committed) and delete it afterwards:Run it with the pinned compiler, from the repo root:
Verbatim output (moc 1.9.0, per
mops.toml):Rows one and two are the n = 3 and n = 4 cases with a 1 % outlier: nothing is trimmed, and
2sdis1.1547and1.0against a deviation of1.0, matching the algebra. Row three is the same outlier at n = 5, wherekeptdrops to four. Row four is the scale-invariance check: a 100 % outlier at n = 3 is still kept.Correct behaviour. The trim must band against a dispersion estimate that the outlier cannot inflate (e.g. MAD × 1.4826, or a re-trim on the kept set), and
stddevBpsmust be computed after that trim so the 50 bps gate judges the surviving cluster.2. Circuit-breaker confirmation livelocks on a sustained move
Where.
src/backend/main.mo:13036-13042(acceptOrPendPrice, replacement branch). Constants:PRICE_JUMP_BPS = 250.0at:12939,PRICE_JUMP_CONFIRM_BPS = 50.0at:12940,PRICE_JUMP_MIN_CONFIRM_GAP_NS = 30 sat:12960. Confirm test at:13014-13015; independence guard at:13019-13029; TTL restart at:13005-13012.What is wrong. Confirming a pended jump requires a later sample that is both within 50 bps of the pended candidate and at least 30 s after
pending.firstSeenNs. A sample outside 50 bps takes the replacement branch, which overwrites the candidate and resets the clock:The 30 s guard immediately above it is careful not to rewrite
firstSeenNsfor exactly this reason ("resetting the clock on each would push the deadline out forever"), but the replacement branch does the reset unconditionally. During a sustained trend atvbps/s, the live price leaves the candidate's 50 bps neighbourhood after50/vseconds. If50/v < 30— i.e.v > 1.67 bps/s, about 1 %/min — every candidate is replaced before it is old enough to be confirmable, and the confirmation clock restarts indefinitely. There is no forced-acceptance escape: the TTL branch at:13005-13012also resets and returnsfalse.Two qualifications. Entry into the breaker path still requires an initial inter-sample move greater than
PRICE_JUMP_BPS(2.5 %), and the livelock then holds as long as the live price stays more than 2.5 % from the frozen mark — which is automatic once a larger move is underway. And the freeze is the fail-safe direction by design (the comment at:12955-12959notes that a stale mark skips liquidations rather than triggering them); the defect is the duration, not the direction.Why it matters. The comment at
:12955-12956states the cost of the 30 s gap explicitly: "a GENUINE >2.5 % move now holds the mark for 30s rather than ~1s." The replacement-and-reset interaction breaks that bound. A 10 % move over five minutes (3.3 bps/s, an unremarkable flash-crash pace) freezesrefPriceat the pre-move level for the whole five minutes, then gaps the entire distance in one tick about 30 s after the move decelerates below ~1 %/min. Margin marks, liquidation triggers, and vault valuation all run against the stale mark for the duration; positions that should have been called at intermediate prices are called at the bottom in a single step. The condition is self-resolving — it clears as soon as the move slows — which is why it will not show up as a stuck state in monitoring.Overlap with #9.1, and why neither fix helps the other. This line range sits inside the range cited by #9 item 1, but it is the opposite failure mode. #9.1 concerns steps at or under 2.5 % being accepted one after another with no absolute anchor, so a large cumulative drift passes unchallenged. This is a genuine move greater than 2.5 % that is never confirmed. A cumulative-drift breaker of the kind #9.1 proposes would, if anything, lengthen this freeze; conversely, letting a trending candidate confirm would do nothing about unanchored small steps. Both need fixing, separately.
How to confirm. Read
:13014-13042as a state machine and note that the only path returningtruerequiresnow − firstSeenNs ≥ 30 swhile|new − pending| ≤ 50 bps, and that the> 50 bpspath unconditionally setsfirstSeenNs = now. With samples arriving roughly every second (the cadence documented at:12946-12948) and a drift rate above 1.67 bps/s, no sample can satisfy both conditions.refPriceis written only through paths gated onacceptOrPendPricereturningtrue, so the freeze is complete;_priceRefreshSuspendedincrements once per replacement and is the observable signature.Correct behaviour. A replacement that continues in the same direction should preserve the original
firstSeenNs(or track it per direction), so 30 s of consistent displacement confirms the move rather than restarting the clock.3. USD-quoted and USDT-quoted venues are pooled into one sample set, and the anchor is on the USDT side
Where.
src/backend/main.mo:12421-12470(PRICE_SOURCES), with symbol mapping atsrc/backend/lib/PriceFeed.mo:104-146. XRC anchor request atmain.mo:13162; justifying comment at:13140-13141. Divergence alarm at:13208-13219; fallback application at:13229-13240.What is wrong. The venue census, re-read from source and confirmed:
/v2/prices/{asset}-USD/spotvs_currencies=usd#krakenLike→XBTUSD,{a}USDinstId={asset}-USDTsymbol={asset}-USDT#htx→btcusdtetc.#cryptocom→{asset}_USDT#binance→{asset}USDTThree USD, five USDT. All eight readings go into a single
okPricesarray and one median and one standard deviation inPriceFeed.aggregate, as if they shared a quote currency. The pool's quote token isICPUSD, USD-pegged.The XRC anchor is requested against USDT:
The comment immediately above it justifies that choice as follows:
Verified against
PriceFeed.assetSymbol's#krakenLikebranch (PriceFeed.mo:124-132), the Kraken leg is configured in USD (XBTUSD,ETHUSD,SOLUSD,ICPUSD), so the stated justification does not hold. The forex-granularity half of the reasoning stands on its own; the "matching the Kraken primary leg" half does not.Why it matters. At a 5 % depeg with a 3-versus-5 split, the two clusters are ~5 % apart and both survive the trim — finding 1 above is the reason: the band is derived from a standard deviation that the split itself inflates, so it is wide enough to contain both clusters. Pooled
stddevBpslands around 260 bps, far above the 50 bps gate, soprimaryOkis false on every tick for every market. Control then falls to the XRC fallback at:13229-13240, which supplies a USDT-quoted rate — the depegged price — and applies it torefPrice. Margin, liquidation, and vault valuation all run against a USDT price for a USD-pegged quote token, and the divergence alarm at:13208-13219cannot catch it: it compares the primary against the same USDT-denominated anchor, so the anchor agrees with the error by construction. The log emitted on that path attributes the degradation to source count, not to quote-currency skew. The alarm is structurally blind here, which is the part that makes this worse than a visible outage.The gate is crossed much earlier than a 5 % depeg. With the 3-versus-5 split the pooled dispersion reaches 50 bps at roughly a 0.97 % depeg, so a ~1 % depeg parks the feed on the gate boundary and flaps between primary and fallback tick by tick. And if two of the three USD sources are rate-limited on the day — routine per
:13287-13288— the remaining USD print is a lone outlier in a set of six, which at n ≥ 5 the trim does reject, leaving the exchange marking at the full USDT price withstddevBpsnear zero and every health signal green.How to confirm. Read
PRICE_SOURCESat:12421-12470alongsidePriceFeed.assetSymbolat:104-146and tabulate the quote currency per source; the split is 3/5 as above. Then readaggregateat:235-260and confirm there is no per-quote grouping — everyReadingthat passes the finite-magnitude gate is appended to one array. Then read:13162for the anchor's quote asset.Correct behaviour. Readings must be normalised to a single quote currency (or tagged with their quote and aggregated per-currency and cross-checked) before a median and dispersion are taken, and the anchor must be denominated on the same side as the pool's quote token.