From af5a587f434184d69b4138046d910293e73fecbf Mon Sep 17 00:00:00 2001 From: adachi-440 Date: Wed, 12 Aug 2026 18:48:06 +0700 Subject: [PATCH 1/2] feat(scoring): price registry stables from a market instead of asserting $1 (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 68 ++- config/regimes/depeg.yaml | 87 ++++ config/scenarios/public.yaml | 12 +- core/src/coordinator.ts | 20 +- core/src/realtime/coordinator.ts | 179 +++++--- core/src/realtime/events.ts | 63 ++- core/src/realtime/liquity.ts | 407 ++---------------- core/src/realtime/reconstruct.ts | 82 +++- core/src/realtime/stableDepeg.ts | 458 +++++++++++++++++++++ deployer/src/protocols/curve.ts | 17 +- docs/adr/0017-scenario-based-evaluation.md | 9 + docs/guide/writing-agents.md | 17 +- example/agents/lib/affordable.ts | 8 +- example/agents/peg-arb/agent.ts | 143 +++++++ scripts/genLocalConstants.ts | 52 ++- sdk/src/action.ts | 10 +- sdk/src/actionSchema.ts | 13 +- sdk/src/chain.ts | 25 +- sdk/src/constants.local.ts | 13 +- sdk/src/constants.ts | 63 ++- sdk/src/markets.ts | 15 +- sdk/src/observation.ts | 49 ++- sdk/src/pnl.ts | 40 +- sdk/src/protocols/balancer.ts | 8 +- sdk/src/protocols/curve.ts | 146 ++++++- sdk/src/protocols/liquity.ts | 172 +++----- sdk/src/protocols/oracles.ts | 52 ++- sdk/src/protocols/registry.ts | 14 +- sdk/src/protocols/types.ts | 8 +- sdk/src/protocols/uniswap.ts | 16 +- sdk/src/stables.ts | 286 +++++++++++++ sdk/src/types.ts | 39 ++ sdk/src/valuation.ts | 43 +- test/gmxMarketToken.test.ts | 3 + test/liquity.test.ts | 130 +++--- test/lst.test.ts | 2 + test/scoringExclusions.test.ts | 2 + test/stables.test.ts | 292 +++++++++++++ 38 files changed, 2397 insertions(+), 666 deletions(-) create mode 100644 config/regimes/depeg.yaml create mode 100644 core/src/realtime/stableDepeg.ts create mode 100644 example/agents/peg-arb/agent.ts create mode 100644 sdk/src/stables.ts create mode 100644 test/stables.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index d3f010f..af68345 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ agents: - `npm run gen:state-dump` — 稼働中の deployer anvil から配布用 state dump + manifest(生成元コミット・deployments 同梱・fingerprint)を `backtest/state/` へ生成(ADR 0016。dump 前に `.local-snapshot` のクリーン断面へ revert し、constants.local.ts も同じ deployments から再生成) - `npm run backtest -- --regime --seed ` — シナリオ 1 本を再生(ADR 0016 Phase 0 = B1 実時間再生)。state dump をロードした専用 anvil(既定 port 8547)で `config/regimes/.yaml` + seed を再生する。**シナリオ = (regime, seed)** で regime YAML は seed を持たないので `--seed` は必須(ADR 0017 §1)。`--agents `(regime 既定ロスターの差し替え)/ `--protocols`/`--blocks`/`--score-every` 等の一回上書き。**override は実効 regime YAML に書き出されて agent プロセスにも伝播**(coordinator だけに効かせると agent が観測で死ぬ)。fingerprint 不一致は manifest 同梱 deployments から constants を自動再生成、genesis 不一致は fail-fast - `npm run backtest -- --scenarios config/scenarios/public.yaml` — シナリオ行列を 1 つの anvil 上で全部再生し順位を出す(ADR 0017)。`{regimes, seeds}` の直積で、シナリオ間は snapshot/revert。`runs/matrix-/matrix.json`(シナリオ × agent の生スコア。**netPnlUsdc と alphaUsdc の両方**)と `standings.json`(レジーム内 z-score → レジーム等重み平均)を書く。順位は派生物で、採点方法は将来見直す前提(matrix.json から再計算できる)。`--metric netPnlUsdc|alphaUsdc` / `--repeat N`(較正の診断用。採点は 1 回が既定) - - **公式レジーム**: `calm` / `cex-drift`(OU に drift、kappa 弱化)/ `informed-flow`(相関した方向性フロー)/ `whale`(単発大口の点イベント)/ `lending-incident`(暴落 + victim + 清算 + 同じ窓の引き抜き)/ `crash`(価格ギャップ + 同じ窓での引き抜き。3 venue が同時に薄くなる)。`depeg`(レジストリの stable を外す方)は issue #27 待ち。`lst` / `liquity` は競技セット外(venue 単体検証用) + - **公式レジーム**: `calm` / `cex-drift`(OU に drift、kappa 弱化)/ `informed-flow`(相関した方向性フロー)/ `whale`(単発大口の点イベント)/ `lending-incident`(暴落 + victim + 清算 + 同じ窓の引き抜き)/ `crash`(価格ギャップ + 同じ窓での引き抜き。3 venue が同時に薄くなる)/ `depeg`(レジストリの stable が $1 でなくなる。issue #27)。`lst` / `liquity` は競技セット外(venue 単体検証用) - `--score-every N` は採点断面の間引き。成績は初期/最終断面しか使わない(`alphaByAgent = alphaLast − alphaFirst`)ので**スコアは不変**、equity curve が粗くなるだけ - `npm run typecheck` / `npm run test` — 型チェック / ユニットテスト - `npm run check:strategy` — 戦略コードの cheatcode 静的検査(入口ゲート) @@ -183,11 +183,13 @@ ours なのは 2 つだけ(core は無改変): (venue の初回 live run で全償還が `Unable to redeem any amount` で revert して判明)。helper は `fetchPrice()` で価格を確定させた同一 tx 内でヒントを計算する。periphery であって core の改変ではない -- **eUSD は TOKENS レジストリに入れない**。stable として登録すると scorer の spot 掃引が $1 で評価してしまい、 - デペグした CDP stablecoin に phantom value を与える(issue #39 が名指しで禁じている失敗)。 - アダプタが**プールの約定価格**で評価する(mark = probe サイズの両側 mid / realizable = 自分サイズの - get_dy と、債務は get_dx で買い戻しコスト)。gas compensation 200 eUSD は借り手の負債ではないので差し引く。 - ICR<100% の Trove は 0 で clamp(担保を捨てて歩き去れる = CDP の実際の性質) +- **eUSD は TOKENS レジストリに入れない**……**だったが issue #27 (b) で昇格した**。外していた理由は + 「レジストリが stable を $1 で値付ける」だけで、それが消えたため。今は**市場価格 stable**(下の節)で、 + 価格の所有者は共通 probe = `sdk/src/stables.ts`。**spot の eUSD 残高は scorer の spot 掃引が値付け、 + liquity アダプタは値付けない**(二重計上の回避)。アダプタに残るのは Trove と Stability Pool で、 + realizable は自分サイズの get_dy、債務は get_dx で買い戻しコスト。gas compensation 200 eUSD は + 借り手の負債ではないので差し引く。ICR<100% の Trove は 0 で clamp(担保を捨てて歩き去れる = CDP の + 実際の性質) - **担保は native ETH**(core が `msg.value` で受ける)。action 側は WETH wei 建てで、`buildTxs` が `WETH.withdraw` を前置する。ただし**ガスと同じ残高**なので、全部突っ込むと閉じる tx すら送れなくなる。 observation に `ethBalanceWei` / `suggestedGasReserveWei` を出すが**強制はしない**(self-stranding は正当な負け) @@ -227,6 +229,60 @@ ours なのは 2 つだけ(core は無改変): 両方積め」と書いているが、その理由(既定ロスターが prompt モード = LLM が毎判断する)は ADR 0018 で 消えている。今の prompt.md は改訂方針であって毎判断プロンプトではない +### 市場価格 stable(レジストリの stable を $1 断定でなく市場から値付ける。issue #27) + +**「stable = $1」はコードがそう書いていたから**だった。`chain.ts` が active stable を全部足して +`usdcUnits` 1 本に潰し、`valuation.ts` が `kind === "stable"` を無条件に 1 と値付けていたので、 +デペグした stable も par で採点されていた(#39 が eUSD をレジストリの**外**に置いて避けていた +phantom value そのもの)。issue #27 でこれを 3 段階で外した: + +1. **観測に内訳を出す** — `obs.balances.stables[] = {token, decimals, balance, priceUsdc, + marketQuoted}`。`marketQuoted: false` は「市場が答えなかったので par を仮置きした」で、 + **`priceUsdc: 1` を「ペグが保たれている」と読んではいけない** +2. **`usdcUnits` を native USDC だけに narrow** — 9 箇所の参加者向け用途は全部**予算**であって評価では + ない(評価は `inventory.valueUsdc`)。合計値は予算として元々間違っていた(USDT は USDC プールで + 使えないし、funding は stable ごとに同額を配るので実際に使える額の約 2 倍を表示していた) +3. **market から値付ける**(`sdk/src/stables.ts`)— **両側の executable probe の幾何平均** + `sqrt(sell × buy)`(片側だけだと売り側に張り付いて過小評価する。LST / Liquity と同じ規律)。 + 両側とも固定 notional なので**1 stage で済み**、採点断面の 1 multicall に相乗りできる。 + quote が返らなければ **par に落として `par-fallback` で報告**(黙って par が最悪、黙って 0 は + 「100% ディスカウント = 無限の裁定」に読めてもっと悪い) + +- **USDC は numéraire で $1 固定**(issue #27 "Settled")。全 metric が USDC 建てなので、ここを + 浮かせると過去 run の数字の意味が変わる。`marketPricedStables()` は USDC の leg を無視する +- **market を持つ stable は funding で配らない**(`fundWallet` は par stable にだけ配る)。cheatcode で + eUSD を湧かせるのは Trove が発行していない stablecoin を流通させることだし、これから割れる stable を + 全員に配ると損が「誰も選んでいないポジションの β」になる。**買って初めて持てる**のがこの regime の要 +- **α でも live mark**(base の fair と違い、peg の乖離は protocol が強制する価格に対する dislocation で、 + それを閉じるのが venue の存在理由。固定参照で評価すると測りたいものが打ち消える) +- `STABLE_MARKET_LEGS`(`sdk/src/constants.ts`)が「どの stable がどのプールで値付くか」の単一ソース。 + leg は `venue` を持ち、**その protocol が有効な run にだけ**その stable が入る(sweep されるが取引 + できない stable は無い方がまし)。eUSD → `liquity` / DAI → `curve` +- **eUSD はレジストリに昇格**((b))。#39 が外していた理由(レジストリが stable を par で値付ける)は + 消えたので、**価格の所有権を移した**(二重計上の回避 = `TokenKind: "lst"` と同じ論点)。 + liquity アダプタは spot eUSD 残高を**もう値付けない**(scorer の spot sweep が値付ける)。Trove の + 債務と Stability Pool 預入は venue のものとして残り、価格は `ctx.stablePrices()` から読む +- **DAI が 2 つ目の市場価格 stable**((c))。deployer の USDC/DAI stableswap-ng plain pool(100k/100k)を + 使う。**A は 2000 → 100**(#39 と同じ較正: A=2000 だと半分売っても 4.4bps しか動かず、永久に + コストを超えない)。eUSD と違い**償還フロアが無い**ので、ディスカウントは「戻ると信じるかどうか」で + あって行使できる請求権ではない = 別のスキル +- **`stableSwap` action**(curve アダプタ所有。プールが Curve stableswap-ng だから)— + `{type, stable, tokenIn, amountIn, slippageBps?}`。無いとデペグは「見えるだけ」になる + (#39 が `liquitySwapEusd` を足したのと同じ理由)。**per-round 上限は USDC の 6 decimals 建てなので + 18 decimals の stable では換算が要る**(実測でこれを忘れると sell が毎回 reject され、買いだけ通って + 「閉じられないポジションの含み益」になる: 42 reject / 6 accept) +- **`depeg` ストレスイベント**(`stress.events`。`stable:` 必須)— 環境が窓の間だけその stable を + プールへ売り、閉じたら買い戻す。機構は `core/src/realtime/stableDepeg.ts` に共通化してあり、 + `eusdDepeg` も同じ実装を通る(イベント名は #39 の `stress_eusd_depeg*` のまま。他の stable は + `stress_depeg*` + payload の `stable`)。**毎ブロック目標へ reconcile**(一撃だと dropped block で + 取り残される)で、売却量はチェーンから読み直す(revert しても窓がずれない) +- Aave の aggregator にも伝播する(`sdk/src/protocols/oracles.ts`。3 経路すべて)。ただし + **今どの market-priced stable も Aave reserve ではない**ので現状は no-op で、listing した日に効く +- レジームは `config/regimes/depeg.yaml`(公式セット入り = ADR 0017 の 7 本目)、参照 agent は + `example/agents/peg-arb/`。実測(seed 701): 環境が depth の 59% を売って最大 89.5bps のディスカウント、 + peg-arb +139.6 / peg-arb-eager +195.7 / noop 0。**買い手が反対側を取るのでペグは戻るとき行き過ぎる** + (実測 −143bps = par 超え) + 実時間化(ADR 0005)の前提: **SEED(=regime) は市場条件のラベル**で価格パスは再現可能だが、tx タイミング/着順は非決定 → 同一 regime でも結果はぶれる。run 長は `ERIS_RUN_BLOCKS` 固定で揃える。run の比較が要るときは同一 config を複数回回してサンプルを貯め、`runs//summary.json` を集計する(旧 evaluate/gate は撤去済み)。 ## アーキテクチャ(環境とエージェント実行の分離。ADR 0006 / ADR 0015) diff --git a/config/regimes/depeg.yaml b/config/regimes/depeg.yaml new file mode 100644 index 0000000..3ece079 --- /dev/null +++ b/config/regimes/depeg.yaml @@ -0,0 +1,87 @@ +# config/regimes/depeg.yaml — a registry stable that stops being a dollar (issue #27). ADR 0016 §2 +# +# Run: npm run backtest -- --regime depeg --seed 701 [--agents ] +# +# What this regime tests that the others do not: an asset whose price the scorer used to assert. +# Every stable in the registry was worth $1 because the code said so, which meant no strategy could +# be right or wrong about one. Since issue #27 a stable with a market is marked at what that market +# pays, in the wallet and in an LP leg alike -- so holding the wrong dollar costs something, and +# buying the discount is a position with a real downside rather than free money. +# +# It is deliberately not the liquity regime with a different token. eUSD's discount is bounded by a +# redemption a CDP will always honour, so the trade is a claim you can enforce. DAI here has no +# floor at all: the only reason to expect par to return is that the dislocation is a window rather +# than a repricing, and an agent still holding at the last block is marked at the pool, not at par. +# The skill being measured is the difference between those two situations. +# +# Funding stays USDC-only (ADR 0017 §4), and note that this now means it literally: fundWallet +# grants the endowment to par stables only, so nobody starts holding DAI. Every unit of exposure to +# the depeg is a unit somebody chose to buy. + +run: + blocks: 120 # the depeg window plus enough afterwards for the peg to come back + seconds: 900 # a stress run ends by block count (ADR 0009) + blockTimeSec: 2 # fixed to the regime (ADR 0016 §2) + # curve is not optional: it owns the stableSwap action, which is the only way to trade the pair. + protocols: [uniswap, balancer, curve, aave] + economicGas: false + localDeploy: true # the DAI/USDC pool is one the deployer seeded; a fork has no such thing + reportDir: ./runs + +funding: + wethWei: "0" # USDC-only: netPnlUsdc carries β, so nobody may start already exposed + usdcUnits: "25000000000" + +limits: + agentWethWei: "1000000000000000000" + agentUsdcUnits: "5000000000" + +flow: + uninformedMaxWethWei: "1000000000000000000" + informedMaxWethWei: "2000000000000000000" + balancerMaxWethWei: "1000000000000000000" + curveMaxWethWei: "1000000000000000000" + informedArbFeeBps: 30 + uninformedArrivalRate: "0.9" + uninformedSizeSigma: "1.0" + # There is no DAI flow bot. The pair sits at par unless the event below pushes it or an agent + # trades it, which is the same arrangement the liquity regime uses and for the same reason: an + # emergent depeg would make the regime's character a function of who entered. + +stress: + events: + # magnitude is the fraction of the pool's seeded DAI depth the environment has sold at the top + # of the trapezoid. The pool is 100k/100k at A=100, the same shape #39 calibrated the eUSD pool + # to, so the measured curve carries over: ~40% sold is ~114bps below par, 50% is ~175bps, 60% + # is ~282bps. + # + # The cost to clear is lower here than in the liquity regime -- there is no 50bps redemption + # fee, only the pool's 0.01% on each leg -- but so is the certainty, because nothing forces the + # price back except the window closing. The range spans "barely worth it" to "obviously worth + # it" on purpose, and the ramp passes through every level in between where waiting is right. + - { + type: depeg, + stable: DAI, + magnitudeRange: [0.35, 0.6], + windowFrac: [0.25, 0.6], + rampBlocks: 4, + holdBlocks: 12, + decayBlocks: 15, + } + +agents: + - id: noop + wallet: AGENT1_PRIVATE_KEY + baseline: true + description: does nothing (baseline) + - id: peg-arb + wallet: AGENT2_PRIVATE_KEY + description: buys DAI below par and sells it back as the peg recovers + - id: peg-arb-eager + dir: peg-arb + wallet: AGENT3_PRIVATE_KEY + description: the same strategy that buys a shallower discount and holds for par + env: { ERIS_PEG_ARB_BUY_BPS: "10", ERIS_PEG_ARB_SELL_BPS: "0" } + - id: venue-arb + wallet: AGENT4_PRIVATE_KEY + description: WETH-only cross-venue arbitrage (keeps the AMM venues honest) diff --git a/config/scenarios/public.yaml b/config/scenarios/public.yaml index c0bad35..7d3c4d0 100644 --- a/config/scenarios/public.yaml +++ b/config/scenarios/public.yaml @@ -14,13 +14,18 @@ # # Run: npm run backtest -- --scenarios config/scenarios/public.yaml --agents -# Six of the seven regimes ADR 0017 lists. The seventh, depeg, waits on issue #39 (the CDP venue that -# brings the stable to break) and then #27 -- not on the scoring rework, since both keep USDC as the -# $1 quote unit. +# All seven regimes ADR 0017 lists. `depeg` was the last one outstanding: it needed a stable the +# scorer would actually mark down, which issue #27 delivered by pricing registry stables from their +# market instead of asserting $1. USDC stays the $1 quote unit, so the scoring rework (#56) was +# never a dependency. # `crash` carries both halves (issue #52: the gap and the liquidity withdrawal, across uniswap, # balancer and curve), and `lending-incident` thins its books on the crash window too. Measured at # a 50% pull, the cost of taking 10 WETH roughly doubles on uniswap/balancer and quadruples on # curve, while the price a small trade sees moves <=0.1bps. +# +# Note that adding depeg moves every standing: the z-score is taken within a regime and then +# averaged across them (ADR 0017 §3), so a seventh regime redistributes the weights. Comparisons +# against a matrix run before this one have to be re-run rather than read across. regimes: - calm - cex-drift @@ -28,5 +33,6 @@ regimes: - whale - lending-incident - crash + - depeg seeds: [101, 202, 303, 404, 505] diff --git a/core/src/coordinator.ts b/core/src/coordinator.ts index deb9109..591432e 100644 --- a/core/src/coordinator.ts +++ b/core/src/coordinator.ts @@ -6,11 +6,25 @@ import type { Address, Hex } from "viem"; import { accountAddress, getBalances } from "@eris/sdk/chain.js"; import type { ProtocolId, RawTxIntent, TxIntent } from "@eris/sdk/types.js"; import { baseTokens } from "@eris/sdk/markets.js"; -import { enabledAdapters } from "@eris/sdk/protocols/registry.js"; +import { enabledAdapters, getAdapter } from "@eris/sdk/protocols/registry.js"; import type { FlowKind, SimContext } from "@eris/sdk/protocols/types.js"; import { FlowProcess, type FlowOrderWire } from "./flowProcess.js"; import type { FlowContextWire } from "./flow/logic.js"; import { readAaveFlowReserves } from "@eris/sdk/protocols/aave.js"; +import { stableBalanceOf, TOKENS } from "@eris/sdk/constants.js"; + +// The stable a venue actually trades against. usdcUnits used to be every stable summed, which was a +// serviceable stand-in for it; since issue #27 narrowed that field to native USDC, a flow bot on +// Balancer's USDC.e leg would have sized against a balance it was not spending. +function venueStableUnits( + protocol: ProtocolId, + balances: Parameters[0], +): bigint { + return stableBalanceOf( + balances, + getAdapter(protocol).stableToken ?? TOKENS.USDC.address, + ); +} // --------------------------------------------------------------------------- // observation / flow / submit @@ -51,7 +65,7 @@ export async function buildFlowContext( wethSupplied: r.wethSupplied.toString(), usdcBorrowed: r.usdcBorrowed.toString(), wethWei: b.wethWei.toString(), - usdcUnits: b.usdcUnits.toString(), + usdcUnits: venueStableUnits("aave", b).toString(), }); } } @@ -62,7 +76,7 @@ export async function buildFlowContext( const b = await getBalances(ctx.publicClient, wallet.address); flowBalances[`${protocol}:${kind}`] = { wethWei: b.wethWei.toString(), - usdcUnits: b.usdcUnits.toString(), + usdcUnits: venueStableUnits(protocol, b).toString(), }; } } diff --git a/core/src/realtime/coordinator.ts b/core/src/realtime/coordinator.ts index e49a529..4026ed6 100644 --- a/core/src/realtime/coordinator.ts +++ b/core/src/realtime/coordinator.ts @@ -16,6 +16,10 @@ import { } from "@eris/sdk/chain.js"; import { RunLogger } from "../logger.js"; import { valueUsdc } from "@eris/sdk/pnl.js"; +import { + marketPricedStables, + readStablePrices, +} from "@eris/sdk/stables.js"; import { checkRunFeeViolations, countRunRevertedTxs } from "../postRunCheck.js"; import { nextFairPrice, priceRngForAsset, Rng } from "@eris/sdk/rng.js"; import type { @@ -40,6 +44,7 @@ import { import { DEFAULT_ANVIL_PRIVATE_KEYS, GMX_MARKETS, + TOKENS, } from "@eris/sdk/constants.js"; import { baseTokens, @@ -91,13 +96,16 @@ import { import { liquityBlockEvent, watchLiquityEvents, - reconcileEusdDepeg, - restoreEusdDepeg, setupEusdDepeg, setupLiquity, - type EusdDepegRuntime, type LiquityRuntime, } from "./liquity.js"; +import { + reconcileStableDepeg, + restoreStableDepeg, + setupStableDepeg, + type StableDepegRuntime, +} from "./stableDepeg.js"; import { PULL_VENUES } from "./liquidityVenues.js"; import type { LstState } from "@eris/sdk/protocols/lst.js"; import type { LiquityState } from "@eris/sdk/protocols/liquity.js"; @@ -714,7 +722,11 @@ export async function runRealtimeSimulation( // senders on one key race on the nonce — the failure mode that once froze the LST redemption // rate for a whole run. Checked once for both events, since they share the key. const deployerPk = DEFAULT_ANVIL_PRIVATE_KEYS[0]; - if (schedule.hasLiquidityPull() || schedule.hasEusdDepeg()) { + if ( + schedule.hasLiquidityPull() || + schedule.hasEusdDepeg() || + schedule.depegStables().length > 0 + ) { const clash = agentRuntimes.find( (a) => a.privateKey.toLowerCase() === deployerPk.toLowerCase(), ); @@ -727,10 +739,17 @@ export async function runRealtimeSimulation( } } - // ---- eUSD depeg stress event (issue #39): stage the account that will push the peg off par. - // Without it the CDP venue's redemption arb has nothing to trade -- the pool is seeded at par by - // construction -- so a regime that wants the venue exercised has to ask for this event. - let eusdDepegRuntime: EusdDepegRuntime | null = null; + // ---- depeg stress events (issues #39 and #27 (c)): stage the accounts that will push each + // peg off par. Without one, a stable's market sits at par by construction and there is nothing + // to trade -- so a regime that wants the peg exercised has to ask for the event. + // + // One list rather than one variable: a run can depeg eUSD and a plain stable in the same window, + // and they differ only in which pool and which float. + const depegRuntimes: Array<{ + runtime: StableDepegRuntime; + fractionAt: (blockIndex: number) => number; + ownerId: string; + }> = []; if (schedule.hasEusdDepeg()) { if (!liquityRuntime) { throw new Error( @@ -738,15 +757,62 @@ export async function runRealtimeSimulation( "venue whose stablecoin the event depegs)", ); } - eusdDepegRuntime = await setupEusdDepeg( - ctx, - { localDeploy: config.localDeploy, actorPk: deployerPk }, - logger, - ); - // Its swaps are environment transactions, like the oracle writes: attributing them to a - // participant would put them through the post-run fee check (core/src/postRunCheck.ts). - ownerByAddress.set(eusdDepegRuntime.actor.toLowerCase(), { + depegRuntimes.push({ + runtime: await setupEusdDepeg( + ctx, + { localDeploy: config.localDeploy, actorPk: deployerPk }, + logger, + ), + fractionAt: (i) => schedule.eusdDepegFractionAt(i), ownerId: "liquity-depeg", + }); + } + for (const symbol of schedule.depegStables()) { + if (!config.localDeploy) { + throw new Error( + "stress event depeg requires run.localDeploy: the environment sells into a pool it seeded, " + + "and a fork has no such pool (issue #27 (c))", + ); + } + const market = marketPricedStables().find((m) => m.symbol === symbol); + if (!market) { + throw new Error( + `stress event depeg targets "${symbol}", which this deployment does not price from a ` + + `market (known: ${marketPricedStables().map((m) => m.symbol).join(", ") || "none"}). ` + + "A stable with no pool cannot be pushed off par -- and would be scored at $1 whatever " + + "the event did.", + ); + } + depegRuntimes.push({ + runtime: await setupStableDepeg( + ctx, + { + market: { + symbol: market.symbol, + stable: market.token, + quote: TOKENS.USDC.address, + pool: market.pool, + stableIndex: market.stableIndex, + quoteIndex: market.quoteIndex, + }, + label: "stress_depeg", + actorPk: deployerPk, + emptyInventoryHint: + `The deploy mints the initial ${symbol} supply to the deployer account and seeds only ` + + "part of it into the pool, so an empty balance means a different account deployed the " + + "tokens, or a previous run spent it.", + }, + logger, + ), + fractionAt: (i) => schedule.depegFractionAt(symbol, i), + ownerId: `depeg-${symbol.toLowerCase()}`, + }); + } + // Their swaps are environment transactions, like the oracle writes: attributing them to a + // participant would put them through the post-run fee check (core/src/postRunCheck.ts). + for (const { runtime, ownerId } of depegRuntimes) { + ownerByAddress.set(runtime.actor.toLowerCase(), { + ownerId, role: "system", }); } @@ -1480,32 +1546,36 @@ export async function runRealtimeSimulation( // deployer key rather than the admin key -- and sequential with it inside that key, which // the two being separate awaited tasks does not guarantee, so they share one task here. const depegTask = async (): Promise => { - if (!eusdDepegRuntime) return; - try { - const hashes = await reconcileEusdDepeg( - ctx, - eusdDepegRuntime, - schedule, - blockIndex, - bn, - { priorityFeeWei: oracleFee }, - logger, - ); - for (const hash of hashes) { - submittedByHash.set(hash.toLowerCase(), { - ownerId: "liquity-depeg", - role: "system", - priorityFeeWei: oracleFee, - actionType: "eusdDepeg", + // Sequential across stables as well as with the pull: they all send from the deployer + // key, and two senders on one key race on the nonce. + for (const { runtime, fractionAt, ownerId } of depegRuntimes) { + try { + const hashes = await reconcileStableDepeg( + ctx, + runtime, + fractionAt(blockIndex), + blockIndex, + bn, + { priorityFeeWei: oracleFee }, + logger, + ); + for (const hash of hashes) { + submittedByHash.set(hash.toLowerCase(), { + ownerId, + role: "system", + priorityFeeWei: oracleFee, + actionType: "depeg", + }); + } + } catch (error) { + logger.event({ + type: `${runtime.label}_task_failed`, + stable: runtime.symbol, + blockIndex, + blockNumber: bn, + error: error instanceof Error ? error.message : String(error), }); } - } catch (error) { - logger.event({ - type: "stress_eusd_depeg_task_failed", - blockIndex, - blockNumber: bn, - error: error instanceof Error ? error.message : String(error), - }); } }; @@ -1543,7 +1613,7 @@ export async function runRealtimeSimulation( if (stressVictims.length > 0) tasks.push(timed(victimTask)); if (vulnRuntime) tasks.push(timed(vulnTask)); if (liquidityPullRuntime) tasks.push(timed(liquidityTask)); - if (eusdDepegRuntime) tasks.push(timed(depegTask)); + if (depegRuntimes.length > 0) tasks.push(timed(depegTask)); if (liquityRuntime) tasks.push(timed(liquityWatchTask)); const results = await Promise.all(tasks); const [keeperMs, oracleMs, stateFlowMs] = results; @@ -1554,7 +1624,8 @@ export async function runRealtimeSimulation( const liquidityMs = liquidityPullRuntime ? results[taskIdx++] : undefined; - const depegMs = eusdDepegRuntime ? results[taskIdx++] : undefined; + const depegMs = + depegRuntimes.length > 0 ? results[taskIdx++] : undefined; const liquityMs = liquityRuntime ? results[taskIdx++] : undefined; logger.event({ type: "round_timing", @@ -1620,12 +1691,13 @@ export async function runRealtimeSimulation( // ---- eUSD depeg teardown (issue #39): same argument as the depth restore above, plus one more. // The startup check refuses to begin on a depegged pool, so a run that ended mid-window would // not just hand the next run a different venue -- it would stop it from starting at all. - if (eusdDepegRuntime) { + for (const { runtime } of depegRuntimes) { try { - await restoreEusdDepeg(ctx, eusdDepegRuntime, logger); + await restoreStableDepeg(ctx, runtime, logger); } catch (error) { logger.event({ - type: "stress_eusd_depeg_teardown_failed", + type: `${runtime.label}_teardown_failed`, + stable: runtime.symbol, error: error instanceof Error ? error.message : String(error), }); } @@ -1731,11 +1803,24 @@ export async function runRealtimeSimulation( ctx.fairPrices && Object.keys(ctx.fairPrices).length > 0 ? ctx.fairPrices : { WETH: finalFairPrice }; + // Issue #27: what each market-priced stable settles at, at the last block. The initial + // valuation uses the same prices for the same reason the fair prices are shared -- netPnlUsdc is + // a difference, and pricing the two ends off different marks would book a peg's whole history + // as this agent's PnL. Nothing is endowed in a market-priced stable, so the initial snapshot + // holds none of them and the choice only bites on a run that ends mid-depeg. + const finalStablePrices = await readStablePrices( + publicClient, + activeStables(), + ); const agentsSummary = []; for (const agent of agentRuntimes) { const final = await getBalances(publicClient, agent.address); - const initialValue = valueUsdc(agent.initial, finalFairPrices); - let finalValue = valueUsdc(final, finalFairPrices); + const initialValue = valueUsdc( + agent.initial, + finalFairPrices, + finalStablePrices, + ); + let finalValue = valueUsdc(final, finalFairPrices, finalStablePrices); const protocolValues: Record = {}; for (const adapter of adapters) { const v = await adapter.valueUsdc( diff --git a/core/src/realtime/events.ts b/core/src/realtime/events.ts index de6d9de..7c926a5 100644 --- a/core/src/realtime/events.ts +++ b/core/src/realtime/events.ts @@ -35,6 +35,12 @@ import type { TokenSymbol } from "@eris/sdk/types.js"; // seeded eUSD depth the environment has dumped, and the resulting discount is // whatever the stableswap curve gives. It is what puts the CDP venue's redemption // arb on the table -- at par there is nothing there to trade, by construction. +// depeg the same mechanism for any other market-priced registry stable, named by +// `stable:` (issue #27 (c)). It is a separate type only because eusdDepeg needs no +// `stable` and predates it; the runtime is shared. The trade it opens is a +// different one: eUSD has a redemption floor a CDP enforces, and a plain stable +// has only the belief that it is a dollar, so closing the gap is an opinion rather +// than a claim on collateral. // They share this config section because from a run's point of view they are the same thing: a // seed-placed shock the agents have to survive. export type StressEventType = @@ -43,7 +49,8 @@ export type StressEventType = | "lstSlash" | "whale" | "liquidityPull" - | "eusdDepeg"; + | "eusdDepeg" + | "depeg"; // How the run consumes each type: // overlay a multiplier layered on the fair price every block of its window (`at()`) @@ -61,6 +68,7 @@ const EVENT_KIND: Record = { whale: "point", liquidityPull: "state", eusdDepeg: "state", + depeg: "state", }; const isPointEvent = (type: StressEventType): boolean => @@ -88,6 +96,9 @@ export type StressEventConfig = { // rather than a fraction because what matters is the size against pool depth, and depth is a // property of the deployed venue, not of this config. magnitudeRange: [number, number]; + // depeg only: which registry stable is pushed off par. Must be one the deployment gave a market + // (constants' STABLE_MARKET_LEGS), or the coordinator has no pool to sell into. + stable?: TokenSymbol; // whale only: which way it trades. Default "random" = the seed decides. side?: WhaleSide; // whale: the venue it prints on. Default "uniswap" (the deepest pool, so the size has to be real @@ -120,6 +131,8 @@ const POINT_EVENT_SPAN = 1; export type ResolvedStressEvent = { type: StressEventType; base: string; // target base (default WETH) + // depeg only: the stable being pushed off par. + stable?: string; magnitude: number; // whale only: resolved from config.side, with "random" collapsed to a concrete side by the seed. side?: "buy" | "sell"; @@ -203,6 +216,7 @@ export class EventSchedule { return { type: c.type, base: c.base ?? "WETH", + ...(c.stable !== undefined ? { stable: c.stable } : {}), magnitude, ...(side !== undefined ? { side } : {}), ...(c.type === "whale" ? { venue: c.venue ?? "uniswap" } : {}), @@ -278,6 +292,31 @@ export class EventSchedule { return this.events.some((ev) => ev.type === "eusdDepeg"); } + // The registry stables a `depeg` event targets (issue #27 (c)). The coordinator needs them at + // setup, before any window opens, to fail fast on a stable it has no market for. eUSD is not + // here: it has its own type and its own actor. + depegStables(): string[] { + const seen = new Set(); + for (const ev of this.events) { + if (ev.type !== "depeg" || !ev.stable) continue; + seen.add(ev.stable); + } + return [...seen]; + } + + // Fraction of a stable's seeded pool depth the environment should have sold by this block. Same + // shape and same reasoning as eusdDepegFractionAt; separate only because it is per-stable. + depegFractionAt(stable: string, blockIndex: number): number { + let sold = 0; + for (const ev of this.events) { + if (ev.type !== "depeg" || ev.stable !== stable) continue; + const e = envelope(ev, blockIndex); + if (e === 0) continue; + sold += ev.magnitude * e; + } + return sold; + } + // Fraction of the eUSD/USDC pool's seeded eUSD depth the environment should have sold by this // block (0 = none). Reconciled against rather than applied once, for the same reason as the depth // multiplier: the target is a pure function of the block index, so a dropped block notification @@ -432,12 +471,23 @@ function parseOne(raw: unknown, i: number): StressEventConfig { o.type !== "lstSlash" && o.type !== "whale" && o.type !== "liquidityPull" && - o.type !== "eusdDepeg" + o.type !== "eusdDepeg" && + o.type !== "depeg" ) { throw new Error( - `${label}.type must be "spike", "crash", "lstSlash", "whale", "liquidityPull" or "eusdDepeg"`, + `${label}.type must be "spike", "crash", "lstSlash", "whale", "liquidityPull", "eusdDepeg" or "depeg"`, ); } + if (o.type === "depeg") { + // Which stable is not a default anyone could guess: a run can have several, and picking one + // silently would make the regime depend on registry order. + if (typeof o.stable !== "string" || o.stable.length === 0) + throw new Error( + `${label}.stable is required for type "depeg" (the registry stable to push off par)`, + ); + } else if (o.stable !== undefined) { + throw new Error(`${label}.stable only applies to type "depeg"`); + } if (o.alignWith !== undefined) { if ( o.alignWith !== "spike" && @@ -445,7 +495,8 @@ function parseOne(raw: unknown, i: number): StressEventConfig { o.alignWith !== "lstSlash" && o.alignWith !== "whale" && o.alignWith !== "liquidityPull" && - o.alignWith !== "eusdDepeg" + o.alignWith !== "eusdDepeg" && + o.alignWith !== "depeg" ) { throw new Error(`${label}.alignWith must be a stress event type`); } @@ -495,7 +546,8 @@ function parseOne(raw: unknown, i: number): StressEventConfig { // nothing to buy, so the discount stops being a price and becomes an outage. ...(o.type === "lstSlash" || o.type === "liquidityPull" || - o.type === "eusdDepeg" + o.type === "eusdDepeg" || + o.type === "depeg" ? { max: 1, exclusiveMax: true } : {}), }, @@ -526,6 +578,7 @@ function parseOne(raw: unknown, i: number): StressEventConfig { return { type: o.type, base: typeof o.base === "string" ? o.base : undefined, + ...(typeof o.stable === "string" ? { stable: o.stable } : {}), ...(o.side !== undefined ? { side: o.side as WhaleSide } : {}), ...(o.venue !== undefined ? { venue: o.venue as "uniswap" | "balancer" | "curve" } diff --git a/core/src/realtime/liquity.ts b/core/src/realtime/liquity.ts index 75a9caa..2a37760 100644 --- a/core/src/realtime/liquity.ts +++ b/core/src/realtime/liquity.ts @@ -31,6 +31,10 @@ import { import type { SimContext } from "@eris/sdk/protocols/types.js"; import type { RunLogger } from "../logger.js"; import type { EventSchedule } from "./events.js"; +import { + setupStableDepeg, + type StableDepegRuntime, +} from "./stableDepeg.js"; // How far the oracle the venue serves may sit from the run's fair price before the run refuses to // start. This is not calibration noise: either the adapter points at this run's PriceFeed or it does @@ -291,31 +295,20 @@ export async function watchLiquityEvents( }); } } - // --------------------------------------------------------------------------- // eUSD depeg (the stress overlay's eusdDepeg, issue #39) +// +// The mechanism is stable-agnostic and lives in stableDepeg.ts, because issue #27 (c) needed the +// same thing for a second stable. What stays here is the part that is genuinely about this venue: +// which account holds the float, and what to tell an operator when it does not. // --------------------------------------------------------------------------- -export type EusdDepegRuntime = { - actor: Address; - actorPk: Hex; - pool: Address; - eusdIndex: number; - usdcIndex: number; - eusd: Address; - usdc: Address; - // The pool's eUSD depth at run start. The event's magnitude is a fraction of this, so the same - // config means the same imbalance whatever the deploy seeded. - seededPoolEusdWei: bigint; - // The actor's eUSD balance at run start, which bounds how far the peg can be pushed. - startEusdWei: bigint; - pending: { hash: Hex; blockIndex: number } | null; - // Whether the inventory limit has already been reported. Once is enough; it is a calibration - // finding, not a per-block event. - cappedReported: boolean; -}; +// #39 named these events before there was a second depeg. They keep their names so a run's +// diagnostics still line up with every measurement taken against them; other stables emit +// `stress_depeg` with the symbol in the payload. +export const EUSD_DEPEG_LABEL = "stress_eusd_depeg"; -/// Stage the account that will move the peg, and record what it has to work with. +/// Stage the account that will move the peg. /// /// The actor is the deployer, which is where the genesis Trove's eUSD ended up (issue #39 phase 1: /// LUSDToken has no admin mint, so every eUSD in existence came out of that Trove). It is not a @@ -324,7 +317,7 @@ export async function setupEusdDepeg( ctx: SimContext, opts: { localDeploy: boolean; actorPk: Hex }, logger: RunLogger, -): Promise { +): Promise { if (!opts.localDeploy) { throw new Error( "stress event eusdDepeg requires run.localDeploy: the liquity venue and its eUSD market exist " + @@ -333,358 +326,24 @@ export async function setupEusdDepeg( } const market = requireEusdMarket(); const l = LIQUITY!; - const actor = accountAddress(opts.actorPk); - - const [poolEusd, actorEusd, actorUsdc] = (await Promise.all([ - ctx.publicClient.readContract({ - address: market.pool, - abi: curveStableSwapNgAbi, - functionName: "balances", - args: [BigInt(market.eusdIndex)], - }), - ctx.publicClient.readContract({ - address: l.eusd, - abi: erc20Abi, - functionName: "balanceOf", - args: [actor], - }), - ctx.publicClient.readContract({ - address: market.stable, - abi: erc20Abi, - functionName: "balanceOf", - args: [actor], - }), - ])) as [bigint, bigint, bigint]; - - if (actorEusd === 0n) { - throw new Error( - `stress event eusdDepeg has nothing to sell: the actor (${actor}) holds no eUSD. The deploy ` + - "leaves the genesis Trove's surplus with the deployer account (deployer/src/protocols/liquity.ts), " + - "so an empty balance means a different account deployed the venue, or a previous run spent it.", - ); - } - - // The deploy approved the pool for exactly the amounts it seeded, so both legs need standing - // approval before the window opens. Sequential: one key, one nonce. - for (const token of [l.eusd, market.stable]) { - await sendAndMine( - ctx.publicClient, - ctx.walletClient, - ctx.chain, - opts.actorPk, - { - to: token, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: "approve", - args: [market.pool, maxUint256], - }), + return setupStableDepeg( + ctx, + { + market: { + symbol: "eUSD", + stable: l.eusd, + quote: market.stable, + pool: market.pool, + stableIndex: market.eusdIndex, + quoteIndex: market.usdcIndex, }, - ); - } - - logger.event({ - type: "stress_eusd_depeg_setup", - actor, - pool: market.pool, - poolEusdWei: poolEusd.toString(), - actorEusdWei: actorEusd.toString(), - actorUsdcUnits: actorUsdc.toString(), - // What fraction of the pool the actor could sell at most. Below the configured magnitude the - // window will simply be shallower than asked for, which the reconcile reports. - maxFractionOfPool: - poolEusd > 0n ? Number((actorEusd * 10_000n) / poolEusd) / 10_000 : 0, - }); - - return { - actor, - actorPk: opts.actorPk, - pool: market.pool, - eusdIndex: market.eusdIndex, - usdcIndex: market.usdcIndex, - eusd: l.eusd, - usdc: market.stable, - seededPoolEusdWei: poolEusd, - startEusdWei: actorEusd, - pending: null, - cappedReported: false, - }; -} - -/// Move the peg toward where the schedule wants it on this block. -/// -/// Every decision is made against the eUSD the actor *actually* still holds, read back each block, -/// rather than against what was submitted: a swap can revert (slippage, an empty float, an agent -/// arriving first in the same block) and a target derived from an assumed fill would then be wrong -/// for the rest of the window. -export async function reconcileEusdDepeg( - ctx: SimContext, - runtime: EusdDepegRuntime, - schedule: EventSchedule, - blockIndex: number, - blockNumber: number, - opts: { priorityFeeWei: bigint }, - logger: RunLogger, -): Promise { - const fraction = schedule.eusdDepegFractionAt(blockIndex); - - if (runtime.pending) { - const settled = await settlePending(ctx, runtime, blockIndex, logger); - if (!settled) return []; - } - - const balance = (await ctx.publicClient.readContract({ - address: runtime.eusd, - abi: erc20Abi, - functionName: "balanceOf", - args: [runtime.actor], - })) as bigint; - const sold = - runtime.startEusdWei > balance ? runtime.startEusdWei - balance : 0n; - - const asked = - (runtime.seededPoolEusdWei * BigInt(Math.round(fraction * 1e9))) / - 1_000_000_000n; - // Bounded by what the actor can still sell. A window that cannot reach its magnitude is a - // calibration finding, so it is reported rather than silently delivering a shallower depeg. - const target = asked > runtime.startEusdWei ? runtime.startEusdWei : asked; - if (target < asked && !runtime.cappedReported) { - runtime.cappedReported = true; - logger.event({ - type: "stress_eusd_depeg_capped", - blockIndex, - askedEusdWei: asked.toString(), - availableEusdWei: runtime.startEusdWei.toString(), - note: "the depeg is shallower than the configured magnitude: the actor's eUSD ran out", - }); - } - - if (target === sold) return []; - const delta = target > sold ? target - sold : sold - target; - const closing = target === 0n; - if ( - !closing && - (delta * 10_000n) / (runtime.seededPoolEusdWei || 1n) < MIN_DELTA_BPS - ) - return []; - - try { - const call = - target > sold - ? await buildSell(ctx, runtime, delta) - : await buildBuyBack(ctx, runtime, delta); - if (!call) return []; - const hash = await sendNoMine( - ctx.publicClient, - ctx.walletClient, - ctx.chain, - runtime.actorPk, - { to: call.to, data: call.data, gas: DEPEG_GAS }, - opts.priorityFeeWei, - ); - runtime.pending = { hash, blockIndex }; - logger.event({ - type: "stress_eusd_depeg", - blockIndex, - blockNumber, - direction: target > sold ? "sell" : "buyback", - targetFraction: Number(fraction.toFixed(4)), - targetSoldEusdWei: target.toString(), - soldEusdWei: sold.toString(), - deltaEusdWei: delta.toString(), - hash, - }); - return [hash]; - } catch (error) { - // `sold` is re-derived from the chain next block, so a failed send costs one block of lag - // rather than desynchronizing the window. - logger.event({ - type: "stress_eusd_depeg_failed", - blockIndex, - blockNumber, - targetSoldEusdWei: target.toString(), - error: error instanceof Error ? error.message : String(error), - }); - return []; - } -} - -async function buildSell( - ctx: SimContext, - runtime: EusdDepegRuntime, - amountEusd: bigint, -): Promise<{ to: Address; data: Hex } | null> { - const quoted = (await ctx.publicClient.readContract({ - address: runtime.pool, - abi: curveStableSwapNgAbi, - functionName: "get_dy", - args: [BigInt(runtime.eusdIndex), BigInt(runtime.usdcIndex), amountEusd], - })) as bigint; - if (quoted <= 0n) return null; - return { - to: runtime.pool, - data: encodeFunctionData({ - abi: curveStableSwapNgAbi, - functionName: "exchange", - args: [ - BigInt(runtime.eusdIndex), - BigInt(runtime.usdcIndex), - amountEusd, - (quoted * (10_000n - DEPEG_SLIPPAGE_BPS)) / 10_000n, - ], - }), - }; -} - -/// The buy-back leg sizes on the *output*: the target is an amount of eUSD to take back off the -/// market, not an amount of USDC to spend, so it is get_dx rather than get_dy. Spending the USDC the -/// sale produced would come up short by exactly the round trip's cost and leave the peg permanently -/// a little broken. -async function buildBuyBack( - ctx: SimContext, - runtime: EusdDepegRuntime, - amountEusd: bigint, -): Promise<{ to: Address; data: Hex } | null> { - const [needed, usdcBalance] = (await Promise.all([ - ctx.publicClient.readContract({ - address: runtime.pool, - abi: curveStableSwapNgAbi, - functionName: "get_dx", - args: [BigInt(runtime.usdcIndex), BigInt(runtime.eusdIndex), amountEusd], - }), - ctx.publicClient.readContract({ - address: runtime.usdc, - abi: erc20Abi, - functionName: "balanceOf", - args: [runtime.actor], - }), - ])) as [bigint, bigint]; - const spend = needed > usdcBalance ? usdcBalance : needed; - if (spend <= 0n) return null; - const quoted = (await ctx.publicClient.readContract({ - address: runtime.pool, - abi: curveStableSwapNgAbi, - functionName: "get_dy", - args: [BigInt(runtime.usdcIndex), BigInt(runtime.eusdIndex), spend], - })) as bigint; - if (quoted <= 0n) return null; - return { - to: runtime.pool, - data: encodeFunctionData({ - abi: curveStableSwapNgAbi, - functionName: "exchange", - args: [ - BigInt(runtime.usdcIndex), - BigInt(runtime.eusdIndex), - spend, - (quoted * (10_000n - DEPEG_SLIPPAGE_BPS)) / 10_000n, - ], - }), - }; -} - -async function settlePending( - ctx: SimContext, - runtime: EusdDepegRuntime, - blockIndex: number, - logger: RunLogger, -): Promise { - const pending = runtime.pending; - if (!pending) return true; - let status: "success" | "reverted" | null = null; - try { - const receipt = await ctx.publicClient.getTransactionReceipt({ - hash: pending.hash, - }); - status = receipt.status === "success" ? "success" : "reverted"; - } catch { - status = null; - } - if (status === null) { - if (blockIndex - pending.blockIndex < PENDING_TIMEOUT_BLOCKS) return false; - logger.event({ - type: "stress_eusd_depeg_stuck", - blockIndex, - hash: pending.hash, - submittedAtBlockIndex: pending.blockIndex, - }); - } - if (status === "reverted") { - logger.event({ - type: "stress_eusd_depeg_reverted", - blockIndex, - hash: pending.hash, - }); - } - runtime.pending = null; - return true; -} - -/// Put the peg back before the run ends, whatever the schedule managed to do. -/// -/// The block loop can simply stop with a window still open (`EventSchedule` clamps the start so the -/// window can end on the last block, and a run can also end early on its time limit). Under the -/// scenario matrix the per-scenario revert would hide it, but a plain `sim:realtime` on a shared -/// anvil would hand the next run a permanently depegged stablecoin -- and the startup check above -/// would then refuse to start it. Mined rather than mempool: there is no next block to settle on. -export async function restoreEusdDepeg( - ctx: SimContext, - runtime: EusdDepegRuntime, - logger: RunLogger, -): Promise { - runtime.pending = null; - for (let attempt = 0; attempt < 3; attempt++) { - const balance = (await ctx.publicClient.readContract({ - address: runtime.eusd, - abi: erc20Abi, - functionName: "balanceOf", - args: [runtime.actor], - })) as bigint; - const sold = - runtime.startEusdWei > balance ? runtime.startEusdWei - balance : 0n; - if ((sold * 10_000n) / (runtime.seededPoolEusdWei || 1n) < MIN_DELTA_BPS) { - logger.event({ - type: "stress_eusd_depeg_restored", - phase: "teardown", - outstandingEusdWei: sold.toString(), - attempts: attempt, - }); - return; - } - try { - const call = await buildBuyBack(ctx, runtime, sold); - if (!call) break; - await sendAndMine( - ctx.publicClient, - ctx.walletClient, - ctx.chain, - runtime.actorPk, - { to: call.to, data: call.data }, - ); - } catch (error) { - logger.event({ - type: "stress_eusd_depeg_teardown_failed", - outstandingEusdWei: sold.toString(), - error: error instanceof Error ? error.message : String(error), - }); - break; - } - } - const balance = (await ctx.publicClient.readContract({ - address: runtime.eusd, - abi: erc20Abi, - functionName: "balanceOf", - args: [runtime.actor], - })) as bigint; - const outstanding = - runtime.startEusdWei > balance ? runtime.startEusdWei - balance : 0n; - logger.event({ - type: - (outstanding * 10_000n) / (runtime.seededPoolEusdWei || 1n) < - MIN_DELTA_BPS - ? "stress_eusd_depeg_restored" - : "stress_eusd_depeg_restore_incomplete", - phase: "teardown", - outstandingEusdWei: outstanding.toString(), - }); + label: EUSD_DEPEG_LABEL, + actorPk: opts.actorPk, + emptyInventoryHint: + "The deploy leaves the genesis Trove's surplus with the deployer account " + + "(deployer/src/protocols/liquity.ts), so an empty balance means a different account deployed " + + "the venue, or a previous run spent it.", + }, + logger, + ); } diff --git a/core/src/realtime/reconstruct.ts b/core/src/realtime/reconstruct.ts index afc4b85..bf559f3 100644 --- a/core/src/realtime/reconstruct.ts +++ b/core/src/realtime/reconstruct.ts @@ -20,6 +20,14 @@ import { MULTICALL3, TOKENS } from "@eris/sdk/constants.js"; import { baseTokens, marketsFor, tokenInfo } from "@eris/sdk/markets.js"; import type { RunLogger } from "../logger.js"; import { valueUsdc } from "@eris/sdk/pnl.js"; +import { + decodeStableProbes, + marketPricedStables, + PAR_STABLE_PRICES, + stableProbeReads, + type StableMarket, + type StablePrices, +} from "@eris/sdk/stables.js"; import { poolPriceUsdcPerWethFromSqrtX96 } from "@eris/sdk/protocols/uniswap.js"; import { getAdapter, hasAdapter } from "@eris/sdk/protocols/registry.js"; import type { @@ -282,6 +290,13 @@ export async function readValueSnapshotAtBlock(opts: { if (wethPool) head.push({ address: wethPool, abi: poolAbi, functionName: "slot0" }); + // Issue #27: what each market-priced stable is actually worth at this cross-section. Two reads + // per stable (both executable directions) and one owner for the answer -- every venue that names + // a stable leg reads it back off ctx rather than probing the same pool again. + const stableMarkets = marketPricedStables(activeStables); + const stableProbeBase = head.length; + head.push(...(stableProbeReads(stableMarkets) as MulticallContract[])); + // Per-agent spot reads, described once so that both the request and the decode below work off the // same list. A failed read can then name the holding it hid instead of decoding to zero (issue #44). const spotLayout: SpotRead[] = [ @@ -318,6 +333,7 @@ export async function readValueSnapshotAtBlock(opts: { const blockNumber = BigInt(opts.blockNumber); let headResults: unknown[] = []; let fairByBase: Record = {}; + let stablePrices: StablePrices = PAR_STABLE_PRICES; const ctx: ValuationContext = { publicClient, blockNumber: opts.blockNumber, @@ -325,6 +341,7 @@ export async function readValueSnapshotAtBlock(opts: { agents, activeStables, fairByBase: () => fairByBase, + stablePrices: () => stablePrices, }; const protocolValues = await runValuations({ runs: adaptersForIds(enabledIds) @@ -354,6 +371,17 @@ export async function readValueSnapshotAtBlock(opts: { extraBases.forEach((b, i) => { fairByBase[b] = fromPriceFeedAnswer((results[1 + i] as bigint) ?? 0n); }); + // A stable whose pool refuses to quote resolves to par here and is named in `unquoted`, which + // the per-agent decode below turns into a reported holding. Unlike a missing WETH fair this + // is not a reason to refuse the block: par is a defensible number, it just is not a measured + // one, and saying so is the whole point (issue #27). + stablePrices = decodeStableProbes( + stableMarkets, + results.slice( + stableProbeBase, + stableProbeBase + stableMarkets.length * 2, + ), + ); }, }); @@ -369,10 +397,15 @@ export async function readValueSnapshotAtBlock(opts: { const refFairByBase = opts.refFairByBase ?? fairByBase; const values: AgentValueSnapshot[] = []; const unpriced: UnpricedHolding[] = []; + // Stables the run priced from a market that would not quote at this block. Reported per agent + // that actually holds one, so the diagnostic names a value someone's number depends on. + const unquotedStables = new Map( + stablePrices.unquoted.map((m) => [m.token.toLowerCase(), m]), + ); agents.forEach((agent, i) => { const spotStart = spotBase + i * spotLayout.length; let ethWei = 0n; - let usdcUnits = 0n; + const stables: Record = {}; const bases: Record = {}; spotLayout.forEach((read, k) => { const raw = headResults[spotStart + k]; @@ -395,9 +428,24 @@ export async function readValueSnapshotAtBlock(opts: { } if (read.kind === "eth") ethWei = raw; else if (read.kind === "base") bases[read.symbol] = raw; - else usdcUnits += raw; + else stables[read.token.toLowerCase()] = raw; }); const wethWei = bases.WETH ?? 0n; + // Native USDC alone, matching getBalances since issue #27. The other stables are valued from + // the `stables` breakdown at their own prices rather than summed in here at face value. + const usdcUnits = stables[TOKENS.USDC.address.toLowerCase()] ?? 0n; + for (const [token, units] of Object.entries(stables)) { + const market = unquotedStables.get(token); + if (!market || units <= 0n) continue; + unpriced.push({ + agentId: agent.id, + source: `spot-${market.symbol}`, + token: market.token, + amountRaw: units.toString(), + reason: "par-fallback", + read: "CurveStableSwapNG.get_dy", + }); + } // A base the run never wrote a price for values at zero the same way an unreadable balance does, // so a holding of one is reported rather than quietly counted as nothing (WETH cannot land here: // a missing WETH fair already failed the block above). @@ -412,13 +460,19 @@ export async function readValueSnapshotAtBlock(opts: { }); } } - const balance = { ethWei, wethWei, usdcUnits, bases }; + const balance = { ethWei, wethWei, usdcUnits, bases, stables }; // Evaluate free inventory two ways: at live fair (β-inclusive) and at the fixed reference fair (β-removed). - let total = valueUsdc(balance, fairByBase); - let alphaTotal = valueUsdc(balance, refFairByBase); + // + // The stable leg is marked live in *both* (issue #27). Unlike a base's fair price, a stable's + // discount is not exogenous drift: it is a dislocation against a price the protocol itself + // enforces, and closing it is the trade the venue exists for. Evaluating it at a fixed reference + // would cancel exactly the thing being measured. Nothing is endowed in a market-priced stable + // (fundWallet grants only the par ones), so every unit of the exposure was chosen. + let total = valueUsdc(balance, fairByBase, stablePrices); + let alphaTotal = valueUsdc(balance, refFairByBase, stablePrices); // Free inventory is realizable by definition, so the liquidatable series starts from the same // live mark and only the venues diverge. - let liquidatableTotal = valueUsdc(balance, fairByBase); + let liquidatableTotal = valueUsdc(balance, fairByBase, stablePrices); // Protocol positions are a live mark in both evaluations (β removal applies to free inventory only). for (const [id, byAgent] of protocolValues) { const value = byAgent[agent.id]; @@ -758,10 +812,20 @@ export async function reconstructValueSeries(opts: { const unreadable = unpricedHoldings.filter( (h) => h.reason === "read-failed", ).length; + // par-fallback holdings are counted in the value, at $1, because their market would not quote + // (issue #27) -- reported for the opposite reason to the others, so they are counted apart. + const atPar = unpricedHoldings.filter( + (h) => h.reason === "par-fallback", + ).length; + const excluded = unpricedHoldings.length - atPar; console.warn( - `[reconstruct] ${unpricedHoldings.length} holding(s) excluded from agent value ` + - `(${unpricedHoldings.length - unreadable} unpriceable, ${unreadable} unreadable; ` + - "see scoring_unpriced_holdings in events.jsonl); a zero here is not a trading loss", + `[reconstruct] ${excluded} holding(s) excluded from agent value ` + + `(${excluded - unreadable} unpriceable, ${unreadable} unreadable)` + + (atPar > 0 + ? `, ${atPar} stable holding(s) marked at par because their market did not quote` + : "") + + "; see scoring_unpriced_holdings in events.jsonl — a zero here is not a trading loss, " + + "and a dollar is not a measurement", ); } diff --git a/core/src/realtime/stableDepeg.ts b/core/src/realtime/stableDepeg.ts new file mode 100644 index 0000000..cd94d31 --- /dev/null +++ b/core/src/realtime/stableDepeg.ts @@ -0,0 +1,458 @@ +// Pushing a stablecoin off its peg, and putting it back (issues #39 and #27 (c)). +// +// The environment sells a market-priced stable into its own USDC pool for the length of a window and +// buys it back afterwards. Everything here is stable-agnostic: it needs a stableswap-ng pool, the two +// coin indices, and an actor holding enough of the stable to move the price. eUSD (#39) and DAI +// (#27 (c)) both run through it, differing only in which account holds the float and what the events +// are called. +// +// Two decisions are worth keeping in view, because both were paid for once already. +// +// *Reconciled per block, not applied once.* The target is a pure function of the block index, so a +// dropped block costs a block of lag instead of leaving the peg stuck wherever it happened to be. +// `pointEventsAt` was written the other way and broke exactly there (issue #52's liquidity pull +// carries the same note). +// +// *Measured against the chain, not against what was submitted.* Every decision reads the actor's +// balance back. A swap can revert -- slippage, an empty float, an agent arriving first in the same +// block -- and a target derived from an assumed fill would then be wrong for the rest of the window. +// +// Why the environment injects the dislocation rather than letting one emerge: an emergent depeg +// depends on who entered the competition, since a redemption-heavy field would hold the peg and a +// passive one would let it collapse. That makes the regime's character a function of the roster, +// which breaks "the same market conditions in every scenario" (ADR 0009). The dislocation is +// injected; the *resolution* is left to the real mechanism -- redemption for eUSD, arbitrage for DAI. +import { encodeFunctionData, maxUint256, type Address, type Hex } from "viem"; +import { curveStableSwapNgAbi, erc20Abi } from "@eris/sdk/abis.js"; +import { accountAddress, sendAndMine, sendNoMine } from "@eris/sdk/chain.js"; +import type { SimContext } from "@eris/sdk/protocols/types.js"; +import type { RunLogger } from "../logger.js"; + +// Swaps against a stableswap pool are a fixed shape; pinning the gas skips an eth_estimateGas (a +// whole extra EVM execution) on a transaction the environment may send every block of a window. +const DEPEG_GAS = 600_000n; + +// Slippage bound on the environment's own depeg trades. It is not being protected from a bad price +// -- moving the price is the point -- only from a pathological fill. +const DEPEG_SLIPPAGE_BPS = 500n; + +// Deltas below this fraction of the pool's seeded depth are rounding, not schedule. Closing the +// window is exempt: leaving the peg broken would hand the rest of the run a different venue. +const MIN_DELTA_BPS = 50n; + +// Blocks to wait for a submitted swap before treating it as lost. Under interval mining a +// transaction lands on the next block, so this is slack for a busy block rather than a normal path. +const PENDING_TIMEOUT_BLOCKS = 3; + +// The pool a depeg pushes against, and who pushes it. +export type StableDepegMarket = { + // Registry symbol, carried into every event so a run with two depegs is readable. + symbol: string; + stable: Address; + quote: Address; + pool: Address; + stableIndex: number; + quoteIndex: number; +}; + +export type StableDepegRuntime = StableDepegMarket & { + // Event-name prefix. eUSD keeps `stress_eusd_depeg` from #39 so its diagnostics stay readable + // against past runs; everything else is `stress_depeg` with the symbol in the payload. + label: string; + actor: Address; + actorPk: Hex; + // The pool's depth in the stable at run start. The event's magnitude is a fraction of this, so the + // same config means the same imbalance whatever the deploy seeded. + seededPoolStableWei: bigint; + // The actor's balance at run start, which bounds how far the peg can be pushed. + startStableWei: bigint; + pending: { hash: Hex; blockIndex: number } | null; + // Whether the inventory limit has already been reported. Once is enough; it is a calibration + // finding, not a per-block event. + cappedReported: boolean; +}; + +/// Stage the account that will move the peg, and record what it has to work with. +/// +/// The actor is not a participant and is excluded from scoring, the same arrangement as the ADR 0009 +/// stress victims. +export async function setupStableDepeg( + ctx: SimContext, + opts: { + market: StableDepegMarket; + label: string; + actorPk: Hex; + // What to say when the actor holds none of the stable. Deployment-specific, and the difference + // between "redeploy" and "a previous run spent it" is exactly what the operator needs. + emptyInventoryHint: string; + }, + logger: RunLogger, +): Promise { + const { market, label } = opts; + const actor = accountAddress(opts.actorPk); + + const [poolStable, actorStable, actorQuote] = (await Promise.all([ + ctx.publicClient.readContract({ + address: market.pool, + abi: curveStableSwapNgAbi, + functionName: "balances", + args: [BigInt(market.stableIndex)], + }), + ctx.publicClient.readContract({ + address: market.stable, + abi: erc20Abi, + functionName: "balanceOf", + args: [actor], + }), + ctx.publicClient.readContract({ + address: market.quote, + abi: erc20Abi, + functionName: "balanceOf", + args: [actor], + }), + ])) as [bigint, bigint, bigint]; + + if (actorStable === 0n) { + throw new Error( + `stress event depeg has nothing to sell: the actor (${actor}) holds no ${market.symbol}. ` + + opts.emptyInventoryHint, + ); + } + + // The deploy approved the pool for exactly the amounts it seeded, so both legs need standing + // approval before the window opens. Sequential: one key, one nonce. + for (const token of [market.stable, market.quote]) { + await sendAndMine( + ctx.publicClient, + ctx.walletClient, + ctx.chain, + opts.actorPk, + { + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [market.pool, maxUint256], + }), + }, + ); + } + + logger.event({ + type: `${label}_setup`, + stable: market.symbol, + actor, + pool: market.pool, + poolStableWei: poolStable.toString(), + actorStableWei: actorStable.toString(), + actorQuoteUnits: actorQuote.toString(), + // What fraction of the pool the actor could sell at most. Below the configured magnitude the + // window will simply be shallower than asked for, which the reconcile reports. + maxFractionOfPool: + poolStable > 0n + ? Number((actorStable * 10_000n) / poolStable) / 10_000 + : 0, + }); + + return { + ...market, + label, + actor, + actorPk: opts.actorPk, + seededPoolStableWei: poolStable, + startStableWei: actorStable, + pending: null, + cappedReported: false, + }; +} + +/// Move the peg toward where the schedule wants it on this block. +export async function reconcileStableDepeg( + ctx: SimContext, + runtime: StableDepegRuntime, + fraction: number, + blockIndex: number, + blockNumber: number, + opts: { priorityFeeWei: bigint }, + logger: RunLogger, +): Promise { + if (runtime.pending) { + const settled = await settlePending(ctx, runtime, blockIndex, logger); + if (!settled) return []; + } + + const balance = (await ctx.publicClient.readContract({ + address: runtime.stable, + abi: erc20Abi, + functionName: "balanceOf", + args: [runtime.actor], + })) as bigint; + const sold = + runtime.startStableWei > balance ? runtime.startStableWei - balance : 0n; + + const asked = + (runtime.seededPoolStableWei * BigInt(Math.round(fraction * 1e9))) / + 1_000_000_000n; + // Bounded by what the actor can still sell. A window that cannot reach its magnitude is a + // calibration finding, so it is reported rather than silently delivering a shallower depeg. + const target = + asked > runtime.startStableWei ? runtime.startStableWei : asked; + if (target < asked && !runtime.cappedReported) { + runtime.cappedReported = true; + logger.event({ + type: `${runtime.label}_capped`, + stable: runtime.symbol, + blockIndex, + askedStableWei: asked.toString(), + availableStableWei: runtime.startStableWei.toString(), + note: `the depeg is shallower than the configured magnitude: the actor's ${runtime.symbol} ran out`, + }); + } + + if (target === sold) return []; + const delta = target > sold ? target - sold : sold - target; + const closing = target === 0n; + if ( + !closing && + (delta * 10_000n) / (runtime.seededPoolStableWei || 1n) < MIN_DELTA_BPS + ) + return []; + + try { + const call = + target > sold + ? await buildSell(ctx, runtime, delta) + : await buildBuyBack(ctx, runtime, delta); + if (!call) return []; + const hash = await sendNoMine( + ctx.publicClient, + ctx.walletClient, + ctx.chain, + runtime.actorPk, + { to: call.to, data: call.data, gas: DEPEG_GAS }, + opts.priorityFeeWei, + ); + runtime.pending = { hash, blockIndex }; + logger.event({ + type: runtime.label, + stable: runtime.symbol, + blockIndex, + blockNumber, + direction: target > sold ? "sell" : "buyback", + targetFraction: Number(fraction.toFixed(4)), + targetSoldStableWei: target.toString(), + soldStableWei: sold.toString(), + deltaStableWei: delta.toString(), + hash, + }); + return [hash]; + } catch (error) { + // `sold` is re-derived from the chain next block, so a failed send costs one block of lag + // rather than desynchronizing the window. + logger.event({ + type: `${runtime.label}_failed`, + stable: runtime.symbol, + blockIndex, + blockNumber, + targetSoldStableWei: target.toString(), + error: error instanceof Error ? error.message : String(error), + }); + return []; + } +} + +async function buildSell( + ctx: SimContext, + runtime: StableDepegRuntime, + amountStable: bigint, +): Promise<{ to: Address; data: Hex } | null> { + const quoted = (await ctx.publicClient.readContract({ + address: runtime.pool, + abi: curveStableSwapNgAbi, + functionName: "get_dy", + args: [ + BigInt(runtime.stableIndex), + BigInt(runtime.quoteIndex), + amountStable, + ], + })) as bigint; + if (quoted <= 0n) return null; + return { + to: runtime.pool, + data: encodeFunctionData({ + abi: curveStableSwapNgAbi, + functionName: "exchange", + args: [ + BigInt(runtime.stableIndex), + BigInt(runtime.quoteIndex), + amountStable, + (quoted * (10_000n - DEPEG_SLIPPAGE_BPS)) / 10_000n, + ], + }), + }; +} + +/// The buy-back leg sizes on the *output*: the target is an amount of the stable to take back off +/// the market, not an amount of USDC to spend, so it is get_dx rather than get_dy. Spending the USDC +/// the sale produced would come up short by exactly the round trip's cost and leave the peg +/// permanently a little broken. +async function buildBuyBack( + ctx: SimContext, + runtime: StableDepegRuntime, + amountStable: bigint, +): Promise<{ to: Address; data: Hex } | null> { + const [needed, quoteBalance] = (await Promise.all([ + ctx.publicClient.readContract({ + address: runtime.pool, + abi: curveStableSwapNgAbi, + functionName: "get_dx", + args: [ + BigInt(runtime.quoteIndex), + BigInt(runtime.stableIndex), + amountStable, + ], + }), + ctx.publicClient.readContract({ + address: runtime.quote, + abi: erc20Abi, + functionName: "balanceOf", + args: [runtime.actor], + }), + ])) as [bigint, bigint]; + const spend = needed > quoteBalance ? quoteBalance : needed; + if (spend <= 0n) return null; + const quoted = (await ctx.publicClient.readContract({ + address: runtime.pool, + abi: curveStableSwapNgAbi, + functionName: "get_dy", + args: [BigInt(runtime.quoteIndex), BigInt(runtime.stableIndex), spend], + })) as bigint; + if (quoted <= 0n) return null; + return { + to: runtime.pool, + data: encodeFunctionData({ + abi: curveStableSwapNgAbi, + functionName: "exchange", + args: [ + BigInt(runtime.quoteIndex), + BigInt(runtime.stableIndex), + spend, + (quoted * (10_000n - DEPEG_SLIPPAGE_BPS)) / 10_000n, + ], + }), + }; +} + +async function settlePending( + ctx: SimContext, + runtime: StableDepegRuntime, + blockIndex: number, + logger: RunLogger, +): Promise { + const pending = runtime.pending; + if (!pending) return true; + let status: "success" | "reverted" | null = null; + try { + const receipt = await ctx.publicClient.getTransactionReceipt({ + hash: pending.hash, + }); + status = receipt.status === "success" ? "success" : "reverted"; + } catch { + status = null; + } + if (status === null) { + if (blockIndex - pending.blockIndex < PENDING_TIMEOUT_BLOCKS) return false; + logger.event({ + type: `${runtime.label}_stuck`, + stable: runtime.symbol, + blockIndex, + hash: pending.hash, + submittedAtBlockIndex: pending.blockIndex, + }); + } + if (status === "reverted") { + logger.event({ + type: `${runtime.label}_reverted`, + stable: runtime.symbol, + blockIndex, + hash: pending.hash, + }); + } + runtime.pending = null; + return true; +} + +/// Put the peg back before the run ends, whatever the schedule managed to do. +/// +/// The block loop can simply stop with a window still open (`EventSchedule` clamps the start so the +/// window can end on the last block, and a run can also end early on its time limit). Under the +/// scenario matrix the per-scenario revert would hide it, but a plain `sim:realtime` on a shared +/// anvil would hand the next run a permanently depegged stablecoin -- and the liquity startup check +/// would then refuse to start it. Mined rather than mempool: there is no next block to settle on. +export async function restoreStableDepeg( + ctx: SimContext, + runtime: StableDepegRuntime, + logger: RunLogger, +): Promise { + runtime.pending = null; + for (let attempt = 0; attempt < 3; attempt++) { + const balance = (await ctx.publicClient.readContract({ + address: runtime.stable, + abi: erc20Abi, + functionName: "balanceOf", + args: [runtime.actor], + })) as bigint; + const sold = + runtime.startStableWei > balance ? runtime.startStableWei - balance : 0n; + if ( + (sold * 10_000n) / (runtime.seededPoolStableWei || 1n) < + MIN_DELTA_BPS + ) { + logger.event({ + type: `${runtime.label}_restored`, + stable: runtime.symbol, + phase: "teardown", + outstandingStableWei: sold.toString(), + attempts: attempt, + }); + return; + } + try { + const call = await buildBuyBack(ctx, runtime, sold); + if (!call) break; + await sendAndMine( + ctx.publicClient, + ctx.walletClient, + ctx.chain, + runtime.actorPk, + { to: call.to, data: call.data }, + ); + } catch (error) { + logger.event({ + type: `${runtime.label}_teardown_failed`, + stable: runtime.symbol, + outstandingStableWei: sold.toString(), + error: error instanceof Error ? error.message : String(error), + }); + break; + } + } + const balance = (await ctx.publicClient.readContract({ + address: runtime.stable, + abi: erc20Abi, + functionName: "balanceOf", + args: [runtime.actor], + })) as bigint; + const outstanding = + runtime.startStableWei > balance ? runtime.startStableWei - balance : 0n; + logger.event({ + type: + (outstanding * 10_000n) / (runtime.seededPoolStableWei || 1n) < + MIN_DELTA_BPS + ? `${runtime.label}_restored` + : `${runtime.label}_restore_incomplete`, + stable: runtime.symbol, + phase: "teardown", + outstandingStableWei: outstanding.toString(), + }); +} diff --git a/deployer/src/protocols/curve.ts b/deployer/src/protocols/curve.ts index 9850063..4168589 100644 --- a/deployer/src/protocols/curve.ts +++ b/deployer/src/protocols/curve.ts @@ -38,7 +38,14 @@ async function deploy( } // Safe parameters for deploy_plain_pool (per the repo's tests/fixtures/pools.py) -const A = 2000n; +// +// A is 100 rather than the fixture's 2000, for the reason issue #39 measured on the eUSD pool: a +// stableswap at A=2000 barely reprices. Selling *half* of a 100k/100k pool's side moves it 4.4bps, +// so no plausible flow -- and no configurable depeg event -- could ever push this pair far enough +// off par for anyone to trade it. At A=100 the same pool gives 114bps for 40k sold, which is past +// the cost of doing anything about it. Issue #27 (c) needs that: a stable nobody can profitably +// arb back to par is a stable whose peg is decorative. +const A = 100n; const FEE = 1_000_000n; // 0.01% const OFFPEG = 20_000_000_000n; const MA_EXP_TIME = 866n; @@ -371,5 +378,11 @@ async function seedPool(factory: Address) { await waitTx(addHash); ok("add_liquidity", "100k USDC / 100k DAI"); - setProtocol("curve", { usdcDaiPool: pool }); + // The indices matter to the poc: DAI is a market-priced registry stable (issue #27 (c)), so the + // scorer probes this pool both ways every cross-section and needs to know which coin is which. + setProtocol("curve", { + usdcDaiPool: pool, + usdcDaiUsdcIndex: 0, + usdcDaiDaiIndex: 1, + }); } diff --git a/docs/adr/0017-scenario-based-evaluation.md b/docs/adr/0017-scenario-based-evaluation.md index 0b96936..3052460 100644 --- a/docs/adr/0017-scenario-based-evaluation.md +++ b/docs/adr/0017-scenario-based-evaluation.md @@ -584,6 +584,15 @@ R を伸ばしても引くのは同じ seed が定めた 1 本の道筋の続き 代償は #39 が大きい(LST venue 相当)ことで、**レジーム 5 がコンペに間に合わない可能性がある**。 集約はレジーム等重み平均なのでレジーム 6 本でも成立する(§4) + > **実施済み(issue #27)**。上の「実際の障害」の診断は正しかったが、範囲は読み違えていた。 + > `valueUsdc` の額面加算はもっと手前の問題の症状で、根は `chain.ts` が active stable を + > **値付ける前に合計していた**こと(合計してしまえば個別に値付けられない)。修正はレジストリ + > stable を市場から値付けることで、eUSD はレジストリに昇格し(#39 が外していた理由が消えた)、 + > 2 本目として DAI を市場価格 stable にした。**「既にある eUSD を depeg させるだけ」ではなく、 + > eUSD の depeg は #39 の時点で採点を一行も変えずに正しく通っていた**(eUSD が意図的に + > レジストリ外だったため)。欠けていたのは「レジストリ stable が断定で $1」の方だった。 + > 公式セットは 7 本になった(`config/scenarios/public.yaml`) + ## Consequences ### Positive diff --git a/docs/guide/writing-agents.md b/docs/guide/writing-agents.md index 715dfcb..d7cee87 100644 --- a/docs/guide/writing-agents.md +++ b/docs/guide/writing-agents.md @@ -66,7 +66,11 @@ flowchart LR "blockNumber": "610", "fairPriceUsdcPerWeth": 2993.27, // fair price distributed by the environment (1 block late = by design) "fairPricesUsd": { "WETH": 2993.27, "WBTC": 60065.96 }, // per-base fair when multi-asset - "balances": { "ethWei": "…", "wethWei": "0", "usdcUnits": "25000000000" }, + "balances": { "ethWei": "…", "wethWei": "0", "usdcUnits": "25000000000", + "stables": { "USDC": { "token": "0x…", "decimals": 6, "balance": "25000000000", + "priceUsdc": 1, "marketQuoted": false }, + "DAI": { "token": "0x…", "decimals": 18, "balance": "0", + "priceUsdc": 0.991, "marketQuoted": true } } }, "inventory": { "valueUsdc": 339290.8, "weth": 0, "usdc": 25000, "eth": 105.0 }, "history": [ { "round": 608, "poolPriceUsdcPerWeth": 3000.0, "fairPriceUsdcPerWeth": 3000 }, … ], "limits": { "maxWethInWei": "1000000000000000000", "maxUsdcInUnits": "5000000000", @@ -81,6 +85,17 @@ Things to watch when reading: - **Token amounts are decimal strings** (`wethWei` is 18-decimal wei, `usdcUnits` is 6-decimal). Handle them with `BigInt(...)`. `inventory` is a human-readable numeric conversion (approximate) +- **`usdcUnits` is native USDC alone, and it is a budget rather than a valuation** (issue #27). It used to be every + stable summed together, which could not be spent anywhere: USDT is not accepted in a USDC pool. What the wallet is + worth is `inventory.valueUsdc`; what a USDC leg can be sized against is this +- **`balances.stables` is where a stable stops being a dollar.** Each entry carries the balance *and* `priceUsdc`, the + two-sided executable mid of that stable's own market. A registry stable is scored at that price, in your wallet and + in an LP leg alike — so holding a depegged one is a real loss, and buying one below par is a real position with a + real downside. `marketQuoted: false` means `priceUsdc: 1` is par by assumption (no market, or the pool would not + quote): **do not read a `1` there as "the peg is holding"**. Trade the pair with `stableSwap` +- **Per-round limits are denominated in USDC's six decimals.** `maxUsdcInUnits` bounds both legs of a `stableSwap`, + so for an 18-decimal stable you have to scale it (`limit * 10n ** 12n`) before sizing a sell. Getting this wrong + rejects every unwind while letting every buy through, which leaves you holding a position you cannot close - `history` is the pool/fair series for the last ~20 blocks (for gauging momentum and the persistence of a gap) - `limits` holds the per-round trade limits and the default/max fees. **Cap your size here** (actions over the limit are rejected by validation) diff --git a/example/agents/lib/affordable.ts b/example/agents/lib/affordable.ts index 9797bd6..ab5dbaa 100644 --- a/example/agents/lib/affordable.ts +++ b/example/agents/lib/affordable.ts @@ -26,11 +26,15 @@ export function minimumFor(tokenIn: string): bigint { } // What the wallet holds of the token a swap would spend. Non-WETH bases come from balances.bases -// when the run has them (ADR 0013); an unknown symbol reads as zero, which is the safe direction -- -// it makes the agent skip rather than propose something unfundable. +// when the run has them (ADR 0013); stables other than USDC come from balances.stables, which since +// issue #27 keeps them apart instead of summing them into usdcUnits. An unknown symbol reads as +// zero, which is the safe direction -- it makes the agent skip rather than propose something +// unfundable. export function balanceOf(obs: AgentObservation, tokenIn: string): bigint { if (tokenIn === "USDC") return BigInt(obs.balances.usdcUnits); if (tokenIn === "WETH") return BigInt(obs.balances.wethWei); + const stable = obs.balances.stables?.[tokenIn]; + if (stable) return BigInt(stable.balance); const bases = (obs.balances as unknown as { bases?: Record }) .bases; const raw = bases?.[tokenIn]; diff --git a/example/agents/peg-arb/agent.ts b/example/agents/peg-arb/agent.ts new file mode 100644 index 0000000..00d1525 --- /dev/null +++ b/example/agents/peg-arb/agent.ts @@ -0,0 +1,143 @@ +/** + * peg-arb: buys a market-priced stable when it trades below a dollar, and sells it back as the peg + * recovers (issue #27 (c)). + * + * This is the trade the second market-priced stable exists to make possible, and it is deliberately + * a *different* trade from redemption-arb's. eUSD has a floor: a CDP will always exchange it for + * $1 of collateral, so a discount is a claim you can enforce. A plain stable has no such thing -- + * only the belief that it is a dollar and the fact that the environment's dislocation is a window + * rather than a permanent repricing. So the position is an opinion, and the risk is real: an agent + * that buys at 0.985 and is still holding at the last block is marked at whatever the pool pays + * then, not at par. Since issue #27 the scorer no longer pretends otherwise. + * + * The whole strategy is two thresholds and a size, because the point is to exercise the venue, not + * to win with it: + * + * below par by more than BUY_BPS spend USDC to buy the stable + * above SELL_BPS of par sell the stable back for USDC + * + * Both are read off `obs.balances.stables`, which since issue #27 carries each stable's balance and + * what the market says it is worth -- `marketQuoted: false` means the price is par by assumption, + * which is exactly the case where there is nothing to trade. + */ +import type { AgentAction, AgentContext, AgentObservation } from "@eris/sdk"; + +// The discount at which buying is worth the round trip. The pool charges its fee on both legs and +// the exit price is not the entry price, so a few bps of dislocation is noise. +const BUY_BPS = Number(process.env.ERIS_PEG_ARB_BUY_BPS ?? "40"); +// Where to let go. Above par is a premium; waiting for one is waiting for the environment to +// overshoot, which the buy-back leg of the event does not promise. +const SELL_BPS = Number(process.env.ERIS_PEG_ARB_SELL_BPS ?? "10"); +// Fraction of the spendable dollar budget committed per buy, in bps. A single block's dislocation +// is not the deepest it will get, so this leaves room to keep buying into it. +const SIZE_BPS = BigInt(process.env.ERIS_PEG_ARB_SIZE_BPS ?? "2500"); +// Which stable to trade. Unset = whichever quoted stable is furthest from par this block. +const TARGET = process.env.ERIS_PEG_ARB_STABLE ?? ""; + +const MIN_USDC_UNITS = 1_000_000n; // 1 USDC +const SLIPPAGE_BPS = 100; + +function minBI(a: bigint, b: bigint): bigint { + return a < b ? a : b; +} + +// limits.maxUsdcInUnits is in USDC's six decimals; a stable need not share them. Both are dollars, +// so the conversion is the decimal difference and nothing else. +const USDC_DECIMALS = 6; +function scaleUsdcLimit(limitUsdcUnits: bigint, decimals: number): bigint { + if (decimals === USDC_DECIMALS) return limitUsdcUnits; + return decimals > USDC_DECIMALS + ? limitUsdcUnits * 10n ** BigInt(decimals - USDC_DECIMALS) + : limitUsdcUnits / 10n ** BigInt(USDC_DECIMALS - decimals); +} + +type StableView = { + symbol: string; + balance: bigint; + decimals: number; + discountBps: number; +}; + +// Every stable whose price came from a market this block. USDC is skipped: it is the numéraire and +// is a dollar by definition, so its "discount" is always exactly zero. +function quotedStables(obs: AgentObservation): StableView[] { + const out: StableView[] = []; + for (const [symbol, s] of Object.entries(obs.balances.stables ?? {})) { + if (symbol === "USDC" || !s.marketQuoted) continue; + if (TARGET && symbol !== TARGET) continue; + out.push({ + symbol, + balance: BigInt(s.balance), + decimals: s.decimals, + discountBps: (1 - s.priceUsdc) * 10_000, + }); + } + return out; +} + +export function decide( + obs: AgentObservation, + ctx?: AgentContext, +): AgentAction | Record | null { + const stables = quotedStables(obs); + const fee = obs.limits.defaultPriorityFeePerGasWei; + // Record why, every cycle. Sitting out is the correct move most of the time -- the pair is at par + // until something moves it -- and without this a run where the agent correctly did nothing is + // indistinguishable from one where it never saw the market at all. + const widest = [...stables].sort((a, b) => b.discountBps - a.discountBps)[0]; + ctx?.log({ + round: obs.round, + reason: widest + ? `${widest.symbol} ${widest.discountBps.toFixed(1)}bps from par` + : "no market-priced stable quoted this block", + signals: Object.fromEntries( + stables.map((s) => [ + `${s.symbol}DiscountBps`, + Number(s.discountBps.toFixed(2)), + ]), + ), + }); + if (stables.length === 0) return null; + + // Sell first. Holding through the end of the run is the one way this strategy loses money it + // never had to lose, so unwinding takes priority over adding. + const rich = stables + .filter((s) => s.balance > 0n && s.discountBps <= SELL_BPS) + .sort((a, b) => a.discountBps - b.discountBps)[0]; + if (rich) { + // Bounded by the per-round cap, restated in this stable's decimals. Asking for the whole + // balance is how a position becomes unclosable: the runtime rejects the oversized leg and the + // agent proposes the same thing again next block, which scores the same as never trying. + const cap = scaleUsdcLimit( + BigInt(obs.limits.maxUsdcInUnits || "0"), + rich.decimals, + ); + const size = cap > 0n ? minBI(rich.balance, cap) : rich.balance; + return { + type: "stableSwap", + stable: rich.symbol, + tokenIn: rich.symbol, + amountIn: size.toString(), + slippageBps: SLIPPAGE_BPS, + maxPriorityFeePerGasWei: fee, + }; + } + + const cheap = stables + .filter((s) => s.discountBps >= BUY_BPS) + .sort((a, b) => b.discountBps - a.discountBps)[0]; + if (!cheap) return null; + const usdc = BigInt(obs.balances.usdcUnits || "0"); + const cap = BigInt(obs.limits.maxUsdcInUnits || "0"); + let size = (usdc * SIZE_BPS) / 10_000n; + if (cap > 0n) size = minBI(size, cap); + if (size < MIN_USDC_UNITS) return null; + return { + type: "stableSwap", + stable: cheap.symbol, + tokenIn: "USDC", + amountIn: size.toString(), + slippageBps: SLIPPAGE_BPS, + maxPriorityFeePerGasWei: fee, + }; +} diff --git a/scripts/genLocalConstants.ts b/scripts/genLocalConstants.ts index c05b749..adfc33d 100644 --- a/scripts/genLocalConstants.ts +++ b/scripts/genLocalConstants.ts @@ -245,6 +245,9 @@ export function generateLocalConstants(deploymentsPath?: string): { wbtcUsdcCryptoPool?: string; cryptoWbtcIndex?: number; cryptoWbtcStableIndex?: number; + usdcDaiPool?: string; + usdcDaiUsdcIndex?: number; + usdcDaiDaiIndex?: number; }) | undefined; const curve = { @@ -288,6 +291,23 @@ export function generateLocalConstants(deploymentsPath?: string): { }; } + // ---- Issue #27 (c): DAI as the second market-priced stable. It needs both the token and the + // stableswap pool that quotes it; a deploy with either missing simply has no market-priced stable + // other than eUSD, and every registry stable stays the USDC-equivalent dollar. + const dai = t.DAI ? getAddress(t.DAI) : undefined; + const daiMarket = + dai && + curveP?.usdcDaiPool && + curveP.usdcDaiUsdcIndex !== undefined && + curveP.usdcDaiDaiIndex !== undefined + ? { + token: dai, + pool: getAddress(curveP.usdcDaiPool), + stableIndex: Number(curveP.usdcDaiDaiIndex), + quoteIndex: Number(curveP.usdcDaiUsdcIndex), + } + : undefined; + const fingerprint = deploymentsFingerprint(data); const out = render({ deploymentsPath: path, @@ -296,6 +316,7 @@ export function generateLocalConstants(deploymentsPath?: string): { weth, usdc, usdt, + daiMarket, multicall3, uni: { pool: ca(uni.wethUsdcPool, "uniswapV3.wethUsdcPool"), @@ -377,6 +398,14 @@ function render(d: { weth: Address; usdc: Address; usdt: Address; + // Issue #27 (c): the second market-priced stable and the pool that quotes it, when the deploy + // produced both. + daiMarket?: { + token: Address; + pool: Address; + stableIndex: number; + quoteIndex: number; + }; multicall3: Address; uni: { pool: Address; swapRouter: Address; npm: Address; quoterV2: Address }; bal: { @@ -459,6 +488,18 @@ function render(d: { ? `\n WBTC: { address: ${a(w.token)}, decimals: 8 },` : ""; + // ---- Issue #27 (c): DAI, and the pool the scorer prices it from ---- + const dm = d.daiMarket; + const tokensDai = dm + ? `\n DAI: { address: ${a(dm.token)}, decimals: 18 },` + : ""; + const stableMarkets = dm + ? ` + STABLE_MARKETS: { + DAI: { pool: ${a(dm.pool)}, stableIndex: ${dm.stableIndex}, quoteIndex: ${dm.quoteIndex} }, + },` + : ""; + // ---- MARKET_LEGS (WETH + WBTC leg. The WBTC leg is included only for venues whose addresses are all present) ---- const uniWbtc = w?.uniPool ? `\n WBTC: { pool: ${a(w.uniPool)}, fee: 3000, tickSpacing: 60 },` @@ -514,8 +555,15 @@ export type LocalDeployment = { WETH: { address: Address; decimals: number }; USDC: { address: Address; decimals: number }; WBTC?: { address: Address; decimals: number }; + DAI?: { address: Address; decimals: number }; }; USDC_VARIANTS: { native: Address; bridged: Address; usdt: Address }; + // Issue #27 (c): stables the deploy gave a market, so the scorer prices them from it instead of + // asserting $1. Keyed by registry symbol. eUSD is not here -- its market comes from LIQUITY. + STABLE_MARKETS?: Record< + string, + { pool: Address; stableIndex: number; quoteIndex: number } + >; UNISWAP: { poolWethUsdc500: Address; swapRouter: Address; @@ -590,8 +638,8 @@ export const LOCAL_DEPLOYMENT: LocalDeployment | null = { CHAIN_ID: ${d.chainId}, TOKENS: { WETH: { address: ${a(d.weth)}, decimals: 18 }, - USDC: { address: ${a(d.usdc)}, decimals: 6 },${tokensWbtc} - }, + USDC: { address: ${a(d.usdc)}, decimals: 6 },${tokensWbtc}${tokensDai} + },${stableMarkets} // Local uses a single USDC/USDT. native/bridged are the same USDC; usdt maps to USDT. USDC_VARIANTS: { native: ${a(d.usdc)}, diff --git a/sdk/src/action.ts b/sdk/src/action.ts index d236c11..641342a 100644 --- a/sdk/src/action.ts +++ b/sdk/src/action.ts @@ -14,6 +14,7 @@ import { getAdapter, } from "./protocols/registry.js"; import { kindOf, tokenInfo } from "./markets.js"; +import { TOKENS } from "./constants.js"; export type ValidatedIntent = { action: LeafAction; @@ -320,15 +321,20 @@ function applyLeafSpend( if (base === "WETH") work.wethWei += amount; if (work.bases) work.bases[base] = (work.bases[base] ?? 0n) + amount; }; + // usdcUnits is native USDC since issue #27, so it only moves when the venue's stable *is* native + // USDC. Decrementing it for a USDC.e leg would have a bundle refuse its own second leg over a + // balance that leg never touched. + const isNativeStable = stableKey === TOKENS.USDC.address.toLowerCase(); const spendStable = (amount: bigint) => { - work.usdcUnits = work.usdcUnits > amount ? work.usdcUnits - amount : 0n; + if (isNativeStable) + work.usdcUnits = work.usdcUnits > amount ? work.usdcUnits - amount : 0n; if (work.stables && stableKey in work.stables) { const cur = work.stables[stableKey]; work.stables[stableKey] = cur > amount ? cur - amount : 0n; } }; const creditStable = (amount: bigint) => { - work.usdcUnits += amount; + if (isNativeStable) work.usdcUnits += amount; if (work.stables && stableKey in work.stables) work.stables[stableKey] += amount; }; diff --git a/sdk/src/actionSchema.ts b/sdk/src/actionSchema.ts index 2eb54e2..e3f3190 100644 --- a/sdk/src/actionSchema.ts +++ b/sdk/src/actionSchema.ts @@ -104,6 +104,17 @@ export const curveSwapSchema = z.object({ ...priorityFee, }); +// Issue #27 (c): the stable/stable leg. `stable` names the market-priced registry stable; the other +// side is always USDC, so tokenIn is one of the two. +export const stableSwapSchema = z.object({ + type: z.literal("stableSwap"), + stable: tokenSymbol, + tokenIn: tokenSymbol, + amountIn: decimalString, + slippageBps: z.number().int().nonnegative().optional(), + ...priorityFee, +}); + export const aaveSupplySchema = z.object({ type: z.literal("aaveSupply"), asset: tokenSymbol, @@ -333,7 +344,7 @@ const LEAF_SCHEMAS_BY_PROTOCOL: Record = { collectFeesSchema, ], balancer: [balancerSwapSchema], - curve: [curveSwapSchema], + curve: [curveSwapSchema, stableSwapSchema], aave: [ aaveSupplySchema, aaveWithdrawSchema, diff --git a/sdk/src/chain.ts b/sdk/src/chain.ts index 61bc08d..0e9899e 100644 --- a/sdk/src/chain.ts +++ b/sdk/src/chain.ts @@ -16,6 +16,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { erc20Abi, wethAbi } from "./abis.js"; import { MULTICALL3, TOKENS } from "./constants.js"; import { baseTokens, tokenInfo } from "./markets.js"; +import { isParStable } from "./stables.js"; import type { BalanceSnapshot, TokenSymbol } from "./types.js"; export function makeChain(chainId: number) { @@ -61,8 +62,16 @@ export function accountAddress(privateKey: Hex): Address { } // --------------------------------------------------------------------------- -// Unified stable accounting: usdcUnits is the sum of the active stables (native USDC / USDC.e / USDT). -// All are treated as 6-decimal and worth $1. The coordinator sets the active set from the enabled adapters. +// The run's stables. The coordinator sets the active set from the enabled adapters (each venue's +// stable leg, plus any stable a venue issues). +// +// usdcUnits used to be the sum of all of them, on the convention that native USDC / USDC.e / USD₮0 +// are interchangeable dollars. Issue #27 narrowed it to native USDC alone, for two reasons. As a +// *budget* -- which is the only thing the nine participant-facing uses do with it -- the sum was +// already wrong: USDT cannot be spent in a USDC pool, and funding grants the configured amount to +// each stable, so the sum read roughly double what any single venue would accept. And as soon as one +// stable is priced from a market rather than asserted at par (stables.ts), summing them at face +// value states a total that no longer exists. The per-stable breakdown lives in `stables`. // --------------------------------------------------------------------------- let ACTIVE_STABLES: Address[] = [TOKENS.USDC.address]; @@ -156,7 +165,9 @@ export async function getBalances( ACTIVE_STABLES.forEach((token, i) => { stables[token.toLowerCase()] = stableBalances[i]; }); - const usdcUnits = stableBalances.reduce((sum, b) => sum + b, 0n); + // Native USDC alone (issue #27 (a) step 2). Narrowing only ever makes an agent trade smaller; + // leaving it summed made agents overstate their dollars exactly when a stable depegs. + const usdcUnits = stables[TOKENS.USDC.address.toLowerCase()] ?? 0n; return { ethWei, wethWei, usdcUnits, bases, stables }; } @@ -542,8 +553,14 @@ export async function fundWallet( }); } if (usdcUnits > 0n) { - // Grant usdcUnits to each active stable (so each stable has inventory cross-venue) + // Grant the endowment to each *par* stable, so every venue's USDC-equivalent leg has inventory. + // + // Market-priced stables are deliberately excluded (issue #27). Two reasons: conjuring eUSD with + // a cheatcode would put stablecoin into circulation that no Trove ever borrowed, and endowing + // everyone with a stable that is about to depeg makes the loss β on a position nobody chose. A + // market-priced stable has to be bought, which is what makes holding one a decision. for (const token of ACTIVE_STABLES) { + if (!isParStable(token)) continue; await dealErc20(publicClient, token, address, usdcUnits); } } diff --git a/sdk/src/constants.local.ts b/sdk/src/constants.local.ts index cc3340d..db357d4 100644 --- a/sdk/src/constants.local.ts +++ b/sdk/src/constants.local.ts @@ -7,7 +7,7 @@ import type { MarketLegs } from "./types.js"; // Canonical fingerprint of the source deployments.json (ADR 0016 §2). The backtest CLI // compares it against the state dump manifest and, on mismatch, regenerates from the manifest's bundled deployments. -export const DEPLOYMENTS_FINGERPRINT = "sha256:c6bce1b47747bd3425512f1eff91693e69348db3fedd2566f9a4871312990f7d"; +export const DEPLOYMENTS_FINGERPRINT = "sha256:026fe70e6f4e871b2c33171976cb961d5a7160c4f36363db27b09f95344a9eec"; export type LocalDeployment = { CHAIN_ID: number; @@ -15,8 +15,15 @@ export type LocalDeployment = { WETH: { address: Address; decimals: number }; USDC: { address: Address; decimals: number }; WBTC?: { address: Address; decimals: number }; + DAI?: { address: Address; decimals: number }; }; USDC_VARIANTS: { native: Address; bridged: Address; usdt: Address }; + // Issue #27 (c): stables the deploy gave a market, so the scorer prices them from it instead of + // asserting $1. Keyed by registry symbol. eUSD is not here -- its market comes from LIQUITY. + STABLE_MARKETS?: Record< + string, + { pool: Address; stableIndex: number; quoteIndex: number } + >; UNISWAP: { poolWethUsdc500: Address; swapRouter: Address; @@ -93,6 +100,10 @@ export const LOCAL_DEPLOYMENT: LocalDeployment | null = { WETH: { address: "0x5FbDB2315678afecb367f032d93F642f64180aa3" as Address, decimals: 18 }, USDC: { address: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" as Address, decimals: 6 }, WBTC: { address: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853" as Address, decimals: 8 }, + DAI: { address: "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707" as Address, decimals: 18 }, + }, + STABLE_MARKETS: { + DAI: { pool: "0xD33d097DD5eE1cB4927632DD211c7981eda96319" as Address, stableIndex: 1, quoteIndex: 0 }, }, // Local uses a single USDC/USDT. native/bridged are the same USDC; usdt maps to USDT. USDC_VARIANTS: { diff --git a/sdk/src/constants.ts b/sdk/src/constants.ts index 5d9211f..4c2abc9 100644 --- a/sdk/src/constants.ts +++ b/sdk/src/constants.ts @@ -1,5 +1,5 @@ import type { Address } from "viem"; -import type { MarketLegs, TokenSymbol } from "./types.js"; +import type { MarketLegs, ProtocolId, TokenSymbol } from "./types.js"; import { LOCAL_DEPLOYMENT } from "./constants.local.js"; // --------------------------------------------------------------------------- @@ -21,8 +21,8 @@ export type { TokenSymbol }; // ADR 0013: token registry. The Record type annotation permits index access by // TokenSymbol (=string). Under local-deploy the overlay adds WBTC etc. -export const TOKENS: Record = - L?.TOKENS ?? { +export const TOKENS: Record = { + ...(L?.TOKENS ?? { WETH: { address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1" as Address, decimals: 18, @@ -31,7 +31,15 @@ export const TOKENS: Record = address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" as Address, decimals: 6, }, - }; + }), + // Issue #27 (b): eUSD is a registry stable, priced from its own market rather than at $1. It is + // issued by a venue instead of deployed as a run token, so it is picked up from the liquity + // deployment rather than from the deployer's token table. #39 kept it out of the registry only + // because the registry priced stables at par -- STABLE_MARKET_LEGS below is what dissolves that. + ...(L?.LIQUITY?.eusd + ? { eUSD: { address: L.LIQUITY.eusd, decimals: 18 } } + : {}), +}; // Unified stable accounting: native USDC / USDC.e / USDT(USD₮0) are all treated as 6-decimal "USDC-equivalent" worth $1. // On Arbitrum the deep Balancer/Curve WETH/stable pools are USDC.e / USDT pairs, so we use a @@ -54,7 +62,9 @@ export function oppositeToken(symbol: TokenSymbol): TokenSymbol { return symbol === "WETH" ? "USDC" : "WETH"; } -// Look up the per-venue stable balance. Falls back to the summed value when there is no stables map. +// Look up the per-venue stable balance. Falls back to usdcUnits when there is no stables map, which +// is the right answer for the fork's USDC-equivalents and the only one available for a snapshot +// assembled without one. export function stableBalanceOf( balances: { usdcUnits: bigint; stables?: Record }, token: Address, @@ -62,6 +72,49 @@ export function stableBalanceOf( return balances.stables?.[token.toLowerCase()] ?? balances.usdcUnits; } +// --------------------------------------------------------------------------- +// Market-priced stables (issue #27). A stable listed here is worth what its pool pays, not $1; the +// pricing rules live in stables.ts. A stable *not* listed is the USDC-equivalent dollar -- USDC by +// definition (the numéraire), and the fork's USDC.e / USD₮0 for want of a pool to quote them. +// --------------------------------------------------------------------------- +export type StableMarketLeg = { + pool: Address; // curve stableswap-ng + stableIndex: number; + quoteIndex: number; + // The protocol whose being enabled brings this stable into the run. A stable nobody can trade is + // worse than absent: it would be swept, probed and reported every block while no action could + // touch it. eUSD comes with the CDP that issues it; a plain stable comes with the venue that owns + // its pool, which is where `stableSwap` lives. + venue: ProtocolId; +}; + +function buildStableMarketLegs(): Record { + const out: Record = {}; + // eUSD's market is part of the liquity deployment, not a standalone venue leg. + const liquity = L?.LIQUITY; + if ( + liquity?.eusdUsdcPool && + liquity.eusdIndex !== undefined && + liquity.usdcIndex !== undefined + ) { + out.eUSD = { + pool: liquity.eusdUsdcPool, + stableIndex: liquity.eusdIndex, + quoteIndex: liquity.usdcIndex, + venue: "liquity", + }; + } + // Issue #27 (c): stables the deployer seeded a stableswap pool for. Those pools come off the + // Curve factory, and the curve adapter is what can trade them. + for (const [symbol, leg] of Object.entries(L?.STABLE_MARKETS ?? {})) { + out[symbol] = { ...leg, venue: "curve" }; + } + return out; +} + +export const STABLE_MARKET_LEGS: Record = + buildStableMarketLegs(); + export function symbolForAddress(addr: Address): TokenSymbol | undefined { const lower = addr.toLowerCase(); if (lower === TOKENS.WETH.address.toLowerCase()) return "WETH"; diff --git a/sdk/src/markets.ts b/sdk/src/markets.ts index 86d9427..5de998c 100644 --- a/sdk/src/markets.ts +++ b/sdk/src/markets.ts @@ -31,7 +31,20 @@ const QUOTE_SYMBOL: TokenSymbol = "USDC"; // Symbols treated as stable. Everything else is base (a tradable with a USD price). // Add here only when adding a new stable. A base is treated as a base without doing anything. -const STABLE_SYMBOLS = new Set(["USDC", "USDT", "DAI", "USDC.e"]); +// +// Since issue #27 "stable" no longer means "worth $1". It means the token is *meant* to settle at a +// dollar and is quoted against USDC rather than against a USD fair-price feed; whether it currently +// does is a question for its market (constants' STABLE_MARKET_LEGS, priced in stables.ts). USDC is +// the one exception that is a dollar by definition, because it is the numéraire. +const STABLE_SYMBOLS = new Set([ + "USDC", + "USDT", + "DAI", + "USDC.e", + // Issue #27 (b): the CDP stablecoin from #39, promoted out of the liquity adapter's private + // accounting now that being in the registry no longer means being priced at par. + "eUSD", +]); // Yield-bearing claims valued by their own venue rather than by the fair-price feed (issue #38). // Listed here so they stay out of the scorer's spot sweep -- see TokenKind for why. diff --git a/sdk/src/observation.ts b/sdk/src/observation.ts index de38e09..d7c6e46 100644 --- a/sdk/src/observation.ts +++ b/sdk/src/observation.ts @@ -11,7 +11,13 @@ import type { ProtocolId, ProtocolObservations, } from "./types.js"; -import { tokenInfo } from "./markets.js"; +import { tokenInfo, tokenInfoByAddress } from "./markets.js"; +import { + readStablePrices, + stableKeyFor, + stablePriceUsdc, + type StablePrices, +} from "./stables.js"; import type { ProtocolAdapter, SimContext } from "./protocols/types.js"; export async function observationFor( @@ -31,6 +37,12 @@ export async function observationFor( // Per-protocol observations are independent reads, so issue them in parallel. With the agent client // (batch=true), same-tick reads are auto-aggregated into a single Multicall3, so parallel issuance directly reduces round-trip count. const protocols: ProtocolObservations = {}; + // Issue #27: what each market-priced stable is worth right now. Read alongside the venues so it + // rides the same batch, and so the agent's own inventory total agrees with the scorer's. + const stablePricesPromise = readStablePrices( + ctx.publicClient, + Object.keys(balances.stables ?? {}) as Address[], + ); await Promise.all( adapters.map(async (adapter) => { const obs = await adapter.observe( @@ -42,6 +54,7 @@ export async function observationFor( (protocols as Record)[adapter.id] = obs; }), ); + const stablePrices = await stablePricesPromise; return { kind: "observation", runId, @@ -72,8 +85,11 @@ export async function observationFor( ethWei: balances.ethWei.toString(), wethWei: balances.wethWei.toString(), usdcUnits: balances.usdcUnits.toString(), + ...(balances.stables + ? { stables: buildStableBalances(balances.stables, stablePrices) } + : {}), }, - inventory: balanceToInventory(balances, fairPrice), + inventory: balanceToInventory(balances, fairPrice, stablePrices), history: history.slice(-20), limits: { maxWethInWei: config.maxAgentWethInWei.toString(), @@ -136,3 +152,32 @@ function buildBaseLimits( } return out; } + +// The per-stable breakdown an agent sizes and marks against (issue #27 (a) step 1). Keyed by +// registry symbol so a strategy can name the stable it wants; the raw address rides along because +// per-venue validation checks that. +function buildStableBalances( + stables: Record, + prices: StablePrices, +): NonNullable { + const quoted = new Set( + prices.quotes + .filter((q) => q.quoted) + .map((q) => q.token.toLowerCase()), + ); + const out: NonNullable = {}; + for (const [token, balance] of Object.entries(stables)) { + const address = token as Address; + // The fork's USDC.e / USD₮0 are outside the registry and 6-decimal by the same convention that + // makes them dollars. + const decimals = tokenInfoByAddress(address)?.decimals ?? 6; + out[stableKeyFor(address)] = { + token, + decimals, + balance: balance.toString(), + priceUsdc: stablePriceUsdc(prices, address), + marketQuoted: quoted.has(token.toLowerCase()), + }; + } + return out; +} diff --git a/sdk/src/pnl.ts b/sdk/src/pnl.ts index 55209b9..dd7bf02 100644 --- a/sdk/src/pnl.ts +++ b/sdk/src/pnl.ts @@ -1,5 +1,6 @@ import { formatUnits } from "viem"; -import { tokenInfo } from "./markets.js"; +import { tokenInfo, tokenInfoByAddress } from "./markets.js"; +import { stablePriceUsdc, type StablePrices } from "./stables.js"; import type { BalanceSnapshot } from "./types.js"; // Price argument. For backward compatibility it also accepts a single number (WETH/USD), normalizing it to {WETH:n} (ADR 0013). @@ -9,15 +10,41 @@ function normalizePrices(arg: PriceArg): Record { return typeof arg === "number" ? { WETH: arg } : arg; } -// Base wallet value: loose ETH + all base tokens + stable (USDC-equivalent). +// Decimals of a stable held in BalanceSnapshot.stables, which is keyed by raw address. The fork's +// USDC.e / USD₮0 are outside the registry and are 6-decimal by the same convention that makes them +// dollars (valuation.ts STABLE_VARIANT_DECIMALS). +function stableDecimals(token: string): number { + return tokenInfoByAddress(token as `0x${string}`)?.decimals ?? 6; +} + +// Base wallet value: loose ETH + all base tokens + every stable at its own price. // Protocol-specific position value (LP, perp, aave net) is added by each adapter.valueUsdc. // ADR 0013: if snapshot.bases exists, value all bases at their respective USD prices; otherwise value // wethWei as WETH (= exactly the old behavior). -export function valueUsdc(snapshot: BalanceSnapshot, prices: PriceArg): number { +// +// Issue #27: the stable leg is no longer a constant. When the snapshot carries the per-stable +// breakdown, each balance is valued at what its market pays (par for the numéraire and for the +// USDC-equivalents, which is byte-identical to the old summed figure). A snapshot without the +// breakdown falls back to usdcUnits at par -- the only thing available, and correct for every +// caller that assembles one by hand. +export function valueUsdc( + snapshot: BalanceSnapshot, + prices: PriceArg, + stablePrices?: StablePrices, +): number { const p = normalizePrices(prices); const wethPrice = p.WETH ?? 0; const eth = Number(formatUnits(snapshot.ethWei, 18)) * wethPrice; - let total = Number(formatUnits(snapshot.usdcUnits, 6)) + eth; + let total = eth; + if (snapshot.stables) { + for (const [token, units] of Object.entries(snapshot.stables)) { + total += + Number(formatUnits(units, stableDecimals(token))) * + stablePriceUsdc(stablePrices, token as `0x${string}`); + } + } else { + total += Number(formatUnits(snapshot.usdcUnits, 6)); + } const bases = snapshot.bases ?? { WETH: snapshot.wethWei }; for (const [sym, wei] of Object.entries(bases)) { total += Number(formatUnits(wei, tokenInfo(sym).decimals)) * (p[sym] ?? 0); @@ -28,12 +55,15 @@ export function valueUsdc(snapshot: BalanceSnapshot, prices: PriceArg): number { export function balanceToInventory( snapshot: BalanceSnapshot, prices: PriceArg, + stablePrices?: StablePrices, ) { const eth = Number(formatUnits(snapshot.ethWei, 18)); const weth = Number(formatUnits(snapshot.wethWei, 18)); + // The spendable dollar budget, which since issue #27 is native USDC alone. The value of the other + // stables is in valueUsdc, not here: this field is what an agent sizes a USDC leg against. const usdc = Number(formatUnits(snapshot.usdcUnits, 6)); return { - valueUsdc: valueUsdc(snapshot, prices), + valueUsdc: valueUsdc(snapshot, prices, stablePrices), weth, usdc, eth, diff --git a/sdk/src/protocols/balancer.ts b/sdk/src/protocols/balancer.ts index f46bc90..872891e 100644 --- a/sdk/src/protocols/balancer.ts +++ b/sdk/src/protocols/balancer.ts @@ -599,6 +599,7 @@ export const balancerAdapter: ProtocolAdapter = { }); const fairByBase = ctx.fairByBase(); + const stablePrices = ctx.stablePrices(); const balancesBase = pools.length * 2; const out: Record = {}; ctx.agents.forEach((agent, a) => { @@ -616,7 +617,12 @@ export const balancerAdapter: ProtocolAdapter = { }); return; } - const share = poolShareValueUsdc(pool, balance, fairByBase); + const share = poolShareValueUsdc( + pool, + balance, + fairByBase, + stablePrices, + ); valueUsdc += share.valueUsdc; for (const h of share.unpriced) unpriced.push({ ...h, source: "balancer-bpt" }); diff --git a/sdk/src/protocols/curve.ts b/sdk/src/protocols/curve.ts index dda645a..c4edc3f 100644 --- a/sdk/src/protocols/curve.ts +++ b/sdk/src/protocols/curve.ts @@ -1,7 +1,12 @@ import { encodeFunctionData, type Address, type PublicClient } from "viem"; -import { curveTricryptoAbi, erc20Abi } from "../abis.js"; +import { + curveStableSwapNgAbi, + curveTricryptoAbi, + erc20Abi, +} from "../abis.js"; import { CURVE, TOKENS, stableBalanceOf } from "../constants.js"; import { poolShareValueUsdc } from "../valuation.js"; +import { marketPricedStables, type StableMarket } from "../stables.js"; import { marketFor, marketsFor, @@ -18,6 +23,7 @@ import type { AmmObservation, BalanceSnapshot, CurveLeg, + StableSwapAction, CurveSwapAction, LeafAction, } from "../types.js"; @@ -239,7 +245,56 @@ function parseBase(obj: Record): { return { base, market }; } +// Issue #27 (c): the stable/stable leg. Its pool is a stableswap-ng created by the same factory as +// this venue's crypto pools, which is why it lives here rather than behind a ProtocolId of its own -- +// a venue whose entire job is one pool would need a config flag and a place in every roster to say +// the same thing. +function parseStableSwap(obj: Record): LeafAction | null { + if (obj.type !== "stableSwap") return null; + if (typeof obj.stable !== "string") + throw new Error("stable must be a token symbol string"); + const market = stableMarketBySymbol(obj.stable); + if (!market) + throw new Error( + `stableSwap: "${obj.stable}" is not a market-priced stable in this deployment ` + + `(known: ${marketPricedStables().map((m) => m.symbol).join(", ") || "none"})`, + ); + if (obj.tokenIn !== market.symbol && obj.tokenIn !== "USDC") + throw new Error(`tokenIn must be ${market.symbol} or USDC`); + requireDecimalString(obj.amountIn, "amountIn"); + const action: StableSwapAction = { + type: "stableSwap", + stable: market.symbol, + tokenIn: obj.tokenIn, + amountIn: obj.amountIn, + }; + if (obj.maxPriorityFeePerGasWei !== undefined) { + requireDecimalString( + obj.maxPriorityFeePerGasWei, + "maxPriorityFeePerGasWei", + ); + action.maxPriorityFeePerGasWei = obj.maxPriorityFeePerGasWei; + } + if (obj.slippageBps !== undefined) { + if ( + typeof obj.slippageBps !== "number" || + !Number.isInteger(obj.slippageBps) || + obj.slippageBps < 0 || + obj.slippageBps > 1000 + ) { + throw new Error("slippageBps must be an integer between 0 and 1000"); + } + action.slippageBps = obj.slippageBps; + } + return action; +} + +function stableMarketBySymbol(symbol: string): StableMarket | undefined { + return marketPricedStables().find((m) => m.symbol === symbol); +} + function parse(obj: Record): LeafAction | null { + if (obj.type === "stableSwap") return parseStableSwap(obj); if (obj.type !== "curveSwap") return null; const { base, market } = parseBase(obj); if (obj.tokenIn !== market.base && obj.tokenIn !== market.quote) @@ -272,11 +327,53 @@ function parse(obj: Record): LeafAction | null { return action; } +// The per-round USDC cap, restated in another token's decimals. Both are dollars, so the conversion +// is the decimal difference and nothing else. +function scaleUsdcLimit(limitUsdcUnits: bigint, decimals: number): bigint { + const quoteDecimals = TOKENS.USDC.decimals; + if (decimals === quoteDecimals) return limitUsdcUnits; + return decimals > quoteDecimals + ? limitUsdcUnits * 10n ** BigInt(decimals - quoteDecimals) + : limitUsdcUnits / 10n ** BigInt(quoteDecimals - decimals); +} + +function validateStableSwap( + action: StableSwapAction, + obs: AgentObservation, + balances: BalanceSnapshot, +): ValidationResult { + const amountIn = BigInt(action.amountIn); + if (amountIn <= 0n) return { ok: false, reason: "amountIn must be positive" }; + const market = stableMarketBySymbol(action.stable); + if (!market) + return { ok: false, reason: `no market for stable ${action.stable}` }; + const sellingStable = action.tokenIn === market.symbol; + // Both legs are dollars, so both are capped by the shared per-round USDC limit -- but the limit is + // denominated in USDC's six decimals and a stable need not share them. Comparing 18-decimal DAI + // wei against it rejected every unwind while letting every buy through, so an agent could open a + // position it was structurally unable to close: measured at 42 rejected sells against 6 accepted + // buys, and the resulting "profit" was a mark on a position that never came back. + const maxIn = scaleUsdcLimit( + BigInt(obs.limits.maxUsdcInUnits), + sellingStable ? market.decimals : TOKENS.USDC.decimals, + ); + if (maxIn > 0n && amountIn > maxIn) + return { ok: false, reason: "amountIn exceeds configured per-round limit" }; + const balance = sellingStable + ? stableBalanceOf(balances, market.token) + : stableBalanceOf(balances, TOKENS.USDC.address); + if (amountIn > balance) + return { ok: false, reason: "amountIn exceeds balance" }; + return { ok: true }; +} + function validate( action: LeafAction, obs: AgentObservation, balances: BalanceSnapshot, ): ValidationResult { + if (action.type === "stableSwap") + return validateStableSwap(action, obs, balances); if (action.type !== "curveSwap") return { ok: false, reason: "not a curve action" }; const amountIn = BigInt(action.amountIn); @@ -335,6 +432,37 @@ async function buildSwapTx( }; } +// Both coins of a stableswap-ng plain pool, quoted the same way a curveSwap is: read the executable +// output, then bound it by the caller's slippage. `stableIndex`/`quoteIndex` come from the registry, +// so an agent never has to know which coin the deploy put first. +async function buildStableSwapTx( + publicClient: PublicClient, + action: StableSwapAction, +): Promise { + const market = stableMarketBySymbol(action.stable); + if (!market) throw new Error(`stableSwap: no market for ${action.stable}`); + const amountIn = BigInt(action.amountIn); + const [i, j] = + action.tokenIn === market.symbol + ? [market.stableIndex, market.quoteIndex] + : [market.quoteIndex, market.stableIndex]; + const quoted = (await publicClient.readContract({ + address: market.pool, + abi: curveStableSwapNgAbi, + functionName: "get_dy", + args: [BigInt(i), BigInt(j), amountIn], + })) as bigint; + const minDy = applySlippage(quoted, action.slippageBps ?? 50); + return { + to: market.pool, + data: encodeFunctionData({ + abi: curveStableSwapNgAbi, + functionName: "exchange", + args: [BigInt(i), BigInt(j), amountIn, minDy], + }), + }; +} + export const curveAdapter: ProtocolAdapter = { id: "curve", stableToken: CURVE.usdcToken, @@ -374,6 +502,8 @@ export const curveAdapter: ProtocolAdapter = { }, async buildTxs(ctx, _owner, action): Promise { + if (action.type === "stableSwap") + return [await buildStableSwapTx(ctx.publicClient, action)]; if (action.type !== "curveSwap") throw new Error("curve buildTxs: unexpected action"); const market = resolveMarket("curve", action); @@ -429,6 +559,7 @@ export const curveAdapter: ProtocolAdapter = { }); const fairByBase = ctx.fairByBase(); + const stablePrices = ctx.stablePrices(); const out: Record = {}; ctx.agents.forEach((agent, a) => { let valueUsdc = 0; @@ -445,7 +576,12 @@ export const curveAdapter: ProtocolAdapter = { }); return; } - const share = poolShareValueUsdc(pool, balance, fairByBase); + const share = poolShareValueUsdc( + pool, + balance, + fairByBase, + stablePrices, + ); valueUsdc += share.valueUsdc; for (const h of share.unpriced) unpriced.push({ ...h, source: "curve-lp" }); @@ -478,6 +614,12 @@ export const curveAdapter: ProtocolAdapter = { approve(tokenInfo(m.base).address, leg.pool); approve(leg.stable, leg.pool); } + // Issue #27 (c): the stable/stable pools, both legs. Without these a stableSwap reverts on the + // transfer and the depeg is untradable for everyone equally, which reads as "nobody found it". + for (const m of marketPricedStables()) { + approve(m.token, m.pool); + approve(TOKENS.USDC.address, m.pool); + } return txs; }, }; diff --git a/sdk/src/protocols/liquity.ts b/sdk/src/protocols/liquity.ts index 64cf8de..3402780 100644 --- a/sdk/src/protocols/liquity.ts +++ b/sdk/src/protocols/liquity.ts @@ -22,11 +22,14 @@ // `WETH.withdraw` before the call. The scorer already prices loose native ETH, so this is not a // valuation gap -- but it is a gas interaction, and the observation surfaces the remaining headroom. // -// *eUSD is never worth $1 by assumption.* It is deliberately absent from the token registry, because -// registering it as a stable would have the scorer's spot sweep price it at par -- and a CDP -// stablecoin trading at 0.97 marked at 1.00 hands every holder phantom value, which is precisely -// what makes the redemption arb look profitable before it has been done. Everything here marks eUSD -// at what the eUSD/USDC pool would actually pay. +// *eUSD is never worth $1 by assumption.* A CDP stablecoin trading at 0.97 marked at 1.00 hands +// every holder phantom value, which is precisely what makes the redemption arb look profitable +// before it has been done. eUSD used to be kept out of the token registry to guarantee that, because +// the registry priced anything of kind "stable" at par. Issue #27 removed the reason: registry +// stables are priced from their market now, so eUSD *is* a registry stable and the shared probe +// (stables.ts) is the single owner of what it is worth. This adapter reads that price rather than +// asserting one, and rather than probing the pool a second time -- two owners of one number is the +// double-counting hazard promoting it had to avoid. import { encodeFunctionData, formatUnits, @@ -79,16 +82,13 @@ import type { ValuationRun, } from "./types.js"; import { approveTx } from "./uniswap.js"; +import { readStablePrices, stablePriceUsdc } from "../stables.js"; const DECIMAL_INTEGER = /^[0-9]+$/; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as Address; const WAD = 10n ** 18n; const USDC_DECIMALS = 6; -// Probe size for the two-sided market quote: big enough to be a real trade against a 100k pool, -// small enough that it reports the pool's price rather than its own footprint. -const PROBE_EUSD_WEI = 1_000n * WAD; - // Slippage bound on the protocol's own fee curves when an action does not say. Both fees rise with // use, so a tight bound reverts on exactly the busy blocks an agent most wants to act. const DEFAULT_MAX_FEE_BPS = 500; @@ -208,36 +208,6 @@ export function discountBpsFrom(marketPriceUsdc: number): number { return (1 - marketPriceUsdc) * 10_000; } -// USDC per eUSD from a raw quote pair. -function usdcPerEusd(eusdIn: bigint, usdcOut: bigint): number { - if (eusdIn <= 0n) return 0; - return ( - Number(formatUnits(usdcOut, USDC_DECIMALS)) / - Number(formatUnits(eusdIn, 18)) - ); -} - -async function quote( - publicClient: PublicClient, - pool: Address, - i: number, - j: number, - dx: bigint, -): Promise { - try { - return (await publicClient.readContract({ - address: pool, - abi: curveStableSwapNgAbi, - functionName: "get_dy", - args: [BigInt(i), BigInt(j), dx], - })) as bigint; - } catch { - // A quote the pool refuses is "no market at this size", not a price of zero. Zero would read as - // a 10000bps discount -- an infinite free arb -- which is the failure mode issue #38 hit first. - return undefined; - } -} - export async function getLiquityState( ctx: SimContext, fairPrice: number, @@ -471,30 +441,15 @@ async function readMarket(publicClient: PublicClient): Promise<{ }; } const market = requireEusdMarket(); - const sellOut = await quote( - publicClient, - market.pool, - market.eusdIndex, - market.usdcIndex, - PROBE_EUSD_WEI, - ); - const sellPriceUsdc = sellOut ? usdcPerEusd(PROBE_EUSD_WEI, sellOut) : 0; - let buyPriceUsdc = 0; - if (sellOut && sellOut > 0n) { - const buyOut = await quote( - publicClient, - market.pool, - market.usdcIndex, - market.eusdIndex, - sellOut, - ); - if (buyOut && buyOut > 0n) buyPriceUsdc = usdcPerEusd(buyOut, sellOut); - } - const midPriceUsdc = - sellPriceUsdc > 0 && buyPriceUsdc > 0 - ? Math.sqrt(sellPriceUsdc * buyPriceUsdc) - : sellPriceUsdc; - const marketQuoted = midPriceUsdc > 0; + // The peg's price is the registry's now (issue #27 (b)): eUSD is a market-priced stable, so the + // same two-sided probe answers here and in the scorer's cross-section. One owner for the number, + // which is what keeps an agent's observation and its score from disagreeing about a discount. + const quotes = await readStablePrices(publicClient, [l.eusd]); + const eusdQuote = quotes.quotes[0]; + const sellPriceUsdc = eusdQuote?.sellPriceUsdc ?? 0; + const buyPriceUsdc = eusdQuote?.buyPriceUsdc ?? 0; + const midPriceUsdc = eusdQuote?.quoted ? eusdQuote.priceUsdc : 0; + const marketQuoted = Boolean(eusdQuote?.quoted); let reserves: { eusd: bigint; usdc: bigint } | undefined; try { @@ -1307,13 +1262,17 @@ async function buildTxs( // --------------------------------------------------------------------------- // What an agent holds on this venue, before it is priced. +// +// The wallet's loose eUSD is deliberately absent. Since issue #27 (b) eUSD is a registry stable and +// the scorer's spot sweep prices it from the same market this adapter reads, so counting it here +// too would value it twice -- the hazard TokenKind "lst" exists to prevent for the LST share token +// (#38). What stays is what only this venue knows about: the Trove and the Stability Pool. type LiquityHoldings = { collWei: bigint; debtEusdWei: bigint; netDebtEusdWei: bigint; spDepositEusdWei: bigint; spEthGainWei: bigint; - eusdBalanceWei: bigint; }; /// Price a Liquity position, given what eUSD is worth. @@ -1336,7 +1295,7 @@ export function liquityPositionValue(input: { debtBuybackUsdc?: number; }): { valueUsdc: number; liquidatableValueUsdc: number } { const { holdings: h, fairPriceUsd, eusdPriceUsdc } = input; - const longEusd = toFloat(h.eusdBalanceWei + h.spDepositEusdWei); + const longEusd = toFloat(h.spDepositEusdWei); const collUsd = toFloat(h.collWei) * fairPriceUsd; const gainUsd = toFloat(h.spEthGainWei) * fairPriceUsd; const netDebtEusd = toFloat(h.netDebtEusdWei); @@ -1385,16 +1344,6 @@ export async function* liquityValuationRun( abi: troveManagerAbi, functionName: "LUSD_GAS_COMPENSATION", }, - ...(hasMarket - ? [ - { - address: pool, - abi: curveStableSwapNgAbi, - functionName: "get_dy", - args: [eusdIndex, usdcIndex, PROBE_EUSD_WEI], - }, - ] - : []), ...ctx.agents.flatMap((a) => [ { address: deployment.troveManager, @@ -1414,36 +1363,19 @@ export async function* liquityValuationRun( functionName: "getDepositorETHGain", args: [a.address], }, - { - address: deployment.eusd, - abi: erc20Abi, - functionName: "balanceOf", - args: [a.address], - }, ]), ]; const results = yield stage0; const gasCompensation = typeof results[0] === "bigint" ? (results[0] as bigint) : 0n; - const probeOut = - hasMarket && typeof results[1] === "bigint" - ? (results[1] as bigint) - : undefined; - const perAgentBase = hasMarket ? 2 : 1; const holdings = ctx.agents.map((_agent, i) => { - const base = perAgentBase + i * 4; + const base = 1 + i * 3; const entire = results[base] as readonly [bigint, bigint, bigint, bigint] | undefined; const spDeposit = results[base + 1]; const spGain = results[base + 2]; - const eusd = results[base + 3]; - if ( - !entire || - typeof spDeposit !== "bigint" || - typeof spGain !== "bigint" || - typeof eusd !== "bigint" - ) + if (!entire || typeof spDeposit !== "bigint" || typeof spGain !== "bigint") return undefined; const [debt, coll] = entire; return { @@ -1452,21 +1384,29 @@ export async function* liquityValuationRun( netDebtEusdWei: debt > gasCompensation ? debt - gasCompensation : 0n, spDepositEusdWei: spDeposit, spEthGainWei: spGain, - eusdBalanceWei: eusd, } satisfies LiquityHoldings; }); - // The mid, from the probe alone. Only the sell side is probed here: the second stage already costs - // a round trip for the sizes that matter, and pairing it with a buy quote of the same notional - // would double the reads on every block for a second decimal place. - const probeMid = probeOut ? usdcPerEusd(PROBE_EUSD_WEI, probeOut) : undefined; + // The mid comes from the registry, which probed this same pool both ways in stage 0 (issue #27). + // The adapter used to run its own one-sided probe here; two owners of one price is exactly what + // promoting eUSD into the registry had to remove. + const stablePrices = ctx.stablePrices(); + // A deploy with no curve factory has no market for the peg at all, which reads the same way as a + // market that would not quote: the mark is par by assumption either way, and either way it is + // said out loud rather than assumed. + const marketQuoted = + hasMarket && + !stablePrices.unquoted.some( + (m) => m.token.toLowerCase() === deployment.eusd.toLowerCase(), + ); + const eusdPriceUsdc = stablePriceUsdc(stablePrices, deployment.eusd); // Own-size quotes, for exactly the agents whose position has a size worth quoting. const longTargets: number[] = []; const debtTargets: number[] = []; holdings.forEach((h, i) => { if (!h) return; - if (h.eusdBalanceWei + h.spDepositEusdWei > 0n) longTargets.push(i); + if (h.spDepositEusdWei > 0n) longTargets.push(i); if (h.netDebtEusdWei > 0n) debtTargets.push(i); }); let quotes: unknown[] = []; @@ -1476,11 +1416,7 @@ export async function* liquityValuationRun( address: pool, abi: curveStableSwapNgAbi, functionName: "get_dy", - args: [ - eusdIndex, - usdcIndex, - holdings[i]!.eusdBalanceWei + holdings[i]!.spDepositEusdWei, - ], + args: [eusdIndex, usdcIndex, holdings[i]!.spDepositEusdWei], })), ...debtTargets.map((i): ValuationRead => ({ address: pool, @@ -1526,23 +1462,24 @@ export async function* liquityValuationRun( return; } const unpriced: UnpricedHoldingDetail[] = []; - const exposure = h.eusdBalanceWei + h.spDepositEusdWei + h.netDebtEusdWei; - if (probeMid === undefined && exposure > 0n) { + const exposure = h.spDepositEusdWei + h.netDebtEusdWei; + if (!marketQuoted && exposure > 0n) { // Falling back to par is the least wrong choice -- par is the value the protocol itself // enforces through redemption -- but it is exactly the assumption this venue must not make - // silently, so the eUSD leg is reported as marked without a market. + // silently, so the eUSD leg is reported as marked without a market. The wallet's loose eUSD + // is reported by the scorer's spot sweep, which prices it now. unpriced.push({ token: deployment.eusd, amountRaw: exposure.toString(), source: "liquity-eusd-market", - reason: "read-failed", + reason: "par-fallback", read: "CurveStableSwapNG.get_dy", }); } const value = liquityPositionValue({ holdings: h, fairPriceUsd: fairWeth, - eusdPriceUsdc: probeMid ?? 1, + eusdPriceUsdc, longExitUsdc: longExitByIndex.get(i), debtBuybackUsdc: debtCostByIndex.get(i), }); @@ -1579,7 +1516,9 @@ export const liquityAdapter: ProtocolAdapter = { if (!LIQUITY) return 0; const d = LIQUITY; const s = state as LiquityState | undefined; - const [entire, spDeposit, spGain, eusdBalance, gasCompensation] = + // The wallet's loose eUSD is not read here: it is registry spot now, swept and priced by the + // caller (issue #27 (b)). What is left is the Trove and the Stability Pool. + const [entire, spDeposit, spGain, gasCompensation] = (await Promise.all([ read( ctx.publicClient, @@ -1602,7 +1541,6 @@ export const liquityAdapter: ProtocolAdapter = { "getDepositorETHGain", [agent], ), - read(ctx.publicClient, d.eusd, erc20Abi, "balanceOf", [agent]), read( ctx.publicClient, d.troveManager, @@ -1614,7 +1552,6 @@ export const liquityAdapter: ProtocolAdapter = { bigint, bigint, bigint, - bigint, ]; const [debt, coll] = entire; const eusdPriceUsdc = s?.marketQuoted ? s.midPriceUsdc : 1; @@ -1625,7 +1562,6 @@ export const liquityAdapter: ProtocolAdapter = { netDebtEusdWei: debt > gasCompensation ? debt - gasCompensation : 0n, spDepositEusdWei: spDeposit, spEthGainWei: spGain, - eusdBalanceWei: eusdBalance, }, fairPriceUsd: fairPrice, eusdPriceUsdc, @@ -1645,9 +1581,10 @@ export const liquityAdapter: ProtocolAdapter = { }, async accountedTokens(): Promise { - // eUSD is valued above. LQTY deliberately is not listed: the venue issues it (Stability Pool - // deposits accrue it) but nothing values it, and issue #41's convention is that such a token - // stays visible as an unaccounted holding rather than being quietly excused. + // eUSD is swept as a registry stable and the Trove / Stability Pool legs are valued above. LQTY + // deliberately is not listed: the venue issues it (Stability Pool deposits accrue it) but + // nothing values it, and issue #41's convention is that such a token stays visible as an + // unaccounted holding rather than being quietly excused. return LIQUITY ? [LIQUITY.eusd] : []; }, @@ -1666,5 +1603,4 @@ export const liquityAdapter: ProtocolAdapter = { }, }; -export const LIQUITY_PROBE_EUSD_WEI = PROBE_EUSD_WEI; export const LIQUITY_GAS_RESERVE_WEI = SUGGESTED_GAS_RESERVE_WEI; diff --git a/sdk/src/protocols/oracles.ts b/sdk/src/protocols/oracles.ts index 7eaee99..d3670ad 100644 --- a/sdk/src/protocols/oracles.ts +++ b/sdk/src/protocols/oracles.ts @@ -8,6 +8,7 @@ import { setStorageAt, } from "../chain.js"; import { lstVaultAbi } from "../abis.js"; +import { marketPricedStables, readStablePrices } from "../stables.js"; import type { SimContext } from "./types.js"; import { mockAggregatorAbi, toAavePrice } from "./aave.js"; @@ -65,6 +66,39 @@ function extraAaveAggregators( return out; } +// Issue #27 (c): the Aave price of a *market-priced* stable. Aave marks every stable at $1 from a +// mock aggregator, which is the same assertion the scorer stopped making -- so a stable used as +// collateral would keep its full borrowing power all the way through a depeg while the wallet's +// copy of the same token was marked down. Reading it off the same probe the scorer uses keeps the +// two consistent. +// +// Empty unless a market-priced stable is actually listed as an Aave reserve. Nothing is today +// (aaveReserveSymbols() is the bases plus USDC plus the LST), so this costs one pool read and +// nothing else; it activates the day one is listed rather than being discovered then. +async function stableAaveAggregators( + ctx: SimContext, +): Promise> { + const markets = marketPricedStables(); + const listed = markets.filter( + (m) => ctx.oracle.aaveAggregators[m.token.toLowerCase()], + ); + if (listed.length === 0) return []; + const prices = await readStablePrices( + ctx.publicClient, + listed.map((m) => m.token), + ); + const out: Array<{ aggregator: Address; aavePrice: bigint }> = []; + for (const m of listed) { + const price = prices.byToken[m.token.toLowerCase()]; + if (!Number.isFinite(price) || !(price > 0)) continue; + out.push({ + aggregator: ctx.oracle.aaveAggregators[m.token.toLowerCase()], + aavePrice: toAavePrice(price), + }); + } + return out; +} + // At the start of each round, make the GMX/Aave mock prices track fairPrice. // Price updates are done with coordinator-privileged txs (in a separate block from the competition block). export async function updateOracles( @@ -112,7 +146,11 @@ export async function updateOracles( } // ADR 0013: also track additional bases' (WBTC etc.) Aave aggregators. Empty loop on the default fork. - for (const { aggregator, aavePrice } of extraAaveAggregators(ctx)) { + // Issue #27 (c): and any market-priced stable Aave lists, at what its pool says rather than $1. + for (const { aggregator, aavePrice } of [ + ...extraAaveAggregators(ctx), + ...(await stableAaveAggregators(ctx)), + ]) { await sendAndMine( ctx.publicClient, ctx.walletClient, @@ -214,7 +252,11 @@ export async function updateOraclesMempool( ); } // ADR 0013: also update additional bases' (WBTC etc.) Aave aggregators via the mempool. Empty loop on the default fork. - for (const { aggregator, aavePrice } of extraAaveAggregators(ctx)) { + // Issue #27 (c): and any market-priced stable Aave lists. + for (const { aggregator, aavePrice } of [ + ...extraAaveAggregators(ctx), + ...(await stableAaveAggregators(ctx)), + ]) { hashes.push( await sendNoMine( ctx.publicClient, @@ -294,7 +336,11 @@ export async function writeAaveOraclesStorage( ); } // ADR 0013: also write additional bases' (WBTC etc.) Aave aggregators to storage directly. Empty loop on the default fork. - for (const { aggregator, aavePrice } of extraAaveAggregators(ctx)) { + // Issue #27 (c): and any market-priced stable Aave lists. + for (const { aggregator, aavePrice } of [ + ...extraAaveAggregators(ctx), + ...(await stableAaveAggregators(ctx)), + ]) { await setStorageAt( ctx.publicClient, aggregator, diff --git a/sdk/src/protocols/registry.ts b/sdk/src/protocols/registry.ts index b106a60..2615097 100644 --- a/sdk/src/protocols/registry.ts +++ b/sdk/src/protocols/registry.ts @@ -11,6 +11,7 @@ import { lstAdapter } from "./lst.js"; import { liquityAdapter } from "./liquity.js"; import { activeBaseSymbols, tokenInfo } from "../markets.js"; import { setEnabledProtocolIds } from "./enabled.js"; +import { stablesForProtocols } from "../stables.js"; // All adapters (only implemented ones are registered). Added as phases progress. const ALL_ADAPTERS: ProtocolAdapter[] = [ @@ -58,9 +59,16 @@ export function enabledAdapters(): ProtocolAdapter[] { export function initProtocols(ids: ProtocolId[]): ProtocolAdapter[] { setEnabledProtocols(ids); const adapters = enabledAdapters(); - setActiveStables( - adapters.map((a) => a.stableToken).filter((t): t is Address => Boolean(t)), - ); + setActiveStables([ + // Each venue's USDC-equivalent leg... + ...adapters + .map((a) => a.stableToken) + .filter((t): t is Address => Boolean(t)), + // ...plus every stable this run's venues bring with them, which are swept and priced from their + // own market rather than assumed to be dollars (issue #27). eUSD arrives with liquity, a + // stableswap-paired stable with curve. + ...stablesForProtocols(enabledIds).map((m) => m.token), + ]); // ADR 0013: register the enabled protocols' bases (WETH + additional bases) into ACTIVE_BASES. getBalances // reads all base balances and feeds observation (baseBalances) and scoring. [WETH] on the default fork (matches prior behavior). setActiveBases( diff --git a/sdk/src/protocols/types.ts b/sdk/src/protocols/types.ts index 8e3380f..4d7e0b5 100644 --- a/sdk/src/protocols/types.ts +++ b/sdk/src/protocols/types.ts @@ -2,6 +2,7 @@ import type { Address, Hex, PublicClient, WalletClient } from "viem"; import type { makeChain } from "../chain.js"; import type { SimConfig } from "../config.js"; import type { Rng } from "../rng.js"; +import type { StablePrices } from "../stables.js"; import type { UnpricedAmount } from "../valuation.js"; import type { AgentObservation, @@ -105,11 +106,16 @@ export type ValuationContext = { // #38). Equals blockNumber when the caller has no horizon, which is the conservative reading. horizonBlock: number; agents: readonly ValuationAgent[]; - // The stables the run already sums as USDC-equivalent spot. + // The stables the run sweeps as spot. activeStables: readonly Address[]; // USD price per base symbol. The prices are themselves read in the first stage, so this is only // populated once that stage returns: call it when computing values, never when choosing reads. fairByBase(): Record; + // What each market-priced stable is worth in USDC at this cross-section (issue #27). One owner + // for the number: the scorer probes each stable's market in stage 0 and every venue that names a + // stable leg reads it from here, so nothing prices the same token twice or differently. Same + // "populated after stage 0" contract as fairByBase. + stablePrices(): StablePrices; }; // A holding the adapter could not fold into its value, either because it cannot be priced (#41) or diff --git a/sdk/src/protocols/uniswap.ts b/sdk/src/protocols/uniswap.ts index 00eac2d..a51a7da 100644 --- a/sdk/src/protocols/uniswap.ts +++ b/sdk/src/protocols/uniswap.ts @@ -25,6 +25,7 @@ import { type MarketConfig, } from "../markets.js"; import { tokenAmountUsd, type UnpricedAmount } from "../valuation.js"; +import type { StablePrices } from "../stables.js"; import { resolveMarket } from "./marketHelpers.js"; import type { AgentObservation, @@ -839,8 +840,12 @@ export function positionPoolKey( export type LpValuationContext = { // lowercased pool address -> current tick. tickByPool: Record; - // base symbol -> USD price. Stables are $1 and do not appear here. + // base symbol -> USD price. Stables do not appear here: they are quoted against USDC, not + // against a USD fair-price feed, so their prices come from stablePrices (issue #27). fairByBase: Record; + // Market-priced stables (issue #27). Omitted -> every stable leg is valued at par, which is the + // right answer for a run whose only stables are USDC-equivalents. + stablePrices?: StablePrices; // positionPoolKey -> pool address, for pools outside the registered market set. poolByKey?: Record; // Issue #21: pool fee growth keyed by lowercased pool address. Omitted -> fees are not marked. @@ -930,7 +935,12 @@ export function lpPositionValuation( }); } for (const [token, amount] of totals) { - const usd = tokenAmountUsd(token, amount, ctx.fairByBase); + const usd = tokenAmountUsd( + token, + amount, + ctx.fairByBase, + ctx.stablePrices, + ); if (usd === undefined) { if (amount > 0n) unpriced.push({ token, amountRaw: amount.toString() }); continue; @@ -1444,6 +1454,7 @@ export const uniswapAdapter: ProtocolAdapter = { } const fairByBase = ctx.fairByBase(); + const stablePrices = ctx.stablePrices(); const out = unreadableCount(zero()); owners.forEach(({ agentId, index }, j) => { const raw = positions[j]; @@ -1464,6 +1475,7 @@ export const uniswapAdapter: ProtocolAdapter = { const valuation = lpPositionValuation(raw as PositionTuple, { tickByPool, fairByBase, + stablePrices, poolByKey, feeGrowthByPool, }); diff --git a/sdk/src/stables.ts b/sdk/src/stables.ts new file mode 100644 index 0000000..bd0e98d --- /dev/null +++ b/sdk/src/stables.ts @@ -0,0 +1,286 @@ +// Market-priced stables (issue #27). +// +// The registry used to make a stablecoin worth a dollar by saying so. `chain.ts` summed every active +// stable into one `usdcUnits` figure before anything valued it, and `valuation.ts` priced any token +// of kind "stable" at exactly 1. After that sum no agent could hold an opinion about a stablecoin's +// price and no scorer could either -- a depegged stable was still scored at par, which is the +// phantom-value failure the eUSD adapter was kept *out* of the registry to avoid (#39). +// +// A stable with a market leg here is priced from that market instead. Three properties matter: +// +// two-sided One side of a stableswap book is a price you can only get by selling into it. +// Marking a holding at it understates the holding as reliably as par overstates it, +// so the mark is the geometric mean of both executable directions -- the mid an +// unwind actually straddles. Same discipline as the LST and Liquity adapters. +// one stage Both probes are fixed-notional, so they are independent reads. Chaining them +// (sell, then buy back the proceeds) needs a second round trip, and the scorer +// batches one multicall per block cross-section (ADR 0006 §4). +// says so A market that will not quote falls back to par -- par is the anchor a CDP's +// redemption or an issuer's mint/burn actually enforces, so it is the least wrong +// number -- but the fallback is reported. A silent par is the bug; a silent zero +// would be worse still, reading as a 100% discount and an infinite free arb. +// +// A stable *without* a market leg keeps the unified USDC-equivalent convention. USDC is that by +// definition -- it is the numéraire and the unit every competition metric is denominated in (issue +// #27, "Settled") -- and on the Arbitrum fork USDC.e and USD₮0 still are, for want of a pool to +// quote them against it. +import { formatUnits, type Address, type PublicClient } from "viem"; +import { curveStableSwapNgAbi } from "./abis.js"; +import { STABLE_MARKET_LEGS, TOKENS } from "./constants.js"; +import { tokenInfoByAddress } from "./markets.js"; +import type { ProtocolId, TokenSymbol } from "./types.js"; + +// A stable and the pool that quotes it against the run's USDC. +export type StableMarket = { + symbol: TokenSymbol; + token: Address; + decimals: number; + // The protocol that brings this stable into a run (see StableMarketLeg.venue). + venue: ProtocolId; + // Curve stableswap-ng. Every market-priced stable in this environment trades on one, because that + // is the pool shape a peg lives on: eUSD/USDC (#39) and USDT/USDC both. + pool: Address; + stableIndex: number; + quoteIndex: number; + // Probe notional per side, in each coin's own units. Big enough to be a real trade against the + // seeded depth, small enough to report the pool's price rather than its own footprint. + probeStableUnits: bigint; + probeQuoteUnits: bigint; +}; + +// What one probe pair said about one stable. +export type StableQuote = { + symbol: TokenSymbol; + token: Address; + // USDC per unit of the stable, and the two executable directions it came from. priceUsdc is 1 + // when `quoted` is false -- see the fallback note above. + priceUsdc: number; + sellPriceUsdc: number; + buyPriceUsdc: number; + // False means priceUsdc is par by fallback rather than an observation of the market. + quoted: boolean; +}; + +export type StablePrices = { + // Lowercase token address -> USDC per unit. Always usable: an unquoted market resolves to par + // here and is named in `unquoted`, because dropping the balance would be a bigger lie than par. + byToken: Record; + // Markets that would not quote at this block. Reported by the caller that has somewhere to report. + unquoted: StableMarket[]; + quotes: StableQuote[]; +}; + +// The prices of a run with no market-priced stable: every stable is the USDC-equivalent dollar. +export const PAR_STABLE_PRICES: StablePrices = { + byToken: {}, + unquoted: [], + quotes: [], +}; + +// Default probe notional, in dollars. 1,000 against the 100k/100k pools this environment seeds is +// ~1% of depth: a real trade, and small enough that the two directions bracket the mid closely. +const DEFAULT_PROBE_USD = 1_000n; + +function probeUnits(decimals: number): bigint { + return DEFAULT_PROBE_USD * 10n ** BigInt(decimals); +} + +// Every stable the deployment gave a market, in registry order. Optionally narrowed to a set of +// tokens (the run's active stables), so a venue that is deployed but not enabled costs no reads. +export function marketPricedStables( + tokens?: readonly Address[], +): StableMarket[] { + const wanted = tokens + ? new Set(tokens.map((t) => t.toLowerCase())) + : undefined; + const quoteDecimals = TOKENS.USDC.decimals; + const out: StableMarket[] = []; + for (const [symbol, leg] of Object.entries(STABLE_MARKET_LEGS)) { + const info = TOKENS[symbol]; + // A market leg naming a token the registry does not carry is a generation bug, not a run-time + // condition: skip it rather than crash a run over a diagnostic price. + if (!info) continue; + // USDC is the numéraire and is $1 by definition (issue #27, "Settled"). Letting a pool price it + // would change what every past run's numbers mean, and every metric is denominated in it -- so + // a market leg naming it is ignored here rather than quietly redefining the unit. + if (info.address.toLowerCase() === TOKENS.USDC.address.toLowerCase()) + continue; + if (wanted && !wanted.has(info.address.toLowerCase())) continue; + out.push({ + symbol, + token: info.address, + decimals: info.decimals, + venue: leg.venue, + pool: leg.pool, + stableIndex: leg.stableIndex, + quoteIndex: leg.quoteIndex, + probeStableUnits: probeUnits(info.decimals), + probeQuoteUnits: probeUnits(quoteDecimals), + }); + } + return out; +} + +// The market-priced stables a run actually gets: those whose owning venue is enabled. A stable +// whose venue is switched off is not swept, not probed, and not tradable -- all three together, +// which is the only combination that is coherent. +export function stablesForProtocols( + protocols: readonly ProtocolId[], +): StableMarket[] { + const enabled = new Set(protocols); + return marketPricedStables().filter((m) => enabled.has(m.venue)); +} + +export function stableMarketFor(token: Address): StableMarket | undefined { + const target = token.toLowerCase(); + return marketPricedStables().find((m) => m.token.toLowerCase() === target); +} + +// True when this token is a dollar by convention rather than by measurement: USDC itself, or a +// registry stable no pool quotes. Funding grants these and only these -- see fundWallet. +export function isParStable(token: Address): boolean { + return stableMarketFor(token) === undefined; +} + +// One contract read inside a batched cross-section multicall. Structurally the scorer's +// ValuationRead; declared here so the sdk's pricing layer does not depend on the protocol types. +export type StableProbeRead = { + address: Address; + // biome-ignore lint/suspicious/noExplicitAny: heterogeneous ABIs share a single multicall + abi: any; + functionName: string; + args?: readonly unknown[]; +}; + +// Two reads per market, in a fixed order the decode below mirrors. +export function stableProbeReads( + markets: readonly StableMarket[], +): StableProbeRead[] { + const reads: StableProbeRead[] = []; + for (const m of markets) { + reads.push({ + address: m.pool, + abi: curveStableSwapNgAbi, + functionName: "get_dy", + args: [BigInt(m.stableIndex), BigInt(m.quoteIndex), m.probeStableUnits], + }); + reads.push({ + address: m.pool, + abi: curveStableSwapNgAbi, + functionName: "get_dy", + args: [BigInt(m.quoteIndex), BigInt(m.stableIndex), m.probeQuoteUnits], + }); + } + return reads; +} + +function ratio( + numerator: bigint, + numDecimals: number, + denominator: bigint, + denDecimals: number, +): number { + if (denominator <= 0n) return 0; + const d = Number(formatUnits(denominator, denDecimals)); + if (!(d > 0)) return 0; + return Number(formatUnits(numerator, numDecimals)) / d; +} + +// Turn one market's probe pair into a price. Exported for the tests that pin the marking rules +// without a deployed pool. +export function stableQuoteFrom( + market: StableMarket, + sellOut: bigint | undefined, + buyOut: bigint | undefined, + quoteDecimals: number, +): StableQuote { + // Sell: USDC received for a fixed amount of the stable. + const sellPriceUsdc = + sellOut === undefined + ? 0 + : ratio(sellOut, quoteDecimals, market.probeStableUnits, market.decimals); + // Buy: USDC paid per unit of the stable received for a fixed amount of USDC. + const buyPriceUsdc = + buyOut === undefined + ? 0 + : ratio(market.probeQuoteUnits, quoteDecimals, buyOut, market.decimals); + const priceUsdc = + sellPriceUsdc > 0 && buyPriceUsdc > 0 + ? Math.sqrt(sellPriceUsdc * buyPriceUsdc) + : sellPriceUsdc > 0 + ? sellPriceUsdc + : buyPriceUsdc; + return { + symbol: market.symbol, + token: market.token, + priceUsdc: priceUsdc > 0 ? priceUsdc : 1, + sellPriceUsdc, + buyPriceUsdc, + quoted: priceUsdc > 0, + }; +} + +// Decode the results of stableProbeReads, in the same order. +export function decodeStableProbes( + markets: readonly StableMarket[], + results: readonly unknown[], +): StablePrices { + const quoteDecimals = TOKENS.USDC.decimals; + const byToken: Record = {}; + const unquoted: StableMarket[] = []; + const quotes: StableQuote[] = []; + markets.forEach((market, i) => { + const sell = results[i * 2]; + const buy = results[i * 2 + 1]; + const quote = stableQuoteFrom( + market, + typeof sell === "bigint" ? sell : undefined, + typeof buy === "bigint" ? buy : undefined, + quoteDecimals, + ); + quotes.push(quote); + byToken[market.token.toLowerCase()] = quote.priceUsdc; + if (!quote.quoted) unquoted.push(market); + }); + return { byToken, unquoted, quotes }; +} + +// The live path: what the agent runtime's observation and the coordinator's end-of-run PnL use. +// Reads are issued together so the client's multicall batching folds them into one round trip. +export async function readStablePrices( + publicClient: PublicClient, + tokens?: readonly Address[], +): Promise { + const markets = marketPricedStables(tokens); + if (markets.length === 0) return PAR_STABLE_PRICES; + const reads = stableProbeReads(markets); + const results = await Promise.all( + reads.map((read) => + publicClient + .readContract({ + address: read.address, + abi: read.abi, + functionName: read.functionName, + ...(read.args ? { args: read.args } : {}), + } as never) + // A quote the pool refuses is "no market at this size", not a price of zero. + .catch(() => undefined), + ), + ); + return decodeStableProbes(markets, results); +} + +// USDC per unit of a stable. Par for the numéraire, for a stable no pool quotes, and for a market +// that did not answer -- the fallback the caller is expected to have already reported. +export function stablePriceUsdc( + prices: StablePrices | undefined, + token: Address, +): number { + return prices?.byToken[token.toLowerCase()] ?? 1; +} + +// The symbol a stable balance should be reported under. Falls back to the raw address for the +// fork's USDC.e / USD₮0, which the registry does not name. +export function stableKeyFor(token: Address): string { + return tokenInfoByAddress(token)?.symbol ?? token.toLowerCase(); +} diff --git a/sdk/src/types.ts b/sdk/src/types.ts index e40507d..0bd137d 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -105,6 +105,21 @@ export type CurveSwapAction = { maxPriorityFeePerGasWei?: string; }; +// Issue #27 (c): swap a market-priced registry stable against USDC on the stableswap pool that +// quotes it. Without it a depeg is something an agent can only watch: the registry knows what DAI is +// worth, and nothing lets anyone act on it. The same argument #39 made for liquitySwapEusd -- a +// venue whose α cannot be reached is not a venue. +export type StableSwapAction = { + type: "stableSwap"; + // The market-priced stable. USDC is the other leg, always: it is the numéraire. + stable: TokenSymbol; + // Which side is being spent -- the stable, or the USDC buying it. + tokenIn: TokenSymbol; + amountIn: string; + slippageBps?: number; + maxPriorityFeePerGasWei?: string; +}; + // Aave v3 export type AaveSupplyAction = { type: "aaveSupply"; @@ -274,6 +289,7 @@ export type BundleActionItem = | CollectFeesAction | BalancerSwapAction | CurveSwapAction + | StableSwapAction | AaveSupplyAction | AaveWithdrawAction | AaveBorrowAction @@ -641,7 +657,30 @@ export type AgentObservation = { balances: { ethWei: string; wethWei: string; + // Native USDC only, since issue #27. It is a *budget*, not a valuation: the summed figure it + // replaced could not be spent anywhere, because USDT is not accepted in a USDC pool. What the + // wallet is worth is inventory.valueUsdc. usdcUnits: string; + // Every stable the run holds, kept apart instead of summed (issue #27 (a) step 1). Keyed by + // registry symbol, or by raw address for the fork's USDC.e / USD₮0 which the registry does not + // name. This is where an agent sizes a specific venue's stable leg from -- and where it reads + // that a stablecoin is no longer trading at a dollar. + stables?: Record< + string, + { + token: string; + decimals: number; + // Raw balance, in the token's own units. + balance: string; + // USDC per unit. 1 for USDC (the numéraire, $1 by definition) and for any stable with no + // market to quote it. + priceUsdc: number; + // False means priceUsdc is par by convention or by fallback, not an observation of a + // market. A stable that is *meant* to be a dollar and has no market saying otherwise reads + // the same as one whose market went quiet -- so do not read `1` as "the peg is holding". + marketQuoted: boolean; + } + >; }; inventory: { valueUsdc: number; diff --git a/sdk/src/valuation.ts b/sdk/src/valuation.ts index 8df7bce..578e794 100644 --- a/sdk/src/valuation.ts +++ b/sdk/src/valuation.ts @@ -7,6 +7,7 @@ import { formatUnits, type Address } from "viem"; import { USDC_VARIANTS } from "./constants.js"; import { tokenInfoByAddress } from "./markets.js"; +import { stablePriceUsdc, type StablePrices } from "./stables.js"; // Stables the run settles in but the token registry does not name. On the Arbitrum fork the registry // is WETH/USDC only, while the deep Balancer and Curve pools hold USDC.e and USDT -- so a BPT holder @@ -23,14 +24,23 @@ function stableVariantDecimals(token: Address): number | undefined { return undefined; } -// Why a holding is missing from a value. "unpriced" means the amount is known but has no USD price; -// "read-failed" means the read that would have revealed the holding failed, so the holding is -// *unknown* rather than zero (issue #44); "unrealizable" means it is known and priceable but -// cannot be turned into anything before the run ends -- an LST redemption whose queue finalizes -// after the last block (issue #38). All three are excluded from the value and all three are -// reported — a zero in summary.json must never be mistaken for a trading loss. +// Why a holding is missing from a value, or in it on an assumption. +// +// "unpriced" means the amount is known but has no USD price; "read-failed" means the read that +// would have revealed the holding failed, so the holding is *unknown* rather than zero (issue #44); +// "unrealizable" means it is known and priceable but cannot be turned into anything before the run +// ends -- an LST redemption whose queue finalizes after the last block (issue #38). Those three are +// excluded from the value. +// +// "par-fallback" is the odd one out: the holding *is* counted, at $1, because its market would not +// quote (issue #27). Par is the least wrong number -- it is the price a CDP redemption or an +// issuer's mint/burn actually enforces -- but it is an assumption, and the whole point of pricing +// stables from a market is that this environment stops making it silently. +// +// All four are reported — a zero in summary.json must never be mistaken for a trading loss, and +// neither must a dollar. export type ScoringExclusionReason = - "unpriced" | "read-failed" | "unrealizable"; + "unpriced" | "read-failed" | "unrealizable" | "par-fallback"; // A holding left out of a value, and why. export type UnpricedAmount = { @@ -46,16 +56,21 @@ export type UnpricedAmount = { read?: string; }; -// USD value of a raw token amount. Stables are $1; bases use the run's fair price. Undefined means -// the token is outside the registry, i.e. unpriceable — not worthless. +// USD value of a raw token amount. Bases use the run's fair price; stables use what their market +// pays, falling back to par for the numéraire and for any stable no pool quotes (issue #27). +// Undefined means the token is outside the registry, i.e. unpriceable — not worthless. export function tokenAmountUsd( token: Address, amount: bigint, fairByBase: Record, + stablePrices?: StablePrices, ): number | undefined { const info = tokenInfoByAddress(token); if (info) { - const price = info.kind === "stable" ? 1 : fairByBase[info.symbol]; + const price = + info.kind === "stable" + ? stablePriceUsdc(stablePrices, token) + : fairByBase[info.symbol]; if (price === undefined) return undefined; return Number(formatUnits(amount, info.decimals)) * price; } @@ -80,6 +95,7 @@ export function poolShareValueUsdc( reserves: PoolReserves, lpBalance: bigint, fairByBase: Record, + stablePrices?: StablePrices, ): { valueUsdc: number; unpriced: UnpricedAmount[] } { if (lpBalance <= 0n || reserves.totalSupply <= 0n) return { valueUsdc: 0, unpriced: [] }; @@ -88,7 +104,12 @@ export function poolShareValueUsdc( for (let i = 0; i < reserves.tokens.length; i++) { const reserve = reserves.balances[i] ?? 0n; const amount = (reserve * lpBalance) / reserves.totalSupply; - const usd = tokenAmountUsd(reserves.tokens[i], amount, fairByBase); + const usd = tokenAmountUsd( + reserves.tokens[i], + amount, + fairByBase, + stablePrices, + ); if (usd === undefined) { if (amount > 0n) unpriced.push({ diff --git a/test/gmxMarketToken.test.ts b/test/gmxMarketToken.test.ts index 5801f9a..0935b05 100644 --- a/test/gmxMarketToken.test.ts +++ b/test/gmxMarketToken.test.ts @@ -8,6 +8,7 @@ import assert from "node:assert/strict"; import type { Address } from "viem"; import { gmxAdapter } from "@eris/sdk/protocols/gmx.js"; import { GMX_MARKETS, TOKENS } from "@eris/sdk/constants.js"; +import { PAR_STABLE_PRICES } from "@eris/sdk/stables.js"; const MARKET = GMX_MARKETS.ETH_USD; const AGENTS = [ @@ -38,6 +39,7 @@ async function drive(stages: Array<(reads: unknown[]) => unknown[]>) { agents: AGENTS, activeStables: [TOKENS.USDC.address as Address], fairByBase: () => FAIR, + stablePrices: () => PAR_STABLE_PRICES, }); const asked: unknown[][] = []; let input: unknown[] | undefined; @@ -233,6 +235,7 @@ test("a perp whose base has no fair price is reported, not scored at zero", asyn agents: AGENTS, activeStables: [TOKENS.USDC.address as Address], fairByBase: () => ({}), // price feed read failed for every base + stablePrices: () => PAR_STABLE_PRICES, }); const first = await run.next(); const done = await run.next([[position], [], marketProps, 0n, 0n] as never); diff --git a/test/liquity.test.ts b/test/liquity.test.ts index e9c2a37..6f275d7 100644 --- a/test/liquity.test.ts +++ b/test/liquity.test.ts @@ -24,6 +24,11 @@ import type { BalanceSnapshot, LiquityObservation, } from "@eris/sdk/types.js"; +import { + PAR_STABLE_PRICES, + type StableMarket, + type StablePrices, +} from "@eris/sdk/stables.js"; const WAD = 10n ** 18n; const USDC = 10n ** 6n; @@ -99,7 +104,6 @@ test("the gas compensation is not the borrower's liability", () => { netDebtEusdWei: 4000n * WAD, spDepositEusdWei: 0n, spEthGainWei: 0n, - eusdBalanceWei: 0n, }, fairPriceUsd: FAIR, eusdPriceUsdc: 1, @@ -108,14 +112,15 @@ test("the gas compensation is not the borrower's liability", () => { assert.equal(value.valueUsdc, 2000); }); -test("eUSD is marked at the market, not at the dollar it is named after", () => { +test("a Stability Pool deposit is marked at the market, not at the dollar it is named after", () => { + // The wallet's loose eUSD is the registry's to price since issue #27 (b); what this venue still + // owns is the deposit, and it moves with the peg the same way. const holdings = { collWei: 0n, debtEusdWei: 0n, netDebtEusdWei: 0n, - spDepositEusdWei: 1000n * WAD, + spDepositEusdWei: 10_000n * WAD, spEthGainWei: 0n, - eusdBalanceWei: 9000n * WAD, }; const atPar = liquityPositionValue({ holdings, @@ -128,7 +133,6 @@ test("eUSD is marked at the market, not at the dollar it is named after", () => eusdPriceUsdc: 0.97, }); assert.equal(atPar.valueUsdc, 10_000); - // 3% off par on 10,000 of eUSD, whether it sits in the wallet or in the Stability Pool. assert.equal(Math.round(depegged.valueUsdc), 9700); }); @@ -140,7 +144,6 @@ test("a depegged debt is cheaper to close, and the realizable mark says so", () netDebtEusdWei: 4000n * WAD, spDepositEusdWei: 0n, spEthGainWei: 0n, - eusdBalanceWei: 0n, }, fairPriceUsd: FAIR, eusdPriceUsdc: 0.98, @@ -154,15 +157,14 @@ test("a depegged debt is cheaper to close, and the realizable mark says so", () assert.ok(value.liquidatableValueUsdc < value.valueUsdc); }); -test("selling a large eUSD balance realizes less than the mid says", () => { +test("withdrawing a large Stability Pool deposit realizes less than the mid says", () => { const value = liquityPositionValue({ holdings: { collWei: 0n, debtEusdWei: 0n, netDebtEusdWei: 0n, - spDepositEusdWei: 0n, + spDepositEusdWei: 20_000n * WAD, spEthGainWei: 0n, - eusdBalanceWei: 20_000n * WAD, }, fairPriceUsd: FAIR, eusdPriceUsdc: 0.99, @@ -182,7 +184,6 @@ test("a Trove past 100% is worth zero, not a negative number", () => { netDebtEusdWei: 4000n * WAD, spDepositEusdWei: 0n, spEthGainWei: 0n, - eusdBalanceWei: 0n, }, fairPriceUsd: 2000, eusdPriceUsdc: 1, @@ -199,7 +200,6 @@ test("Stability Pool ETH gains are collateral the depositor already owns", () => netDebtEusdWei: 0n, spDepositEusdWei: 5000n * WAD, spEthGainWei: WAD / 2n, - eusdBalanceWei: 0n, }, fairPriceUsd: FAIR, eusdPriceUsdc: 1, @@ -219,10 +219,42 @@ function context(overrides: Partial = {}): ValuationContext { agents: [AGENT], activeStables: [], fairByBase: () => ({ WETH: FAIR }), + stablePrices: () => PAR_STABLE_PRICES, ...overrides, }; } +// The registry's view of eUSD (issue #27 (b)): the adapter reads the price from here instead of +// probing the pool itself, so a valuation test says what the market said rather than what a probe +// read returned. +const EUSD_MARKET: StableMarket = { + symbol: "eUSD", + token: DEPLOYMENT.eusd, + decimals: 18, + venue: "liquity", + pool: DEPLOYMENT.eusdUsdcPool as Address, + stableIndex: 0, + quoteIndex: 1, + probeStableUnits: 1_000n * WAD, + probeQuoteUnits: 1_000n * USDC, +}; + +function eusdAt(priceUsdc: number): StablePrices { + return { + byToken: { [DEPLOYMENT.eusd.toLowerCase()]: priceUsdc }, + unquoted: [], + quotes: [], + }; +} + +function eusdUnquoted(): StablePrices { + return { + byToken: { [DEPLOYMENT.eusd.toLowerCase()]: 1 }, + unquoted: [EUSD_MARKET], + quotes: [], + }; +} + // Drive the generator with canned stage results, recording what each stage asked for. async function drive( ctx: ValuationContext, @@ -247,25 +279,22 @@ function entire(debt: bigint, coll: bigint) { return [debt, coll, 0n, 0n] as const; } -test("the historical mark prices eUSD off the pool and the Trove off the fair price", async () => { - const { asked, values } = await drive(context(), [ - // stage 0: gas compensation, the market probe, then the agent's four position reads - () => [ - GAS_COMPENSATION, - // 1,000 eUSD probe fetches 990 USDC: 100bps below par. - 990n * USDC, - entire(4200n * WAD, 2n * WAD), - 0n, - 0n, - 1000n * WAD, +test("the historical mark prices eUSD off the registry and the Trove off the fair price", async () => { + // eUSD 100bps below par, as the scorer's shared probe measured it. + const { asked, values } = await drive( + context({ stablePrices: () => eusdAt(0.99) }), + [ + // stage 0: gas compensation, then the agent's three position reads + () => [GAS_COMPENSATION, entire(4200n * WAD, 2n * WAD), 1000n * WAD, 0n], + // stage 1: own-size quotes for the deposit and for buying the debt back + () => [990n * USDC, 3960n * USDC], ], - // stage 1: own-size quotes for the eUSD held and for buying the debt back - () => [990n * USDC, 3960n * USDC], - ]); - // Stage 0 asks for exactly one read per agent position plus the two globals. - assert.equal(asked[0].length, 6); + ); + // Stage 0 asks for one read per agent position plus the one global. The market probe is gone: the + // wallet's eUSD is registry spot and its price comes off ctx (issue #27 (b)). + assert.equal(asked[0].length, 4); const v = values[AGENT.id]; - // Trove 6000 - 4000 x 0.99, plus 1,000 eUSD at 0.99. + // Trove 6000 - 4000 x 0.99, plus a 1,000 eUSD deposit at 0.99. assert.equal(Math.round(v.valueUsdc), Math.round(6000 - 3960 + 990)); assert.equal( Math.round(v.liquidatableValueUsdc), @@ -274,16 +303,20 @@ test("the historical mark prices eUSD off the pool and the Trove off the fair pr assert.equal(v.unpriced.length, 0); }); +test("the wallet's eUSD is left to the registry, so nothing counts it twice", async () => { + // The agent holds nothing on the venue itself. Whatever eUSD is in its wallet is the scorer's + // spot sweep to price; this adapter must contribute exactly zero. + const { asked, values } = await drive( + context({ stablePrices: () => eusdAt(0.9) }), + [() => [GAS_COMPENSATION, entire(0n, 0n), 0n, 0n]], + ); + assert.equal(asked.length, 1); + assert.equal(values[AGENT.id].valueUsdc, 0); +}); + test("a failed position read is reported, not scored as zero", async () => { const { values } = await drive(context(), [ - () => [ - GAS_COMPENSATION, - 990n * USDC, - undefined, - undefined, - undefined, - undefined, - ], + () => [GAS_COMPENSATION, undefined, undefined, undefined], ]); const v = values[AGENT.id]; assert.equal(v.valueUsdc, 0); @@ -293,34 +326,29 @@ test("a failed position read is reported, not scored as zero", async () => { }); test("a market that will not quote falls back to par and says so", async () => { - const { values } = await drive(context(), [ - () => [ - GAS_COMPENSATION, - undefined, // the probe reverted - entire(0n, 0n), - 0n, - 0n, - 5000n * WAD, - ], - ]); + const { values } = await drive( + context({ stablePrices: () => eusdUnquoted() }), + [() => [GAS_COMPENSATION, entire(0n, 0n), 5000n * WAD, 0n]], + ); const v = values[AGENT.id]; // Par is the least wrong fallback -- it is the value the protocol enforces -- but silently // assuming it is exactly what this venue must never do, so the holding is reported. assert.equal(v.valueUsdc, 5000); assert.equal(v.unpriced.length, 1); assert.equal(v.unpriced[0].source, "liquity-eusd-market"); + assert.equal(v.unpriced[0].reason, "par-fallback"); assert.equal(v.unpriced[0].token, DEPLOYMENT.eusd); }); test("an agent with nothing on the venue costs no second-stage read", async () => { const { asked, values } = await drive(context(), [ - () => [GAS_COMPENSATION, 990n * USDC, entire(0n, 0n), 0n, 0n, 0n], + () => [GAS_COMPENSATION, entire(0n, 0n), 0n, 0n], ]); assert.equal(asked.length, 1); assert.equal(values[AGENT.id].valueUsdc, 0); }); -test("a deployment without a market skips the probe entirely", async () => { +test("a deployment without a market marks at par and says that too", async () => { const noMarket: LiquityDeployment = { ...DEPLOYMENT, eusdUsdcPool: undefined, @@ -329,11 +357,13 @@ test("a deployment without a market skips the probe entirely", async () => { }; const { asked, values } = await drive( context(), - [() => [GAS_COMPENSATION, entire(0n, 0n), 0n, 0n, 2000n * WAD]], + [() => [GAS_COMPENSATION, entire(0n, 0n), 2000n * WAD, 0n]], noMarket, ); - assert.equal(asked[0].length, 5); + // No market means no own-size quotes either, so there is no second stage to ask for. + assert.equal(asked.length, 1); assert.equal(values[AGENT.id].valueUsdc, 2000); + assert.equal(values[AGENT.id].unpriced[0].reason, "par-fallback"); }); // --------------------------------------------------------------------------- diff --git a/test/lst.test.ts b/test/lst.test.ts index c025861..0eceb90 100644 --- a/test/lst.test.ts +++ b/test/lst.test.ts @@ -23,6 +23,7 @@ import type { BalanceSnapshot, LstObservation, } from "@eris/sdk/types.js"; +import { PAR_STABLE_PRICES } from "@eris/sdk/stables.js"; const WAD = 10n ** 18n; const FAIR = 3000; @@ -180,6 +181,7 @@ function valuationCtx( agents: [AGENT], activeStables: [], fairByBase: () => ({ WETH: FAIR }), + stablePrices: () => PAR_STABLE_PRICES, ...overrides, }; } diff --git a/test/scoringExclusions.test.ts b/test/scoringExclusions.test.ts index 11c6ab6..386a59f 100644 --- a/test/scoringExclusions.test.ts +++ b/test/scoringExclusions.test.ts @@ -11,6 +11,7 @@ import { toPriceFeedAnswer } from "@eris/sdk/priceFeed.js"; import { TOKENS, USDC_VARIANTS } from "@eris/sdk/constants.js"; import { aaveAdapter } from "@eris/sdk/protocols/aave.js"; import type { ValuationContext } from "@eris/sdk/protocols/types.js"; +import { PAR_STABLE_PRICES } from "@eris/sdk/stables.js"; const PRICE_FEED = "0x00000000000000000000000000000000feed0001" as Address; const AGENT = { @@ -151,6 +152,7 @@ function valuationCtx(): ValuationContext { agents: [AGENT], activeStables: [USDC], fairByBase: () => ({ WETH: FAIR }), + stablePrices: () => PAR_STABLE_PRICES, }; } diff --git a/test/stables.test.ts b/test/stables.test.ts new file mode 100644 index 0000000..11f0975 --- /dev/null +++ b/test/stables.test.ts @@ -0,0 +1,292 @@ +// A registry stable is worth what its market pays, not $1 (issue #27). +// +// Three things are pinned here: the two-sided probe and its explicit did-not-quote state; that a +// depegged stable is marked down in both the live path an agent reads and the historical +// cross-section the scorer builds; and that a stable whose market went silent is *reported* rather +// than counted as a dollar in silence. +// +// It runs under the local-deploy overlay, because the fork registry is WETH/USDC only and has no +// market-priced stable to be right or wrong about. No chain is needed -- the addresses come from +// the committed constants.local.ts and every read here is faked -- so the env is set before the +// dynamic imports below rather than by the caller. Node's test runner gives each file its own +// process, so this does not leak into any other test. +process.env.ERIS_LOCAL_DEPLOY = "1"; + +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Address } from "viem"; + +const { + marketPricedStables, + decodeStableProbes, + stableQuoteFrom, + stablePriceUsdc, + PAR_STABLE_PRICES, +} = await import("@eris/sdk/stables.js"); +const { balanceToInventory, valueUsdc } = await import("@eris/sdk/pnl.js"); +const { poolShareValueUsdc, tokenAmountUsd } = + await import("@eris/sdk/valuation.js"); +const { TOKENS } = await import("@eris/sdk/constants.js"); +const { readValueSnapshotAtBlock } = + await import("../core/src/realtime/reconstruct.js"); +const { toPriceFeedAnswer } = await import("@eris/sdk/priceFeed.js"); + +const WAD = 10n ** 18n; +const USDC_UNIT = 10n ** 6n; +const FAIR = { WETH: 2000 }; + +const MARKETS = marketPricedStables(); +// A deploy without the DAI/USDC pool has no second market-priced stable, and the eUSD one needs the +// liquity venue. Skip rather than assert on a deployment nobody promised. +const DAI = MARKETS.find((m) => m.symbol === "DAI"); +const skip = DAI + ? false + : "this deployment prices no stable from a market (regenerate constants.local.ts from a deploy that seeded the DAI/USDC pool)"; + +// --------------------------------------------------------------------------- +// The probe +// --------------------------------------------------------------------------- + +test( + "the mark is the geometric mean of both executable directions", + { skip }, + () => { + const m = DAI!; + // Selling 1,000 fetches 980 USDC; 1,000 USDC buys 1,010.101... of the stable, i.e. 0.99 each. + const quote = stableQuoteFrom( + m, + 980n * USDC_UNIT, + 1_010_101_010_101_010_101_010n, + 6, + ); + assert.equal(quote.quoted, true); + assert.ok(Math.abs(quote.sellPriceUsdc - 0.98) < 1e-9); + assert.ok(Math.abs(quote.buyPriceUsdc - 0.99) < 1e-6); + assert.ok(Math.abs(quote.priceUsdc - Math.sqrt(0.98 * 0.99)) < 1e-6); + // And it sits strictly between the two, which is the point of asking both sides: the sell side + // alone would mark the holding 50bps lower than an unwind actually straddles. + assert.ok(quote.priceUsdc > quote.sellPriceUsdc); + assert.ok(quote.priceUsdc < quote.buyPriceUsdc); + }, +); + +test("one side is better than none", { skip }, () => { + const sellOnly = stableQuoteFrom(DAI!, 970n * USDC_UNIT, undefined, 6); + assert.equal(sellOnly.quoted, true); + assert.ok(Math.abs(sellOnly.priceUsdc - 0.97) < 1e-9); +}); + +test("a market that will not quote is par, and says so", { skip }, () => { + const quote = stableQuoteFrom(DAI!, undefined, undefined, 6); + // Not zero: zero would read as a 10000bps discount, i.e. an infinite free arb -- the failure mode + // the LST venue hit first (issue #38). + assert.equal(quote.priceUsdc, 1); + assert.equal(quote.quoted, false); +}); + +test("decoding pairs each market with its own two reads", { skip }, () => { + const prices = decodeStableProbes( + [DAI!], + [980n * USDC_UNIT, 1_010_101_010_101_010_101_010n], + ); + assert.equal(prices.unquoted.length, 0); + assert.ok( + Math.abs( + prices.byToken[DAI!.token.toLowerCase()] - Math.sqrt(0.98 * 0.99), + ) < 1e-6, + ); +}); + +test("a silent market is named, and still has a usable price", { skip }, () => { + const prices = decodeStableProbes([DAI!], [undefined, undefined]); + assert.equal(prices.unquoted.length, 1); + assert.equal(prices.unquoted[0].symbol, "DAI"); + assert.equal(prices.byToken[DAI!.token.toLowerCase()], 1); +}); + +test("USDC is never market-priced: it is the unit", () => { + // Even if a deploy handed the registry a USDC leg, the numéraire stays $1 by definition -- every + // competition metric is denominated in it (issue #27, "Settled"). + assert.equal( + MARKETS.some( + (m) => m.token.toLowerCase() === TOKENS.USDC.address.toLowerCase(), + ), + false, + ); + assert.equal(stablePriceUsdc(PAR_STABLE_PRICES, TOKENS.USDC.address), 1); +}); + +// --------------------------------------------------------------------------- +// What the marks do to a value +// --------------------------------------------------------------------------- + +function depegged(price: number) { + return { + byToken: { [DAI!.token.toLowerCase()]: price }, + unquoted: [], + quotes: [], + }; +} + +// 1,000 USDC and 1,000 DAI, no ETH exposure at all. +function held() { + return { + ethWei: 0n, + wethWei: 0n, + usdcUnits: 1_000n * USDC_UNIT, + bases: { WETH: 0n }, + stables: { + [TOKENS.USDC.address.toLowerCase()]: 1_000n * USDC_UNIT, + [DAI!.token.toLowerCase()]: 1_000n * WAD, + }, + }; +} + +test( + "a depegged stable is marked down, and USDC beside it is not", + { skip }, + () => { + assert.ok(Math.abs(valueUsdc(held(), FAIR, depegged(0.95)) - 1_950) < 1e-9); + // Same holdings, no market: the old behaviour, and still the right one for a dollar nothing + // quotes. + assert.equal(valueUsdc(held(), FAIR, PAR_STABLE_PRICES), 2_000); + }, +); + +test("the live and historical paths cannot disagree", { skip }, () => { + // balanceToInventory is what an agent's observation carries; valueUsdc is what the scorer's + // cross-section sums. They are the same call, which is the point -- an agent that sees 1,950 in + // its inventory is scored at 1,950. + const live = balanceToInventory(held(), FAIR, depegged(0.95)); + assert.equal(live.valueUsdc, valueUsdc(held(), FAIR, depegged(0.95))); + // The budget field stays native USDC: it is what a USDC leg can be sized against, and the + // depegged stable cannot be spent in a USDC pool. + assert.equal(live.usdc, 1_000); +}); + +test( + "a snapshot with no breakdown falls back to usdcUnits at par", + { skip }, + () => { + // Every hand-assembled snapshot in the codebase looks like this, and there is nothing better to + // do with one: the per-stable balances simply are not there. + const bare = { ethWei: 0n, wethWei: 0n, usdcUnits: 500n * USDC_UNIT }; + assert.equal(valueUsdc(bare, FAIR, depegged(0.95)), 500); + }, +); + +test("a registry stable's price reaches an LP leg too", { skip }, () => { + // A pool holding the depegged stable is worth less, which is where a position could hide if only + // spot balances were marked. + const reserves = { + tokens: [TOKENS.WETH.address, DAI!.token], + balances: [10n * WAD, 20_000n * WAD], + totalSupply: 100n * WAD, + }; + const share = poolShareValueUsdc(reserves, 10n * WAD, FAIR, depegged(0.95)); + // 10% of 10 WETH at $2000, plus 10% of 20,000 DAI at 0.95. + assert.ok(Math.abs(share.valueUsdc - (2_000 + 1_900)) < 1e-6); + assert.equal(share.unpriced.length, 0); +}); + +test( + "tokenAmountUsd prices USDC at par whatever the probes said", + { skip }, + () => { + assert.equal( + tokenAmountUsd( + TOKENS.USDC.address, + 250n * USDC_UNIT, + FAIR, + depegged(0.95), + ), + 250, + ); + }, +); + +// --------------------------------------------------------------------------- +// The historical cross-section, end to end +// --------------------------------------------------------------------------- + +type Call = { + address: Address; + functionName: string; + args?: readonly unknown[]; +}; + +const PRICE_FEED = "0x00000000000000000000000000000000feed0001" as Address; +const AGENT = { + id: "a1", + address: "0x00000000000000000000000000000000000a0001" as Address, +}; + +// A cross-section where the agent holds 1,000 USDC and 1,000 DAI and nothing else, and the pool +// answers `sell`/`buy` for the two probe directions. +function snapshotWith(probe: (call: Call) => bigint | undefined) { + const client = { + multicall: async ({ contracts }: { contracts: Call[] }) => + contracts.map((c) => { + if (c.functionName === "latestAnswer") + return { + status: "success" as const, + result: toPriceFeedAnswer(2000), + }; + if (c.functionName === "getEthBalance") + return { status: "success" as const, result: 0n }; + if (c.functionName === "get_dy") { + const result = probe(c); + return result === undefined + ? { status: "failure" as const } + : { status: "success" as const, result }; + } + if (c.address.toLowerCase() === DAI!.token.toLowerCase()) + return { status: "success" as const, result: 1_000n * WAD }; + if (c.address.toLowerCase() === TOKENS.USDC.address.toLowerCase()) + return { status: "success" as const, result: 1_000n * USDC_UNIT }; + return { status: "success" as const, result: 0n }; + }), + } as never; + return readValueSnapshotAtBlock({ + publicClient: client, + agents: [AGENT], + enabledIds: [], + activeStables: [TOKENS.USDC.address, DAI!.token], + priceFeed: PRICE_FEED, + blockNumber: 100, + }); +} + +test( + "the scorer marks a depegged stable at the pool, not at par", + { skip }, + async () => { + // Both directions say 0.95: selling 1,000 DAI fetches 950 USDC, and 1,000 USDC buys 1,052.6 DAI. + const s = await snapshotWith((c) => + (c.args?.[0] as bigint) === BigInt(DAI!.stableIndex) + ? 950n * USDC_UNIT + : 1_052_631_578_947_368_421_052n, + ); + assert.deepEqual(s.unpriced, []); + assert.ok(Math.abs(s.values[0].valueUsdc - (1_000 + 950)) < 0.5); + // α marks the stable live too: unlike a base's fair price, a peg's discount is a dislocation + // against a price something enforces, so removing it would cancel the thing being measured. + assert.equal(s.values[0].alphaValueUsdc, s.values[0].valueUsdc); + }, +); + +test( + "a stable whose market cannot quote is reported, not silently par", + { skip }, + async () => { + const s = await snapshotWith(() => undefined); + // The dollar is still counted -- dropping the balance would be worse -- but the assumption is on + // the record, against the agent whose value depends on it. + assert.ok(Math.abs(s.values[0].valueUsdc - 2_000) < 1e-6); + const reported = s.unpriced.filter((h) => h.reason === "par-fallback"); + assert.equal(reported.length, 1); + assert.equal(reported[0].agentId, AGENT.id); + assert.equal(reported[0].source, "spot-DAI"); + assert.equal(reported[0].amountRaw, (1_000n * WAD).toString()); + }, +); From 6330b0a8c84b26786e8eb3c5982c67240edb6895 Mon Sep 17 00:00:00 2001 From: adachi-440 Date: Wed, 12 Aug 2026 19:36:16 +0700 Subject: [PATCH 2/2] fix(scoring): close the holes the review found in market-priced stables (#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) --- CLAUDE.md | 6 +- core/src/realtime/coordinator.ts | 71 +++++++++++++----- core/src/realtime/liquity.ts | 53 ++++++------- deployer/src/protocols/liquity.ts | 8 +- deployer/src/protocols/lst.ts | 5 +- docs/guide/protocols-and-actions.md | 29 +++++++- example/agents/peg-arb/agent.ts | 35 +++++++-- scripts/genStateDump.ts | 6 ++ sdk/src/action.ts | 52 +++++++++++++ sdk/src/constants.ts | 7 +- sdk/src/markets.ts | 5 +- sdk/src/pnl.ts | 5 +- sdk/src/stables.ts | 57 ++++++++++---- sdk/src/types.ts | 5 +- test/liquity.test.ts | 2 +- test/stables.test.ts | 111 ++++++++++++++++++++++++++++ 16 files changed, 371 insertions(+), 86 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index af68345..cf991ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -280,8 +280,10 @@ phantom value そのもの)。issue #27 でこれを 3 段階で外した: **今どの market-priced stable も Aave reserve ではない**ので現状は no-op で、listing した日に効く - レジームは `config/regimes/depeg.yaml`(公式セット入り = ADR 0017 の 7 本目)、参照 agent は `example/agents/peg-arb/`。実測(seed 701): 環境が depth の 59% を売って最大 89.5bps のディスカウント、 - peg-arb +139.6 / peg-arb-eager +195.7 / noop 0。**買い手が反対側を取るのでペグは戻るとき行き過ぎる** - (実測 −143bps = par 超え) + peg-arb +139.6 / peg-arb-eager +195.7 / noop 0。**買い手が反対側を取ったぶん、環境が買い戻すと + プールは stable 不足になって par を超える**(裁定側が解消できていれば −6bps 程度で収まるが、 + 解消できないと大きく行き過ぎる。上限バグで sell が全 reject された run では −143bps まで振れ、 + 「閉じられないポジションの含み益」が +439 と表示された) 実時間化(ADR 0005)の前提: **SEED(=regime) は市場条件のラベル**で価格パスは再現可能だが、tx タイミング/着順は非決定 → 同一 regime でも結果はぶれる。run 長は `ERIS_RUN_BLOCKS` 固定で揃える。run の比較が要るときは同一 config を複数回回してサンプルを貯め、`runs//summary.json` を集計する(旧 evaluate/gate は撤去済み)。 diff --git a/core/src/realtime/coordinator.ts b/core/src/realtime/coordinator.ts index 4026ed6..55d4aa8 100644 --- a/core/src/realtime/coordinator.ts +++ b/core/src/realtime/coordinator.ts @@ -774,13 +774,17 @@ export async function runRealtimeSimulation( "and a fork has no such pool (issue #27 (c))", ); } + // Enabled-venue gated (marketPricedStables reads the run's protocol set), not merely + // deployed: a stable this run did not enable is not swept, not priced and not tradable, so + // depegging it would move a price nobody can see or act on. const market = marketPricedStables().find((m) => m.symbol === symbol); if (!market) { + const known = marketPricedStables().map((m) => m.symbol).join(", "); throw new Error( - `stress event depeg targets "${symbol}", which this deployment does not price from a ` + - `market (known: ${marketPricedStables().map((m) => m.symbol).join(", ") || "none"}). ` + - "A stable with no pool cannot be pushed off par -- and would be scored at $1 whatever " + - "the event did.", + `stress event depeg targets "${symbol}", which this run does not price from a market ` + + `(available: ${known || "none"}). Either the deployment has no pool for it, or its ` + + "venue is missing from run.protocols -- a stable with neither cannot be pushed off " + + "par, and would be scored at $1 whatever the event did.", ); } depegRuntimes.push({ @@ -810,9 +814,17 @@ export async function runRealtimeSimulation( } // Their swaps are environment transactions, like the oracle writes: attributing them to a // participant would put them through the post-run fee check (core/src/postRunCheck.ts). - for (const { runtime, ownerId } of depegRuntimes) { - ownerByAddress.set(runtime.actor.toLowerCase(), { - ownerId, + // + // One entry, not one per runtime. Every depeg -- and the liquidity pull below -- trades as the + // same deployer account, so per-mechanism entries would simply overwrite each other and label + // every unmatched tx from that account with whichever ran last. A tx whose hash *is* in + // submittedByHash still gets its own mechanism's id; this is only the fallback. + if (depegRuntimes.length > 0) { + ownerByAddress.set(depegRuntimes[0].runtime.actor.toLowerCase(), { + ownerId: + depegRuntimes.length === 1 + ? depegRuntimes[0].ownerId + : depegRuntimes.map((d) => d.ownerId).join("+"), role: "system", }); } @@ -1541,11 +1553,9 @@ export async function runRealtimeSimulation( } }; - // eUSD depeg stress event (issue #39): move the peg toward where this block's trapezoid - // wants it. Its own task for the same reason as the liquidity pull -- it sends from the - // deployer key rather than the admin key -- and sequential with it inside that key, which - // the two being separate awaited tasks does not guarantee, so they share one task here. - const depegTask = async (): Promise => { + // Depeg stress events (issues #39 and #27 (c)): move each peg toward where this block's + // trapezoid wants it. + const depegStep = async (): Promise => { // Sequential across stables as well as with the pull: they all send from the deployer // key, and two senders on one key race on the nonce. for (const { runtime, fractionAt, ownerId } of depegRuntimes) { @@ -1612,8 +1622,17 @@ export async function runRealtimeSimulation( ]; if (stressVictims.length > 0) tasks.push(timed(victimTask)); if (vulnRuntime) tasks.push(timed(vulnTask)); - if (liquidityPullRuntime) tasks.push(timed(liquidityTask)); - if (depegRuntimes.length > 0) tasks.push(timed(depegTask)); + // One task for both, actually rather than by comment. Every one of these sends from the + // deployer key, and `sendNoMine` resolves the nonce per call -- two of them in the same + // Promise.all resolve the same pending nonce and one silently replaces the other. The + // comment here used to claim they shared a task while the code pushed two; the crash + + // depeg combination `alignWith` exists to express is exactly when both are live. + const deployerKeyTask = async (): Promise => { + if (liquidityPullRuntime) await liquidityTask(); + if (depegRuntimes.length > 0) await depegStep(); + }; + if (liquidityPullRuntime || depegRuntimes.length > 0) + tasks.push(timed(deployerKeyTask)); if (liquityRuntime) tasks.push(timed(liquityWatchTask)); const results = await Promise.all(tasks); const [keeperMs, oracleMs, stateFlowMs] = results; @@ -1621,11 +1640,14 @@ export async function runRealtimeSimulation( const victimMs = stressVictims.length > 0 ? results[taskIdx++] : undefined; const vulnMs = vulnRuntime ? results[taskIdx++] : undefined; - const liquidityMs = liquidityPullRuntime - ? results[taskIdx++] - : undefined; - const depegMs = - depegRuntimes.length > 0 ? results[taskIdx++] : undefined; + // One measurement now that both share a task; reported under both names so the existing + // round_timing readers keep working. + const deployerKeyMs = + liquidityPullRuntime || depegRuntimes.length > 0 + ? results[taskIdx++] + : undefined; + const liquidityMs = liquidityPullRuntime ? deployerKeyMs : undefined; + const depegMs = depegRuntimes.length > 0 ? deployerKeyMs : undefined; const liquityMs = liquityRuntime ? results[taskIdx++] : undefined; logger.event({ type: "round_timing", @@ -1673,6 +1695,14 @@ export async function runRealtimeSimulation( flowProcess.close(); await setIntervalMining(publicClient, 0); + // The last block anyone competed on, captured *before* the teardowns below. Everything after it + // is the environment putting the chain back, and scoring across those blocks would score the + // teardown: a depeg restore buys the stable back to par (issue #27), so an agent that never + // unwound would be marked at par and holding through the end would cost nothing -- which is + // exactly the risk the regime exists to create. Nothing agents did lands after this point, + // because they were stopped one line above. + const finalBlock = Number(await publicClient.getBlockNumber()); + // ---- liquidity-pull teardown (issue #52): the run can end with a window still open, since the // schedule may place it against the last block and the time limit can cut in mid-window. Restore // here so a shared anvil does not hand the next run a thinner venue. Agents are already stopped, @@ -1705,7 +1735,6 @@ export async function runRealtimeSimulation( // ---- bulk recording of blocks.csv: scan all run blocks for what was removed from the realtime loop ---- // (finish before resetFork erases history, and before the violation check and summary) - const finalBlock = Number(await publicClient.getBlockNumber()); for (let b = runStartBlock; b <= finalBlock; b++) await logBlock(b); // ---- scoring: batch-reconstruct the per-agent value series from historical blocks (ADR 0006 §4) ---- @@ -1811,6 +1840,8 @@ export async function runRealtimeSimulation( const finalStablePrices = await readStablePrices( publicClient, activeStables(), + // At the last competition block, for the same reason the reconstruction stops there. + BigInt(finalBlock), ); const agentsSummary = []; for (const agent of agentRuntimes) { diff --git a/core/src/realtime/liquity.ts b/core/src/realtime/liquity.ts index 2a37760..1ca01f7 100644 --- a/core/src/realtime/liquity.ts +++ b/core/src/realtime/liquity.ts @@ -15,26 +15,22 @@ // same reconcile-to-a-target shape as the liquidity pull (issue #52) and for the same reason: the // coordinator drops block notifications while it is busy, so a state that is re-derived every block // costs a block of lag where a one-shot would strand the pool. -import { encodeFunctionData, maxUint256, type Address, type Hex } from "viem"; -import { - curveStableSwapNgAbi, - erc20Abi, - troveManagerAbi, -} from "@eris/sdk/abis.js"; -import { accountAddress, sendAndMine, sendNoMine } from "@eris/sdk/chain.js"; +import { encodeFunctionData, type Address, type Hex } from "viem"; +import { troveManagerAbi } from "@eris/sdk/abis.js"; +import { accountAddress, sendAndMine } from "@eris/sdk/chain.js"; import { liquityPriceFeedAdapterAbi } from "@eris/sdk/abis.js"; -import { LIQUITY, requireEusdMarket } from "@eris/sdk/constants.js"; +import { + LIQUITY, + requireEusdMarket, + STABLE_MARKET_LEGS, +} from "@eris/sdk/constants.js"; import { getLiquityState, type LiquityState, } from "@eris/sdk/protocols/liquity.js"; import type { SimContext } from "@eris/sdk/protocols/types.js"; import type { RunLogger } from "../logger.js"; -import type { EventSchedule } from "./events.js"; -import { - setupStableDepeg, - type StableDepegRuntime, -} from "./stableDepeg.js"; +import { setupStableDepeg, type StableDepegRuntime } from "./stableDepeg.js"; // How far the oracle the venue serves may sit from the run's fair price before the run refuses to // start. This is not calibration noise: either the adapter points at this run's PriceFeed or it does @@ -47,22 +43,6 @@ const ORACLE_TOLERANCE_BPS = 100; export const LIQUITY_STARTUP_WARN_BPS = 25; export const LIQUITY_STARTUP_FAIL_BPS = 200; -// Swaps against a stableswap pool are a fixed shape; pinning the gas skips an eth_estimateGas (a -// whole extra EVM execution) on a transaction the environment may send every block of a window. -const DEPEG_GAS = 600_000n; - -// Slippage bound on the environment's own depeg trades. It is not being protected from a bad price -// -- moving the price is the point -- only from a pathological fill. -const DEPEG_SLIPPAGE_BPS = 500n; - -// Deltas below this fraction of the pool's seeded eUSD depth are rounding, not schedule. Closing the -// window is exempt: leaving the peg broken would hand the rest of the run a different venue. -const MIN_DELTA_BPS = 50n; - -// Blocks to wait for a submitted swap before treating it as lost. Under interval mining a -// transaction lands on the next block, so this is slack for a busy block rather than a normal path. -const PENDING_TIMEOUT_BLOCKS = 3; - export type LiquityRuntime = { troveManager: Address; priceFeedAdapter: Address; @@ -85,6 +65,19 @@ export async function setupLiquity( "includes liquity, or drop liquity from run.protocols.", ); } + // Since issue #27 the wallet's eUSD is a registry stable priced from its own pool, and this + // adapter deliberately stopped valuing it. A deployment without that pool therefore has no way to + // value eUSD at all -- a borrower who drew 4,000 against a Trove would be scored as having lost + // the whole borrow, silently. The venue used to be usable without a market ("only the peg has + // nowhere to trade"); it is not any more, so say so instead of scoring it wrong. + if (!STABLE_MARKET_LEGS.EUSD) { + throw new Error( + "the liquity deployment has no eUSD/USDC market, and since issue #27 eUSD is priced from " + + "that pool rather than assumed to be $1 -- so a wallet holding eUSD would be scored at " + + "zero. Redeploy with the curve factory present (`cd deployer && npm run deploy " + + "-- --keep-fresh`), or drop liquity from run.protocols.", + ); + } const admin = accountAddress(ctx.adminPk); const operator = (await ctx.publicClient.readContract({ address: LIQUITY.priceFeed, @@ -330,7 +323,7 @@ export async function setupEusdDepeg( ctx, { market: { - symbol: "eUSD", + symbol: "EUSD", stable: l.eusd, quote: market.stable, pool: market.pool, diff --git a/deployer/src/protocols/liquity.ts b/deployer/src/protocols/liquity.ts index 132d951..962efcd 100644 --- a/deployer/src/protocols/liquity.ts +++ b/deployer/src/protocols/liquity.ts @@ -65,11 +65,11 @@ const BOOTSTRAP_SECONDS = 14n * 24n * 60n * 60n + 3600n; /// 0.5% floor; this is only the slippage bound on it. const MAX_BORROW_FEE = parseEther("0.05"); -// Curve plain-pool parameters. Everything but the amplification matches the USDC/DAI pool the -// factory already hosts. +// Curve plain-pool parameters. Matches the USDC/DAI pool the factory already hosts. // -// A is 100 rather than that pool's 2000, and it is the one number here that had to be measured -// rather than copied. Redemption costs a 0.5% floor fee, so the venue's α exists only when eUSD +// A is 100, and it is the one number here that had to be measured rather than copied. (The USDC/DAI +// pool was 2000 when this was written; issue #27 (c) moved it to 100 on exactly this finding.) +// Redemption costs a 0.5% floor fee, so the venue's α exists only when eUSD // trades more than 50bps below par -- and at A=2000 a 100k/100k pool moves 4.4bps when *half* its // eUSD side is sold (measured on chain). No plausible flow could ever open the trade. At A=100 the // same pool moves 22bps on a 10k sale and 114bps on 40k, which keeps the stableswap's peg-then-cliff diff --git a/deployer/src/protocols/lst.ts b/deployer/src/protocols/lst.ts index 524e0a4..9498e83 100644 --- a/deployer/src/protocols/lst.ts +++ b/deployer/src/protocols/lst.ts @@ -60,8 +60,9 @@ const REWARD_RESERVE = parseUnits("50", 18); // Curve stableswap-ng plain-pool parameters. Modelled on the real wstETH/ETH ng pool: a low fee and // a soft A, so the curve holds the peg over ordinary size and gives way (the cliff) on a large -// one-sided exit. Much lower than the USDC/DAI pool's 2000 because an LST is not a hard peg — at -// 500 the curve was so flat that no trade an agent is allowed to make registered at all. +// one-sided exit. This used to be contrasted with the USDC/DAI pool's A=2000; that pool has since +// been dropped to 100 for the same reason (issue #27 (c)), so all three stableswap pools here now +// share it. At 500 the curve was so flat that no trade an agent is allowed to make registered at all. const POOL_A = 100n; const POOL_FEE = 4_000_000n; // 0.04% const POOL_OFFPEG_FEE_MULTIPLIER = 20_000_000_000n; diff --git a/docs/guide/protocols-and-actions.md b/docs/guide/protocols-and-actions.md index 9958763..300dc52 100644 --- a/docs/guide/protocols-and-actions.md +++ b/docs/guide/protocols-and-actions.md @@ -8,10 +8,19 @@ Each adapter (`sdk/src/protocols/.ts`) implements parse/validate, calldata |---|---|---| | Uniswap V3 | `swap`, `mintLiquidity`, `removeLiquidity`, `collectFees` | fork: WETH/USDC 0.05% pool / local: WETH/USDC 0.3% pool | | Balancer v2 | `balancerSwap` | fork: 33/33/34 WETH/USDC/USDT weighted (seeded at fork time) / local: 50/50 WETH/USDC | -| Curve | `curveSwap` | fork: tricrypto WETH↔USDT / local: twocrypto-ng WETH/USDC | +| Curve | `curveSwap`, `stableSwap` | fork: tricrypto WETH↔USDT / local: twocrypto-ng WETH/USDC, plus the stableswap-ng pools that quote each market-priced stable | | Aave v3 | `aaveSupply`, `aaveWithdraw`, `aaveBorrow`, `aaveRepay` | native USDC / WETH reserves | | GMX v2 | `gmxIncrease`, `gmxDecrease` | ETH/USD perp market | | LST | `lstDeposit`, `lstSwap`, `lstRequestWithdraw`, `lstClaimWithdraw` | **local only**: a wstETH-style vault plus its LST/WETH stableswap-ng market | +| Liquity (eUSD) | `liquityOpenTrove`, `liquityAdjustTrove`, `liquityCloseTrove`, `liquityRedeem`, `liquityProvideToSP`, `liquityWithdrawFromSP`, `liquityLiquidate`, `liquitySwapEusd` | **local only**: a Liquity V1 fork issuing eUSD, plus its eUSD/USDC stableswap-ng market | + +`stableSwap` (issue #27) trades a **market-priced stable** against USDC on the pool that quotes it: +`{"type":"stableSwap","stable":"DAI","tokenIn":"USDC","amountIn":"…"}`. It lives on the Curve +adapter because those pools come off the Curve factory, so a run has to enable `curve` to reach any +of them — and a stable whose owning venue is disabled is not tradable, not swept and not priced, +which is the only combination that leaves nothing to fall through the cracks. Both legs are bounded +by `limits.maxUsdcInUnits`, which is denominated in USDC's six decimals: an 18-decimal stable needs +that scaled (`limit * 10n ** 12n`) before you size a sell against it. The LST venue (issue #38) is the one venue with no fork counterpart — the vault is deployed by `deployer/`, so a fork run that lists `lst` fails fast at startup. It is also the one venue where an @@ -28,7 +37,23 @@ In addition there are the protocol-agnostic `noop` / `bundle` (multiple bundleab ## Stablecoin Accounting -Arbitrum's deep WETH/stable liquidity lives in the USDC.e / USDT pools, so native USDC, USDC.e, and USDT are all summed into balances and PnL as **USDC-equivalent** at `$1` and 6 decimals (`setActiveStables` / `getBalances` in `sdk/src/chain.ts`). Uniswap / Aave / GMX use native USDC, Balancer uses native USDC (its pool is seeded at fork time), and Curve uses USDT on fork and USDC on local. +Arbitrum's deep WETH/stable liquidity lives in the USDC.e / USDT pools, so native USDC, USDC.e and +USDT are all treated as **USDC-equivalent** at `$1` and 6 decimals (`setActiveStables` / +`getBalances` in `sdk/src/chain.ts`). Uniswap / Aave / GMX use native USDC, Balancer uses native +USDC (its pool is seeded at fork time), and Curve uses USDT on fork and USDC on local. + +Two things changed in issue #27, and both are visible to agents: + +- **`balances.usdcUnits` is native USDC alone.** It used to be every active stable summed, which is + not a number anyone can spend — USDT is not accepted in a USDC pool. Treat it as a budget for a + USDC leg; what the wallet is *worth* is `inventory.valueUsdc`. +- **A stable with a market is worth what that market pays**, not `$1`. `balances.stables` carries + each one's balance, decimals and `priceUsdc` (the two-sided executable mid of its own pool), and + the scorer marks spot balances and LP legs at the same number. `marketQuoted: false` means + `priceUsdc: 1` is par by assumption rather than an observation, so do not read it as "the peg is + holding". USDC itself stays `$1` by definition: it is the numéraire every metric is denominated + in. Today the market-priced stables are **eUSD** (from the Liquity venue) and **DAI** (local + deploy); funding never grants either, so any exposure to one is a position somebody chose. ## Oracle Control (Aave v3 / GMX v2) diff --git a/example/agents/peg-arb/agent.ts b/example/agents/peg-arb/agent.ts index 00d1525..9ba13c2 100644 --- a/example/agents/peg-arb/agent.ts +++ b/example/agents/peg-arb/agent.ts @@ -22,19 +22,35 @@ */ import type { AgentAction, AgentContext, AgentObservation } from "@eris/sdk"; +// A roster's `env` is a string map, so a typo silently becomes NaN and the comparison below is +// then false forever -- an agent that never trades, indistinguishable in the score from one that +// correctly sat out. Fail at startup instead, where the message is attached to the cause. +function numberEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const value = Number(raw); + if (!Number.isFinite(value)) + throw new Error(`${name} must be a number, got ${JSON.stringify(raw)}`); + return value; +} + // The discount at which buying is worth the round trip. The pool charges its fee on both legs and // the exit price is not the entry price, so a few bps of dislocation is noise. -const BUY_BPS = Number(process.env.ERIS_PEG_ARB_BUY_BPS ?? "40"); +const BUY_BPS = numberEnv("ERIS_PEG_ARB_BUY_BPS", 40); // Where to let go. Above par is a premium; waiting for one is waiting for the environment to // overshoot, which the buy-back leg of the event does not promise. -const SELL_BPS = Number(process.env.ERIS_PEG_ARB_SELL_BPS ?? "10"); +const SELL_BPS = numberEnv("ERIS_PEG_ARB_SELL_BPS", 10); // Fraction of the spendable dollar budget committed per buy, in bps. A single block's dislocation // is not the deepest it will get, so this leaves room to keep buying into it. -const SIZE_BPS = BigInt(process.env.ERIS_PEG_ARB_SIZE_BPS ?? "2500"); +const SIZE_BPS = BigInt(Math.round(numberEnv("ERIS_PEG_ARB_SIZE_BPS", 2500))); // Which stable to trade. Unset = whichever quoted stable is furthest from par this block. const TARGET = process.env.ERIS_PEG_ARB_STABLE ?? ""; -const MIN_USDC_UNITS = 1_000_000n; // 1 USDC +// Dust floor, in dollars. Below this a leg is not worth a transaction: the gas eats it, and a +// balance of a few wei left over from an unwind would otherwise have the agent propose a swap every +// remaining block of the run for nothing. +const MIN_DOLLARS = 1n; +const MIN_USDC_UNITS = MIN_DOLLARS * 1_000_000n; const SLIPPAGE_BPS = 100; function minBI(a: bigint, b: bigint): bigint { @@ -65,6 +81,11 @@ function quotedStables(obs: AgentObservation): StableView[] { for (const [symbol, s] of Object.entries(obs.balances.stables ?? {})) { if (symbol === "USDC" || !s.marketQuoted) continue; if (TARGET && symbol !== TARGET) continue; + // EUSD is reachable through stableSwap like any other market-priced stable, but its venue has + // a better instrument for it: a redemption enforces par rather than hoping for it, which is + // what `example/agents/redemption-arb/` trades. Leaving it here would have this agent take the + // strictly worse side of the same dislocation. + if (symbol === "EUSD") continue; out.push({ symbol, balance: BigInt(s.balance), @@ -102,7 +123,11 @@ export function decide( // Sell first. Holding through the end of the run is the one way this strategy loses money it // never had to lose, so unwinding takes priority over adding. const rich = stables - .filter((s) => s.balance > 0n && s.discountBps <= SELL_BPS) + .filter( + (s) => + s.balance >= MIN_DOLLARS * 10n ** BigInt(s.decimals) && + s.discountBps <= SELL_BPS, + ) .sort((a, b) => a.discountBps - b.discountBps)[0]; if (rich) { // Bounded by the per-round cap, restated in this stable's decimals. Asking for the whole diff --git a/scripts/genStateDump.ts b/scripts/genStateDump.ts index 8ef86fe..8cd3efe 100644 --- a/scripts/genStateDump.ts +++ b/scripts/genStateDump.ts @@ -103,6 +103,12 @@ async function main(): Promise { localDeploy: true, localSnapshotFile: snapshotFile, }); + // evm_revert restores the mining mode along with the state, and the clean snapshot was taken with + // automine off (a run turns it off at competition start and never turns it back on). Leaving it + // that way hands the next `sim:realtime` an anvil that accepts transactions and mines none of + // them, so setup hangs on a receipt that will never arrive -- with nothing to say why. Measured + // once, the hard way. + await rpc(rpcUrl, "evm_setAutomine", [true]); // ---- dump (hex-gzip -> plain JSON. --load-state only accepts plain JSON) ---- const hex = await rpc(rpcUrl, "anvil_dumpState"); diff --git a/sdk/src/action.ts b/sdk/src/action.ts index 641342a..330426c 100644 --- a/sdk/src/action.ts +++ b/sdk/src/action.ts @@ -15,6 +15,19 @@ import { } from "./protocols/registry.js"; import { kindOf, tokenInfo } from "./markets.js"; import { TOKENS } from "./constants.js"; +import { marketPricedStables } from "./stables.js"; + +// Both dollars, so converting between two stables' units is the decimal difference and nothing else. +function rescaleDecimals(amount: bigint, from: number, to: number): bigint { + if (from === to) return amount; + return to > from + ? amount * 10n ** BigInt(to - from) + : amount / 10n ** BigInt(from - to); +} + +function stableMarketBySymbol(symbol: string) { + return marketPricedStables().find((m) => m.symbol === symbol); +} export type ValidatedIntent = { action: LeafAction; @@ -340,6 +353,18 @@ function applyLeafSpend( }; const currentStable = (): bigint => work.stables?.[stableKey] ?? work.usdcUnits; + // Move a named stable by a signed amount, keeping usdcUnits in step when it is the numéraire. + // Used by stableSwap, whose two legs are the action's own pair rather than the adapter's. + const moveStable = (key: string, delta: bigint) => { + if (key === TOKENS.USDC.address.toLowerCase()) { + work.usdcUnits = + work.usdcUnits + delta > 0n ? work.usdcUnits + delta : 0n; + } + if (work.stables && key in work.stables) { + const next = work.stables[key] + delta; + work.stables[key] = next > 0n ? next : 0n; + } + }; switch (item.type) { case "swap": @@ -374,6 +399,33 @@ function applyLeafSpend( ); spendStable(BigInt(item.amountQuoteDesired ?? item.amountUsdcDesired)); break; + // Issue #27 (c): a stableSwap is bundleable too, and its two legs are named by the action + // rather than by the adapter's stableToken -- so it moves its own pair instead of going through + // spendStable/creditStable. Without this, both leaves of a + // {stableSwap USDC->DAI 5k, stableSwap USDC->DAI 5k} bundle validated against the same + // untouched 5k and the second reverted on chain at the agent's expense (the same hole the lst + // cases below were added for). + case "stableSwap": { + const amt = BigInt(item.amountIn); + const market = stableMarketBySymbol(item.stable); + if (!market) break; + const inKey = ( + item.tokenIn === market.symbol ? market.token : TOKENS.USDC.address + ).toLowerCase(); + const outKey = ( + item.tokenIn === market.symbol ? TOKENS.USDC.address : market.token + ).toLowerCase(); + moveStable(inKey, -amt); + // Both legs are dollars, so the output is the input rescaled by the decimal difference. It + // ignores the discount and the pool fee, which only ever credits slightly too much -- the + // conservative direction is the *spend*, and that is exact. + const inDecimals = + inKey === TOKENS.USDC.address.toLowerCase() ? 6 : market.decimals; + const outDecimals = + outKey === TOKENS.USDC.address.toLowerCase() ? 6 : market.decimals; + moveStable(outKey, rescaleDecimals(amt, inDecimals, outDecimals)); + break; + } // Issue #38: an lst leg is bundleable, so cumulative validation has to see what it consumes. // Without these two cases both leaves of a {lstDeposit 10 WETH, lstSwap 10 WETH} bundle // validated against the same untouched balance, the bundle was accepted, and the second leg diff --git a/sdk/src/constants.ts b/sdk/src/constants.ts index 4c2abc9..8f5b849 100644 --- a/sdk/src/constants.ts +++ b/sdk/src/constants.ts @@ -36,8 +36,11 @@ export const TOKENS: Record = { // issued by a venue instead of deployed as a run token, so it is picked up from the liquity // deployment rather than from the deployer's token table. #39 kept it out of the registry only // because the registry priced stables at par -- STABLE_MARKET_LEGS below is what dissolves that. + // The symbol is uppercase like every other one: it is an action field (`stableSwap.stable`), and + // actionSchema's tokenSymbol rejects mixed case so a prompt-mode typo comes back as a validation + // error instead of dying at send time. `liquitySwapEusd` already spells it "EUSD". ...(L?.LIQUITY?.eusd - ? { eUSD: { address: L.LIQUITY.eusd, decimals: 18 } } + ? { EUSD: { address: L.LIQUITY.eusd, decimals: 18 } } : {}), }; @@ -97,7 +100,7 @@ function buildStableMarketLegs(): Record { liquity.eusdIndex !== undefined && liquity.usdcIndex !== undefined ) { - out.eUSD = { + out.EUSD = { pool: liquity.eusdUsdcPool, stableIndex: liquity.eusdIndex, quoteIndex: liquity.usdcIndex, diff --git a/sdk/src/markets.ts b/sdk/src/markets.ts index 5de998c..5011fa7 100644 --- a/sdk/src/markets.ts +++ b/sdk/src/markets.ts @@ -42,8 +42,9 @@ const STABLE_SYMBOLS = new Set([ "DAI", "USDC.e", // Issue #27 (b): the CDP stablecoin from #39, promoted out of the liquity adapter's private - // accounting now that being in the registry no longer means being priced at par. - "eUSD", + // accounting now that being in the registry no longer means being priced at par. Uppercase like + // every other symbol -- it reaches agents as an action field. + "EUSD", ]); // Yield-bearing claims valued by their own venue rather than by the fair-price feed (issue #38). diff --git a/sdk/src/pnl.ts b/sdk/src/pnl.ts index dd7bf02..110fd59 100644 --- a/sdk/src/pnl.ts +++ b/sdk/src/pnl.ts @@ -36,7 +36,10 @@ export function valueUsdc( const wethPrice = p.WETH ?? 0; const eth = Number(formatUnits(snapshot.ethWei, 18)) * wethPrice; let total = eth; - if (snapshot.stables) { + // Object.keys rather than a truthiness check: validateLeafItems builds an empty `stables` map + // when the snapshot it copies had none, and treating that as authoritative would drop usdcUnits + // from the total entirely instead of falling back to it. + if (snapshot.stables && Object.keys(snapshot.stables).length > 0) { for (const [token, units] of Object.entries(snapshot.stables)) { total += Number(formatUnits(units, stableDecimals(token))) * diff --git a/sdk/src/stables.ts b/sdk/src/stables.ts index bd0e98d..0a2a2ec 100644 --- a/sdk/src/stables.ts +++ b/sdk/src/stables.ts @@ -28,6 +28,7 @@ import { formatUnits, type Address, type PublicClient } from "viem"; import { curveStableSwapNgAbi } from "./abis.js"; import { STABLE_MARKET_LEGS, TOKENS } from "./constants.js"; import { tokenInfoByAddress } from "./markets.js"; +import { enabledProtocolIds } from "./protocols/enabled.js"; import type { ProtocolId, TokenSymbol } from "./types.js"; // A stable and the pool that quotes it against the run's USDC. @@ -85,14 +86,13 @@ function probeUnits(decimals: number): bigint { return DEFAULT_PROBE_USD * 10n ** BigInt(decimals); } -// Every stable the deployment gave a market, in registry order. Optionally narrowed to a set of -// tokens (the run's active stables), so a venue that is deployed but not enabled costs no reads. -export function marketPricedStables( - tokens?: readonly Address[], -): StableMarket[] { - const wanted = tokens - ? new Set(tokens.map((t) => t.toLowerCase())) - : undefined; +// Every stable the *deployment* gave a market, whether or not this run enabled its venue. Derived +// once: STABLE_MARKET_LEGS and TOKENS are module constants, and this is called from the per-block +// oracle writes and from every action parse. +let deployedMarkets: StableMarket[] | undefined; + +function allMarkets(): StableMarket[] { + if (deployedMarkets) return deployedMarkets; const quoteDecimals = TOKENS.USDC.decimals; const out: StableMarket[] = []; for (const [symbol, leg] of Object.entries(STABLE_MARKET_LEGS)) { @@ -105,7 +105,6 @@ export function marketPricedStables( // a market leg naming it is ignored here rather than quietly redefining the unit. if (info.address.toLowerCase() === TOKENS.USDC.address.toLowerCase()) continue; - if (wanted && !wanted.has(info.address.toLowerCase())) continue; out.push({ symbol, token: info.address, @@ -118,17 +117,38 @@ export function marketPricedStables( probeQuoteUnits: probeUnits(quoteDecimals), }); } + deployedMarkets = out; return out; } -// The market-priced stables a run actually gets: those whose owning venue is enabled. A stable -// whose venue is switched off is not swept, not probed, and not tradable -- all three together, -// which is the only combination that is coherent. +// The market-priced stables this run can actually see, in registry order: those whose owning venue +// the run enabled. Optionally narrowed further to a set of tokens (the run's active stables). +// +// Venue-gated rather than deployment-wide, because the three things have to agree. A stable the run +// did not enable is not swept, so its balance never reaches a value; if it were still parseable and +// approved, an agent could spend USDC on a token the scorer would then not count -- the dollars +// would simply vanish from its score. +export function marketPricedStables( + tokens?: readonly Address[], +): StableMarket[] { + const wanted = tokens + ? new Set(tokens.map((t) => t.toLowerCase())) + : undefined; + const enabled = new Set(enabledProtocolIds()); + return allMarkets().filter( + (m) => + enabled.has(m.venue) && + (!wanted || wanted.has(m.token.toLowerCase())), + ); +} + +// The market-priced stables an explicit protocol set brings with it. Used by initProtocols, which +// runs *while* setting the enabled ids and so cannot read them back yet. export function stablesForProtocols( protocols: readonly ProtocolId[], ): StableMarket[] { const enabled = new Set(protocols); - return marketPricedStables().filter((m) => enabled.has(m.venue)); + return allMarkets().filter((m) => enabled.has(m.venue)); } export function stableMarketFor(token: Address): StableMarket | undefined { @@ -138,8 +158,12 @@ export function stableMarketFor(token: Address): StableMarket | undefined { // True when this token is a dollar by convention rather than by measurement: USDC itself, or a // registry stable no pool quotes. Funding grants these and only these -- see fundWallet. +// +// Deliberately checked against the whole deployment rather than the enabled venues: a venue-issued +// stable must never be conjured by a cheatcode just because its venue is switched off. export function isParStable(token: Address): boolean { - return stableMarketFor(token) === undefined; + const target = token.toLowerCase(); + return !allMarkets().some((m) => m.token.toLowerCase() === target); } // One contract read inside a batched cross-section multicall. Structurally the scorer's @@ -250,6 +274,10 @@ export function decodeStableProbes( export async function readStablePrices( publicClient: PublicClient, tokens?: readonly Address[], + // Read at a specific block. The end-of-run PnL needs it: the environment's depeg teardown runs + // after the last competition block, and pricing the close at the restored peg would score the + // teardown rather than the run. + blockNumber?: bigint, ): Promise { const markets = marketPricedStables(tokens); if (markets.length === 0) return PAR_STABLE_PRICES; @@ -262,6 +290,7 @@ export async function readStablePrices( abi: read.abi, functionName: read.functionName, ...(read.args ? { args: read.args } : {}), + ...(blockNumber !== undefined ? { blockNumber } : {}), } as never) // A quote the pool refuses is "no market at this size", not a price of zero. .catch(() => undefined), diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 0bd137d..8ae9665 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -793,7 +793,10 @@ export type RawTxIntent = { export type BalanceSnapshot = { ethWei: bigint; wethWei: bigint; - usdcUnits: bigint; // sum of active stables (for display/PnL) + // Native USDC only, since issue #27. A spending budget, not a valuation: it used to be every + // active stable summed, which could not be spent anywhere. The per-stable breakdown is `stables`, + // and what the wallet is worth comes from pnl.ts valueUsdc over that. + usdcUnits: bigint; // ADR 0013: base symbol -> balance (WETH/WBTC etc.). wethWei equals bases["WETH"] for compatibility. bases?: Record; // stable token address (lowercase) -> balance. Validation checks each venue's stable individually via this map. diff --git a/test/liquity.test.ts b/test/liquity.test.ts index 6f275d7..51958b5 100644 --- a/test/liquity.test.ts +++ b/test/liquity.test.ts @@ -228,7 +228,7 @@ function context(overrides: Partial = {}): ValuationContext { // probing the pool itself, so a valuation test says what the market said rather than what a probe // read returned. const EUSD_MARKET: StableMarket = { - symbol: "eUSD", + symbol: "EUSD", token: DEPLOYMENT.eusd, decimals: 18, venue: "liquity", diff --git a/test/stables.test.ts b/test/stables.test.ts index 11f0975..6e2868e 100644 --- a/test/stables.test.ts +++ b/test/stables.test.ts @@ -30,6 +30,13 @@ const { TOKENS } = await import("@eris/sdk/constants.js"); const { readValueSnapshotAtBlock } = await import("../core/src/realtime/reconstruct.js"); const { toPriceFeedAnswer } = await import("@eris/sdk/priceFeed.js"); +const { setEnabledProtocolIds } = await import("@eris/sdk/protocols/enabled.js"); +const { validateAction } = await import("@eris/sdk/action.js"); + +// A market-priced stable belongs to the venue that owns its pool, and marketPricedStables() is +// gated on the run having enabled it. Say so explicitly rather than relying on whichever module +// happened to initialise the registry first. +setEnabledProtocolIds(["uniswap", "curve"]); const WAD = 10n ** 18n; const USDC_UNIT = 10n ** 6n; @@ -290,3 +297,107 @@ test( assert.equal(reported[0].amountRaw, (1_000n * WAD).toString()); }, ); + +// --------------------------------------------------------------------------- +// Reachability: swept, priced and tradable have to agree +// --------------------------------------------------------------------------- + +test("a stable whose venue the run disabled is not visible at all", { skip }, () => { + setEnabledProtocolIds(["uniswap"]); + try { + // Not priced, so a holding of it would be marked at par; the point is that it is not tradable + // either, so nobody can acquire one. All three move together or dollars vanish from a score. + assert.equal(marketPricedStables().length, 0); + } finally { + setEnabledProtocolIds(["uniswap", "curve"]); + } +}); + +// --------------------------------------------------------------------------- +// Bundle accounting +// --------------------------------------------------------------------------- + +const AGENT_OBS = { + round: 1, + limits: { + maxUsdcInUnits: "5000000000", + maxWethInWei: "1000000000000000000", + maxPriorityFeePerGasWei: "1000000000", + defaultPriorityFeePerGasWei: "100000000", + maxBundleActions: 5, + maxOpenPositions: 5, + }, + protocols: {}, + enabledProtocols: ["uniswap", "curve"], +} as never; + +test("a bundle cannot spend the same dollars on two stableSwaps", { skip }, () => { + const balances = { + ethWei: 0n, + wethWei: 0n, + usdcUnits: 5_000n * USDC_UNIT, + bases: { WETH: 0n }, + stables: { + [TOKENS.USDC.address.toLowerCase()]: 5_000n * USDC_UNIT, + [DAI!.token.toLowerCase()]: 0n, + }, + }; + const leg = { + type: "stableSwap", + stable: "DAI", + tokenIn: "USDC", + amountIn: (5_000n * USDC_UNIT).toString(), + }; + const single = validateAction( + { type: "bundle", actions: [leg] } as never, + AGENT_OBS, + balances, + ); + assert.equal(single.ok, true); + // The second leg has to see the first one's spend. Before issue #27 wired stableSwap into + // applyLeafSpend both legs validated against the same untouched 5,000 and the second reverted on + // chain at the agent's expense. + const doubled = validateAction( + { type: "bundle", actions: [leg, leg] } as never, + AGENT_OBS, + balances, + ); + assert.equal(doubled.ok, false); +}); + +test("the stable a bundle just bought is spendable by its next leg", { skip }, () => { + const balances = { + ethWei: 0n, + wethWei: 0n, + usdcUnits: 1_000n * USDC_UNIT, + bases: { WETH: 0n }, + stables: { + [TOKENS.USDC.address.toLowerCase()]: 1_000n * USDC_UNIT, + [DAI!.token.toLowerCase()]: 0n, + }, + }; + // Buy 1,000 USDC of DAI, then sell (almost) all of it back: the credit has to cross the decimal + // difference, or the round trip reads as spending DAI the wallet does not have. + const round = validateAction( + { + type: "bundle", + actions: [ + { + type: "stableSwap", + stable: "DAI", + tokenIn: "USDC", + amountIn: (1_000n * USDC_UNIT).toString(), + }, + { + type: "stableSwap", + stable: "DAI", + tokenIn: "DAI", + amountIn: (990n * WAD).toString(), + }, + ], + } as never, + AGENT_OBS, + balances, + ); + assert.equal(round.ok, true); +});