Price registry stables from a market instead of asserting $1 (closes #27) - #61
Merged
Conversation
…ing $1 (#27) Every stable in the registry was worth a dollar because the code said so. chain.ts summed the active stables into one usdcUnits figure *before* anything valued them, and valuation.ts priced any token of kind "stable" at exactly 1 -- so a depegged stable was scored at par, which is the phantom-value failure #39 kept eUSD outside the registry to avoid. (a) The structural fix, in the order the issue sets out: 1. Publish the breakdown. obs.balances.stables carries each stable's balance, decimals, and priceUsdc, with marketQuoted saying whether that price is an observation or par by assumption. A better place to look has to exist before the old one is taken away. 2. Narrow usdcUnits to native USDC. All nine participant-facing uses treat it as a spending budget, and as a budget the sum was already wrong: USDT cannot be spent in a USDC pool, and funding grants the configured amount to each stable, so it read roughly double. 3. Price each stable from its market: the geometric mean of both executable directions, both probes fixed-notional so they fit one stage of the scorer's cross-section multicall. A pool that will not quote falls back to par and is *reported* (the new "par-fallback" exclusion reason) rather than assumed in silence. USDC stays the numéraire at $1 by definition, and marketPricedStables() refuses a leg naming it. (b) eUSD is promoted into the registry, and its price *moves* rather than gaining a second owner: the liquity adapter no longer values the wallet's loose eUSD (the spot sweep does) and reads the shared probe for its Trove and Stability Pool legs instead of running its own. Measured on config/regimes/liquity.yaml seed 401: redemption-arb +57.50, against #39's +57.81. The issue's open point is answered -- the price source is the same pool either way. (c) DAI becomes the second market-priced stable, which is the trade eUSD cannot supply: no redemption floor, so par returning is an opinion rather than a claim on collateral. Its USDC/DAI stableswap pool drops to A=100 (at A=2000, selling half the pool moves it 4.4bps -- #39's calibration), a `stableSwap` action makes the pair tradable, and a `depeg` stress event pushes it through the same per-block reconcile the eUSD depeg uses, now shared in stableDepeg.ts. Market prices also reach the Aave aggregators, which is inert until a market-priced stable is listed. Funding grants the endowment to par stables only. Conjuring eUSD with a cheatcode would circulate stablecoin no Trove borrowed, and endowing everyone with a stable about to depeg would make the loss beta on a position nobody chose. Measured on the new config/regimes/depeg.yaml seed 701: the environment sells 59% of the pool's depth for an 89.5bps discount, peg-arb +139.6 / peg-arb-eager +195.7 / noop 0. The peg overshoots to 143bps *above* par on the way back, because the arbitrageurs took the other side. depeg joins the public scenario set as ADR 0017's seventh regime, which redistributes every standing. One bug this shook out and fixed: maxUsdcInUnits is denominated in USDC's six decimals, so comparing it against 18-decimal DAI rejected every unwind while letting every buy through -- 42 rejected sells against 6 accepted buys, and a "profit" that was a mark on a position the agent could not close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es (#27) Correctness, in the order it matters: **The scored cross-section ran after the teardown.** `restoreStableDepeg` buys the stable back to par before the run captures its final block, so an agent that never unwound was marked at par -- holding through the end cost nothing, which is the exact risk the regime exists to create. The last competition block is now captured before the teardowns, and the end-of-run PnL prices its close at that block too, so netPnlUsdc and alphaUsdc agree about when the run ended. **A stableSwap in a bundle spent nothing.** curve is bundleable and `applyLeafSpend` had no case for it, so both legs of a two-leg bundle validated against the same untouched balance and the second reverted on chain at the agent's expense -- the hole the lst cases were added for, reopened. **Reachability was three different sets.** `marketPricedStables()` answered from the deployment, so a stable whose venue the run disabled was still parseable, approved and executable while being neither swept nor priced: an agent could spend USDC on a token the scorer would not count, and the dollars would simply vanish from its score. It is venue-gated now, and the depeg event's target is checked against the same set with an error that names the actual requirement. **eUSD without a pool was a silent zero.** The adapter stopped valuing wallet eUSD, so a deployment with no eUSD/USDC market would have scored a borrower's whole draw at nothing. That configuration now fails fast and says which redeploy fixes it. **The registry symbol is EUSD.** `actionSchema`'s tokenSymbol is uppercase-only, so `stable: "eUSD"` was structurally invalid on the schema-validated path -- and `liquitySwapEusd` already spelt it EUSD. **One deployer-key task, actually.** The comment claimed the liquidity pull and the depeg shared a task while the code pushed two into the same Promise.all; both send from the deployer key and resolve the nonce per call, so the crash+depeg combination `alignWith` exists to express would have had one replace the other. Same for the shared `ownerByAddress` entry, which every mechanism on that account was overwriting. **`valueUsdc` treated an empty `stables: {}` as authoritative**, dropping usdcUnits entirely. `validateLeafItems` constructs exactly that shape. peg-arb: a dust floor on the sell leg (a few wei left over had it propose a swap every remaining block), validation on its env parameters (a typo became NaN and silently disabled it), and it leaves EUSD alone -- redemption-arb trades that dislocation with an instrument that enforces par instead of hoping for it. Also: memoized the derived market list, removed the constants and imports the stableDepeg extraction left behind, corrected the comments this PR invalidated (BalanceSnapshot.usdcUnits, and two files still citing the USDC/DAI pool's old A=2000), added stableSwap to the venue/action table, and made `gen:state-dump` restore automine -- evm_revert had been leaving the shared anvil accepting transactions and mining none, so the next run hung on a receipt that never arrived. Re-measured after the fixes: depeg#701 unchanged at peg-arb +139.61 / peg-arb-eager +195.71 with zero rejections, and the peg's overshoot past par is ~6bps once arbitrageurs actually unwind (the -143bps figure came from the run where the decimal bug rejected every sell -- the size of that overshoot reads as "nobody could close"). liquity#401 across three runs: 57.50 / 91.86 / 100.60, bracketing #39's 57.81 -- run-to-run timing non-determinism dominates, which is the documented property of a realtime run rather than a change in the mark. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #27.
Every stable in the registry was worth a dollar because the code said so.
chain.tssummed theactive stables into one
usdcUnitsfigure before anything valued them, andvaluation.tspriced any token of kind
"stable"at exactly 1 — so a depegged stable was scored at par, thephantom-value failure #39 kept eUSD outside the registry to avoid.
(a) Price registry stables from a market
The three steps the issue sets out, in order:
obs.balances.stablescarries each stable's balance, decimals andpriceUsdc, withmarketQuotedsaying whether that price is an observation or par byassumption. A better place to look has to exist before the next step takes the old one away.
usdcUnitsto native USDC. All nine participant-facing uses treat it as a spendingbudget, and as a budget the sum was already wrong: USDT cannot be spent in a USDC pool, and
funding grants the configured amount to each stable, so it read roughly double.
sdk/src/stables.ts): the geometric mean of bothexecutable directions, both probes fixed-notional so they fit one stage of the scorer's
cross-section multicall. A pool that will not quote falls back to par and is reported — a
new
par-fallbackexclusion reason — rather than assumed in silence.USDC stays the numéraire at $1 by definition, and
marketPricedStables()refuses a leg naming it.The stable leg is marked live in the α evaluation too: unlike a base's fair price, a peg's
discount is a dislocation against a price something enforces, and evaluating it at a fixed
reference would cancel exactly the thing being measured.
(b) eUSD promoted, with the price moved rather than copied
The liquity adapter no longer values the wallet's loose eUSD (the spot sweep does) and reads the
shared probe for its Trove and Stability Pool legs instead of running its own — one owner, which is
what the double-counting hazard in the issue asks for. Its registry symbol is
EUSD, matching theuppercase rule
actionSchemaenforces and the spellingliquitySwapEusdalready used.On the open point — whether promoting eUSD changes #39's measured numbers:
redemption-arbonliquity#401came in at 57.50 / 91.86 / 100.60 across three runs, bracketing #39's 57.81. Thespread is run-to-run timing non-determinism, which is the documented property of a realtime run
(CLAUDE.md, ADR 0005); the mark itself is the same pool either way.
(c) DAI as the second market-priced stable
The trade eUSD cannot supply: no redemption floor, so par returning is an opinion rather than a
claim on collateral. The deployer's USDC/DAI stableswap pool drops to A=100 (at A=2000, selling
half the pool moves it 4.4bps — #39's calibration), a
stableSwapaction makes the pair tradable,and a
depegstress event pushes it through the same per-block reconcile the eUSD depeg uses, nowshared in
core/src/realtime/stableDepeg.ts. Market prices also reach the Aave aggregators, whichis inert until a market-priced stable is listed as a reserve.
Funding grants the endowment to par stables only. Conjuring eUSD with a cheatcode would
circulate stablecoin no Trove borrowed, and endowing everyone with a stable about to depeg would
make the loss β on a position nobody chose.
Measured on the new
config/regimes/depeg.yamlseed 701: the environment sells 59% of the pool'sdepth for an 89.5bps discount;
peg-arb+139.6 /peg-arb-eager+195.7 /noop0. The pegovershoots past par on the way back, because the arbitrageurs took the other side — by ~6bps when
they unwind, and much further when they cannot (see below).
depegjoinsconfig/scenarios/public.yamlas ADR 0017's seventh regime — which redistributesevery standing, so matrix runs from before this cannot be read across.
Two bugs this shook out
The per-round cap's units.
maxUsdcInUnitsis denominated in USDC's six decimals, so comparingit against 18-decimal DAI rejected every unwind while letting every buy through: 42 rejected sells
against 6 accepted buys, and a "+439 profit" that was a mark on a position the agent could not
close. That run is also where the −143bps overshoot came from — the size of the overshoot reads as
"nobody could close".
The scored cross-section ran after the teardown.
restoreStableDepegbuys the peg back beforethe run captured its final block, so an agent that never unwound was marked at par and holding
through the end cost nothing — the exact risk the regime exists to create. The last competition
block is now captured before the teardowns, and the end-of-run PnL prices its close there too.
Review fixes (second commit)
stableSwaphad noapplyLeafSpendcase, so both legs of a bundle validated against the sameuntouched balance
marketPricedStables()answered from the deployment rather than the run, so a stable whose venuewas disabled stayed parseable and executable while being neither swept nor priced
fails fast now
Promise.allwhile both send from thedeployer key — the comment claimed they shared a task, the code never did
valueUsdctreated an emptystables: {}as authoritative and droppedusdcUnitsredemption-arb
gen:state-dumpnow restores automine:evm_reverthad been leaving the shared anvil acceptingtransactions and mining none, so the next run hung on a receipt that never arrived
Verification
npm run test454 tests, 0 failures (16 intest/stables.test.ts: the probe, the live andhistorical marks, the reported par fallback end to end through the scorer, venue gating, and
bundle spend accounting)
npm run typecheck/check:boundaries/check:strategycleansim:realtimeon the liquity and depeg regimes, andbacktestreplays ofdepeg#701(peg-arb +162.16) and
calm#101(multi-arb +225.24) from a regenerated state dump🤖 Generated with Claude Code