diff --git a/CLAUDE.md b/CLAUDE.md index d3f010f..cf991ab 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,62 @@ 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。**買い手が反対側を取ったぶん、環境が買い戻すと + プールは 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 は撤去済み)。 ## アーキテクチャ(環境とエージェント実行の分離。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..55d4aa8 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,74 @@ 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))", + ); + } + // 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 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({ + 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). + // + // 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", }); } @@ -1475,37 +1553,39 @@ 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 => { - 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", + // 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) { + 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), - }); } }; @@ -1542,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 (eusdDepegRuntime) 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; @@ -1551,10 +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 = eusdDepegRuntime ? 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", @@ -1602,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, @@ -1620,12 +1721,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), }); } @@ -1633,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) ---- @@ -1731,11 +1832,26 @@ 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(), + // At the last competition block, for the same reason the reconstruction stops there. + BigInt(finalBlock), + ); 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..1ca01f7 100644 --- a/core/src/realtime/liquity.ts +++ b/core/src/realtime/liquity.ts @@ -15,22 +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"; // 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 @@ -43,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; @@ -81,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, @@ -291,31 +288,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 +310,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 +319,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/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/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/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/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..9ba13c2 --- /dev/null +++ b/example/agents/peg-arb/agent.ts @@ -0,0 +1,168 @@ +/** + * 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"; + +// 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 = 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 = 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(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 ?? ""; + +// 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 { + 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; + // 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), + 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 >= 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 + // 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/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 d236c11..330426c 100644 --- a/sdk/src/action.ts +++ b/sdk/src/action.ts @@ -14,6 +14,20 @@ import { getAdapter, } 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; @@ -320,20 +334,37 @@ 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; }; 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": @@ -368,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/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..8f5b849 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,18 @@ 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. + // 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 } } + : {}), +}; // 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 +65,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 +75,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..5011fa7 100644 --- a/sdk/src/markets.ts +++ b/sdk/src/markets.ts @@ -31,7 +31,21 @@ 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. 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). // 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..110fd59 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,44 @@ 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; + // 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))) * + 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 +58,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..0a2a2ec --- /dev/null +++ b/sdk/src/stables.ts @@ -0,0 +1,315 @@ +// 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 { 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. +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, 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)) { + 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; + 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), + }); + } + deployedMarkets = out; + return out; +} + +// 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 allMarkets().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. +// +// 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 { + 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 +// 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[], + // 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; + 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 } : {}), + ...(blockNumber !== undefined ? { blockNumber } : {}), + } 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..8ae9665 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; @@ -754,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/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..51958b5 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..6e2868e --- /dev/null +++ b/test/stables.test.ts @@ -0,0 +1,403 @@ +// 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 { 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; +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()); + }, +); + +// --------------------------------------------------------------------------- +// 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); +});