Skip to content

Price registry stables from a market instead of asserting $1 (closes #27) - #61

Merged
adachi-440 merged 2 commits into
mainfrom
feat/market-priced-stables
Aug 12, 2026
Merged

Price registry stables from a market instead of asserting $1 (closes #27)#61
adachi-440 merged 2 commits into
mainfrom
feat/market-priced-stables

Conversation

@adachi-440

@adachi-440 adachi-440 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #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, the
phantom-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:

  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 next step takes the old one 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 (sdk/src/stables.ts): 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 — a
    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.
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 the
uppercase rule actionSchema enforces and the spelling liquitySwapEusd already used.

On the open point — whether promoting eUSD changes #39's measured numbers: redemption-arb on
liquity#401 came in at 57.50 / 91.86 / 100.60 across three runs, bracketing #39's 57.81. The
spread 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 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 core/src/realtime/stableDepeg.ts. Market prices also reach the Aave aggregators, which
is 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.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 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).

depeg joins config/scenarios/public.yaml as ADR 0017's seventh regime — which redistributes
every standing, so matrix runs from before this cannot be read across.

Two bugs this shook out

The per-round cap's units. 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 "+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. restoreStableDepeg buys the peg back before
the 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)

  • stableSwap had no applyLeafSpend case, so both legs of a bundle validated against the same
    untouched balance
  • marketPricedStables() answered from the deployment rather than the run, so a stable whose venue
    was disabled stayed parseable and executable while being neither swept nor priced
  • a liquity deployment with no eUSD/USDC pool would have scored a borrower's whole draw at zero; it
    fails fast now
  • the liquidity pull and the depeg were two entries in one Promise.all while both send from the
    deployer key — the comment claimed they shared a task, the code never did
  • valueUsdc treated an empty stables: {} as authoritative and dropped usdcUnits
  • peg-arb: dust floor on the sell leg, env-parameter validation, and it leaves EUSD to
    redemption-arb
  • gen:state-dump now restores 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

Verification

  • npm run test 454 tests, 0 failures (16 in test/stables.test.ts: the probe, the live and
    historical marks, the reported par fallback end to end through the scorer, venue gating, and
    bundle spend accounting)
  • npm run typecheck / check:boundaries / check:strategy clean
  • Live: sim:realtime on the liquity and depeg regimes, and backtest replays of depeg#701
    (peg-arb +162.16) and calm#101 (multi-arb +225.24) from a regenerated state dump

🤖 Generated with Claude Code

adachi-440 and others added 2 commits August 12, 2026 18:48
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[sim] Stablecoin depeg: price registry stables from a market instead of asserting $1 (eUSD first)

1 participant