diff --git a/CLAUDE.md b/CLAUDE.md index b3c1300..d3f010f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,18 +23,24 @@ deployer/ venue デプロイ(自己完結サブパッケージ。workspace |------|------|--------| | `agent.ts`(`decide(obs, ctx)` export) | ルール戦略 | runtime/bot.ts が read→decide→send のループで駆動(`export const config = { intervalMs }` で間隔指定可) | | `agent.ts`(`run(ctx)` export) | 自走型 | bot.ts はループせず ctx(clients/observe/submit/log)を渡して委譲(例 liquidator) | -| `agent.ts` + `improve.md`(frontmatter: name/description 必須) | **自己改善型**(ADR 0018) | decide を毎ブロック駆動しつつ、LLM が取引経路の**外**で戦略コードを書き換える | +| `agent.ts` + `prompt.md`(frontmatter: **`kind: improve`** / name / description 必須) | **自己改善型**(ADR 0018) | decide を毎ブロック駆動しつつ、LLM が取引経路の**外**で戦略コードを書き換える | `runtime/`(汎用スクリプト: bot/read/send/llm/improve/deploy/agentLog)と `lib/`(共有戦略ヘルパ)は予約名。 **プロンプト型(毎判断 LLM)は ADR 0018 で廃止**。実測で 1 判断 8〜28 ブロック・行動回数がルール型の 1/64 で競技として成立しなかった(ADR 0017 §5 B1)。`ERIS_AGENT_MODE` / `ERIS_PROMPT_*` は fail-fast する。 -`improve.md` は prompt.md の改名ではない(前者は「いつ・何を根拠に・どう直すか」、後者は「この observation で -どう動くか」)。ロスターの `env`: -- `ERIS_AGENT_FROZEN: "1"` — improve.md を無視して戦略を固定。**ADR 0018 §5 が要求する frozen 対照** +**`prompt.md` は同じ名前で意味が逆になっている**(ADR 0018 Amendment 1。当初は `improve.md` という別名で +分離していたのを、名前を戻した)。旧 prompt.md は「この observation でどう動くか」、今の prompt.md は +「いつ・何を根拠に・どう直すか」。旧形式 19 個は f42fd2a で削除済みだが git 履歴と旧 bundle には残っており、 +**frontmatter のキー(name/description)も同じ**なので、区別できるのは `kind: improve` だけ。マーカーの無い +prompt.md は**起動時に fail-fast**(黙って読むと、取引指示が「改訂方針」として system prompt に入る)。 +`improve.md` だけがあるディレクトリも fail-fast(黙って無改訂で走ると、LLM が一度も動かなかったことが +どこにも出ない)。ロスターの `env`: + +- `ERIS_AGENT_FROZEN: "1"` — prompt.md を無視して戦略を固定。**ADR 0018 §5 が要求する frozen 対照** (自己改善が効いたかを毎 run 見えるようにする)をディレクトリ複製なしで作る -- `ERIS_LLM_MODEL: ""` — 改訂呼び出しのバックエンド(improve.md の frontmatter が優先)。 +- `ERIS_LLM_MODEL: ""` — 改訂呼び出しのバックエンド(prompt.md の frontmatter が優先)。 API キー無しでも `codex[:]` / `claude-cli[:]` でサブスク CLI 実行可 = docs/guide/llm-agents.md - `ERIS_IMPROVE_LOG_CALLS: "1"` — 改訂の生のやり取りを `agents/.llm.jsonl` に残す(既定 off) @@ -79,14 +85,14 @@ 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` は issue #39 → #27 待ち。`lst` は競技セット外 + - **公式レジーム**: `calm` / `cex-drift`(OU に drift、kappa 弱化)/ `informed-flow`(相関した方向性フロー)/ `whale`(単発大口の点イベント)/ `lending-incident`(暴落 + victim + 清算 + 同じ窓の引き抜き)/ `crash`(価格ギャップ + 同じ窓での引き抜き。3 venue が同時に薄くなる)。`depeg`(レジストリの stable を外す方)は issue #27 待ち。`lst` / `liquity` は競技セット外(venue 単体検証用) - `--score-every N` は採点断面の間引き。成績は初期/最終断面しか使わない(`alphaByAgent = alphaLast − alphaFirst`)ので**スコアは不変**、equity curve が粗くなるだけ - `npm run typecheck` / `npm run test` — 型チェック / ユニットテスト - `npm run check:strategy` — 戦略コードの cheatcode 静的検査(入口ゲート) - `npm run check:boundaries` — workspace 依存方向(example → sdk ← core)の検査 - `npm run bundle:agent ` — 提出用 zip(runtime + sdk + lib + 対象 agent。ADR 0015 §7) -> **deployer は本 repo 同梱**(`deployer/`。旧 `../eris-app-deployer` を統合)。全 protocol を空の anvil へ deploy する自己完結のサブパッケージ(独自の `package.json` / `foundry.toml`)。初回のみ `cd deployer && npm install && forge build && cp .env.example .env && ./scripts/setup-vendors.sh`。以降は `cd deployer && npm run deploy -- --keep-fresh` で anvil 起動+全 venue deploy。`vendor/` の重いクローン(gmx-src/curve-src/twocrypto-src)は git 管理外で `setup-vendors.sh` が再現する。 +> **deployer は本 repo 同梱**(`deployer/`。旧 `../eris-app-deployer` を統合)。全 protocol を空の anvil へ deploy する自己完結のサブパッケージ(独自の `package.json` / `foundry.toml`)。初回のみ `cd deployer && npm install && forge build && cp .env.example .env && ./scripts/setup-vendors.sh`。以降は `cd deployer && npm run deploy -- --keep-fresh` で anvil 起動+全 venue deploy。**焼き直すときは anvil ごと立て直す**(`--keep-fresh` が消すのは deployments.json だけ。全 venue の seed で deployer アカウントは 100 万 ETH のうち ~99.9 万を使うので、同じ anvil に 2 回目を流すと WETH の wrap で `insufficient funds` で落ちる)。`vendor/` の重いクローン(gmx-src/curve-src/twocrypto-src)は git 管理外で `setup-vendors.sh` が再現する。 > 評価・採点・可視化系コマンド(`sim` 同期ラウンド / `evaluate` / `gate` / `discrimination` / `leaderboard` / `dashboard` / `stress-report`)は撤去済み。run は `sim:realtime` 一本。run 後の解析は `runs//` の `summary.json` / `events.jsonl` / `blocks.csv` を直接読む。 @@ -155,6 +161,72 @@ OU の base price はそのまま進め、その上に **SEED 由来でランダ 一方で WETH を持たない venue-arb は +115)。alphaUsdc は free inventory の β しか除去せず、 LST ポジションは live mark のため。ETH 建て採点(DAT 型)が issue #38 の motivation で follow-on +### CDP stablecoin venue(Liquity V1 フォーク = eUSD。issue #39。既定 off・**ローカルデプロイ専用**) + +Liquity V1 の core を**無改変**でフォークした CDP(`deployer/src/protocols/liquity.ts`)。Recovery Mode・ +再分配・sorted list・2 本の動的手数料がそのまま入っているので、他 venue に無い skill が 4 つ増える: + +- **redemption arb** — eUSD は常に「最もリスクの高い Trove に対して $1 分の担保」と交換できる。よって + eUSD/USDC プールのディスカウントは**プロトコルが強制する価格に対する乖離**であって価格予想ではない(ADR 0007 の α 方向) +- **Stability Pool** — eUSD を預けて清算債務を吸収し担保を割引で受け取る +- **Recovery Mode** — system TCR が CCR(150%) を割ると清算閾値が MCR でなくなり、**その時点の TCR** を + 下回る Trove が清算対象になる(SP がその債務を全額吸収できる場合のみ。押収は債務の 110% で頭打ちで、 + 余剰は借り手が claim できる)。全員の線が同時に動くのが Aave の per-position HF と対照的 +- **sorted list 上の位置** — 償還は最下位 ICR から walk するので、借り手は「自分の前にどれだけ債務があるか」を守る + +ours なのは 2 つだけ(core は無改変): +- `LiquityPriceFeedAdapter` — Liquity は wiring 後に ownership を renounce するのでオラクルアドレスは永久固定。 + 一方 run は毎回新しい PriceFeed を deploy するので、その間に挟んで admin key で毎 run 差し替える +- `LiquityRedemptionHelper` — **部分償還のヒントは実行時価格に依存する**(`_redeemCollateralFromTrove` が + 執行価格から NICR を再計算してヒントと一致しなければ partial を cancel)。環境はブロック毎にオラクルを + 書き、しかも agent より先に入るので、オフチェーンで計算したヒントは構造的に必ず陳腐化する + (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 の実際の性質) +- **担保は native ETH**(core が `msg.value` で受ける)。action 側は WETH wei 建てで、`buildTxs` が + `WETH.withdraw` を前置する。ただし**ガスと同じ残高**なので、全部突っ込むと閉じる tx すら送れなくなる。 + observation に `ethBalanceWei` / `suggestedGasReserveWei` を出すが**強制はしない**(self-stranding は正当な負け) +- action は 8 つ: `liquityOpenTrove` / `liquityAdjustTrove` / `liquityCloseTrove` / `liquityRedeem` / + `liquityProvideToSP` / `liquityWithdrawFromSP` / `liquityLiquidate` + `liquitySwapEusd`。 + 最後の 1 つは issue #39 の列挙には無いが、**venue 自身の α(デペグを買って償還する)が届かなくなる**ため追加 +- **`eusdDepeg` ストレスイベント**(`stress.events`)— プールは par で seed されるので、放っておくと + redemption arb は「何もしないのが正解」になる。環境(deployer アカウント = genesis Trove の余剰 eUSD 保有者)が + 窓の間だけ eUSD を売り、閉じたら買い戻す。liquidityPull と同じ**毎ブロック目標へ reconcile** 方式 + (一撃だと dropped block で取り残される)。magnitude は「プールの seeded eUSD depth の何割を売ったか」 +- **較正**(実測。100k/100k・A=100 のプール): 40k 売却で 114bps / 50k で 175bps / 60k で 282bps。 + 償還手数料 floor 50bps + 償還 ETH を USDC に戻す ~30bps を超えて初めて α になる。 + プールの A は 2000 ではなく **100**(A=2000 だと半分売っても 4.4bps しか動かず、償還手数料を永久に超えない)。 + eUSD 供給 250k に対し baseRate は 5k 償還ごとに約 +100bps 上がるので、**先に償還した者が後続の価格を決める** +- coordinator は `liquity_setup`(オラクル差し替えと drift 検証。Recovery Mode 開幕やデペグ済みチェーンは fail-fast)/ + `liquity_block`(毎ブロックの peg・TCR・手数料・最下位 ICR)/ `stress_eusd_depeg`(+ `_setup` / `_capped` / + `_failed` / `_restored`)を emit する +- **オラクル順序の実測**(issue #39 の Open point「清算は Aave より順序に敏感か」への回答): 敏感だが + **特別扱いは不要**。実測(`config/regimes/liquity-crash.yaml`, seed 501)では、Trove が MCR を割った + ブロック 982 → agent が観測した 983(観測は 1 ブロック遅れ)→ 清算が着弾した 984 で **2 ブロック遅延**。 + 内訳は「観測遅れ 1 + mempool 1」で、これは全 venue 共通。**部分償還のヒントと違い、`liquidate()` には + 実行時に一致しなければならない値が無い**(執行価格で ICR を再判定するだけ)ので、価格が戻れば単に + revert して gas を捨てるだけ=構造的な破綻ではない。よって helper のような仕組みは清算側には不要 +- 参照 agent は 3 体: `redemption-arb`(α 側)/ `trove-manager`(借り手側。清算・償還・Recovery Mode に + 対する防御)/ `sp-underwriter`(Stability Pool で清算を吸収し、自分で `liquidate` を叩いて担保を取る)。 + 借り手の防御が効くかは**借りた eUSD を使ったかどうか**で決まる(`ERIS_TROVE_SPEND_DEBT`)。実測で + 200% 保持組は無傷、125% で全額 post して eUSD を売った組は清算され −13,140(担保 20 ETH を失い USDC を残す) +- **Recovery Mode は現状の較正では到達不能**(実測: seed 501 で最小 TCR 2.244 対 CCR 1.5)。genesis Trove + が 250 ETH / 250k eUSD(300%)で TCR を支配するため。到達させるには system 債務を約 3 倍にする必要があり、 + それは償還手数料カーブ(供給に反比例。250k で 5k 償還あたり +100bps → 700k なら +36bps)と SP の相対深度 + (RM の清算は SP が債務を全額吸収できる場合のみ成立)を必ず薄める。**issue #59** に分離 +- **LQTY は意図どおり「値付けしないが見える」**: SP 預入で LQTY gain が付き、run 後に + `scoring_unpriced_holdings` に `erc20-unaccounted` として 61.3 LQTY が報告された(黙って 0 にしていない) +- 設定例は `config/liquity.yaml`、レジームは `config/regimes/liquity.yaml`(α 側)と + `config/regimes/liquity-crash.yaml`(借り手 / 引受側)、参照 agent は + `example/agents/redemption-arb/`(`agent.ts` + `prompt.md`)。issue #39 は「agent.ts と prompt.md を + 両方積め」と書いているが、その理由(既定ロスターが prompt モード = LLM が毎判断する)は ADR 0018 で + 消えている。今の prompt.md は改訂方針であって毎判断プロンプトではない + 実時間化(ADR 0005)の前提: **SEED(=regime) は市場条件のラベル**で価格パスは再現可能だが、tx タイミング/着順は非決定 → 同一 regime でも結果はぶれる。run 長は `ERIS_RUN_BLOCKS` 固定で揃える。run の比較が要るときは同一 config を複数回回してサンプルを貯め、`runs//summary.json` を集計する(旧 evaluate/gate は撤去済み)。 ## アーキテクチャ(環境とエージェント実行の分離。ADR 0006 / ADR 0015) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb83f06 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Nyx Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 163f4cb..1c124e6 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ For details, see [Backtesting](docs/guide/backtest.md). | [Backtesting](docs/guide/backtest.md) | Replaying state dump + official regimes, iterating with `--repeat`, sparring, what is and isn't measurable | | [Run Output and Analysis](docs/guide/run-output.md) | The output files under `runs//` and how to analyze a run afterwards | | [Protocols and Actions](docs/guide/protocols-and-actions.md) | Reference: actions per venue, stablecoin accounting, oracle control | -| [Self-improving Agents](docs/guide/llm-agents.md) | agent.ts + improve.md (in-run strategy rewriting, sandbox, rollback, frozen control) | +| [Self-improving Agents](docs/guide/llm-agents.md) | agent.ts + prompt.md (in-run strategy rewriting, sandbox, rollback, frozen control) | **How the environment works / operations**: @@ -151,6 +151,20 @@ For details, see [Backtesting](docs/guide/backtest.md). --- +## License + +MIT — see [LICENSE](LICENSE). + +That is the answer to the question this repository is built around: **copy `example/agents//`, +change it, keep what you build.** A strategy written from one of the bundled agents is yours, and +nothing here asks for it back. + +A few files in the tree are somebody else's work and keep their own terms — the canonical WETH9 mock +and Curve's prebuilt artifacts. [THIRD-PARTY.md](THIRD-PARTY.md) lists them, along with the +dependencies `deployer/scripts/setup-vendors.sh` fetches at setup rather than redistributing. + +--- + ## Disclaimer This is an **MVP / Proof of Concept** for research and experimentation, not intended for production use. The Aave / GMX oracles are mocks controlled by the coordinator, and the fair price is a synthetic path generated deterministically. Simulation results (PnL, ranking, discrimination) depend on the environment configuration, SEED, and sample count, and do not guarantee real-market performance. diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md new file mode 100644 index 0000000..90448bd --- /dev/null +++ b/THIRD-PARTY.md @@ -0,0 +1,43 @@ +# Third-party code + +The repository is MIT (see [LICENSE](LICENSE)). That covers everything written here. Three things in +the tree are somebody else's work and keep their own terms, and this file records which — so that +"the repository is MIT" is a statement about the code we wrote rather than about every byte in it. + +## Redistributed here + +**`deployer/contracts/WETH9.sol` — GPL-3.0.** The canonical WETH9, kept verbatim (its own +`SPDX-License-Identifier: GPL-3.0` header is intact) so that the local chain's wrapped ether behaves +byte-for-byte like the deployed one every venue integrates against. It is a development mock, never +part of a distributed agent bundle. + +**`deployer/vendor/curve/*.json` — compiled Curve contracts.** ABI and bytecode only, committed +because Curve ships prebuilt artifacts and rebuilding Vyper is not reproducible without a pinned +toolchain. Upstream, recorded in `deployer/scripts/setup-vendors.sh`: + +| file | upstream | +|---|---| +| `CurveStableSwapNG*.json` | [curvefi/stableswap-ng](https://github.com/curvefi/stableswap-ng) | +| `CurveTwocrypto*.json` | [curvefi/twocrypto-ng](https://github.com/curvefi/twocrypto-ng), tag `lite-0.3.10` | + +**`deployer/vendor/gmx-localhost.patch` and `deployer/vendor/aave/*`** — a patch and a build +configuration authored here against those projects' sources. + +## Fetched, not redistributed + +`deployer/scripts/setup-vendors.sh` clones the following at pinned commits when the deployer is first +set up. None of it is in this repository (`deployer/.gitignore`), and each carries its own licence: + +| clone | project | +|---|---| +| `vendor/liquity-src/` | Liquity V1 (all 81 contract sources are `SPDX-License-Identifier: MIT`) | +| `vendor/gmx-src/` | GMX V2 (gmx-synthetics) | +| `vendor/curve-src/`, `vendor/twocrypto-src/` | Curve, for rebuilding the artifacts above | + +npm dependencies are likewise resolved at install time and are not redistributed here. + +## Agent bundles + +`npm run bundle:agent ` packages the runtime, the SDK, `example/agents/lib/` and one agent +directory. Everything it contains is this repository's own code, under MIT — a participant may copy, +modify and keep whatever they build from it. diff --git a/config/example.yaml b/config/example.yaml index 957ff9c..7d9309d 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -20,7 +20,7 @@ # start `npm run anvil` in another terminal with ARB_RPC_URL set. # # The agents below are rule strategies (agent.ts) that trade every block. Three of them also ship an -# improve.md, so an LLM periodically rewrites the strategy while it runs (ADR 0018) -- the LLM is +# prompt.md, so an LLM periodically rewrites the strategy while it runs (ADR 0018) -- the LLM is # never in the trade path. Prompt mode, where an LLM produced each action, was removed: it managed # one decision every 8-28 blocks and 1/64 the actions of the same strategy in rule mode. # @@ -37,13 +37,14 @@ run: seed: 1 # The trading loop is rule-speed (one decision per block) regardless of the LLM, so the run length # is not set by LLM latency any more. It does need to be long enough for at least one revision to - # land -- improve.md declares reviseEveryBlocks: 60. + # land -- prompt.md declares reviseEveryBlocks: 60. blocks: 100 seconds: 300 blockTimeSec: 2 # gmx and aave are left out because they are the slow half of the deploy; add them once - # `deployer` has run with them. `lst` is local-only (issue #38) and fails fast on a fork. - protocols: [uniswap, balancer, curve, lst] + # `deployer` has run with them. `lst` (issue #38) and `liquity` (issue #39) are local-only and + # fail fast on a fork. + protocols: [uniswap, balancer, curve, lst, liquity] economicGas: false localDeploy: true reportDir: ./runs @@ -97,9 +98,26 @@ flow: # To trade multi-asset (WBTC), enable WBTC flow (creates price divergence = arbitrage opportunities): # baseMax: { WBTC: "50000000" } -# stress (market stress events. default off): -# stress: -# events: +# stress (market stress events). +# +# The eUSD depeg is here rather than commented out because the CDP venue is otherwise correctly +# inert: the deploy seeds the eUSD/USDC pool at par, and redemption costs a 50bps floor fee, so with +# nothing pushing the peg the right move is to sit still and redemption-arb would do exactly that. +# The event has the environment sell eUSD into its own market for the length of a window and buy it +# back afterwards. See config/liquity.yaml for how the magnitude range was calibrated. +# +# Uncomment the crash below to add a price gap as well (ADR 0009); `victimCount` stages the Aave +# positions a liquidator agent could take. +stress: + events: + - { + type: eusdDepeg, + magnitudeRange: [0.4, 0.6], + windowFrac: [0.25, 0.6], + rampBlocks: 4, + holdBlocks: 10, + decayBlocks: 12, + } # - { type: crash, magnitudeRange: [0.12, 0.16], windowFrac: [0.3, 0.7], rampBlocks: 3, holdBlocks: 6, decayBlocks: 8 } # victimCount: 0 @@ -113,7 +131,7 @@ agents: description: does nothing (baseline; rule-based on purpose) - id: venue-arb wallet: AGENT2_PRIVATE_KEY - description: WETH-only cross-venue arbitrage, with an LLM revising it in-run (improve.md) + description: WETH-only cross-venue arbitrage, with an LLM revising it in-run (prompt.md) env: { ERIS_IMPROVE_LOG_CALLS: "1" } # Claude Code / Codex subscription instead of Ollama (no API key): # env: { ERIS_IMPROVE_LOG_CALLS: "1", ERIS_LLM_MODEL: "claude-cli:haiku" } @@ -130,5 +148,9 @@ agents: wallet: AGENT5_PRIVATE_KEY description: liquid staking — stake for yield, or trade the LST redemption/market gap, revised in-run env: { ERIS_IMPROVE_LOG_CALLS: "1" } + - id: redemption-arb + wallet: AGENT6_PRIVATE_KEY + description: CDP stablecoin — buy eUSD below par and redeem it against the riskiest Trove, revised in-run + env: { ERIS_IMPROVE_LOG_CALLS: "1" } # config/lst.yaml is the same venue with a second competing participant and the calibration knobs # spelled out, if you want to look at the LST market on its own. diff --git a/config/lst.yaml b/config/lst.yaml index b7ef56b..c9dd182 100644 --- a/config/lst.yaml +++ b/config/lst.yaml @@ -127,7 +127,7 @@ agents: wallet: AGENT1_PRIVATE_KEY baseline: true description: does nothing (baseline) - # Self-improving (agent.ts + improve.md, ADR 0018): the strategy trades every block and an LLM + # Self-improving (agent.ts + prompt.md, ADR 0018): the strategy trades every block and an LLM # periodically rewrites it. Needs OLLAMA_API_KEY in .env.local, or ERIS_LLM_MODEL: "claude-cli" # for a subscription CLI. Without a backend the run still completes — the revisions are recorded # as failed and the strategy keeps trading unchanged. diff --git a/config/regimes/liquity-crash.yaml b/config/regimes/liquity-crash.yaml new file mode 100644 index 0000000..2063b8f --- /dev/null +++ b/config/regimes/liquity-crash.yaml @@ -0,0 +1,90 @@ +# config/regimes/liquity-crash.yaml — the CDP venue's borrower and underwriter sides (issue #39). +# +# Not part of the ADR 0017 competition set. `liquity` covers the venue's α (buy a depegged eUSD and +# redeem it); this one covers the other half, which only exists when the collateral price falls: +# +# trove-manager holds a Trove through the path, against liquidation, redemption and Recovery Mode +# sp-underwriter deposits eUSD to absorb what falls under MCR, calls the liquidation, banks the ETH +# +# Run: npm run backtest -- --regime liquity-crash --seed 501 [--agents ] +# +# **Two Troves that differ in one switch.** `trove-manager` holds the eUSD it draws, so repaying is +# always available as a defence; `trove-manager-spent` sells it for USDC and posts every last WETH, +# so when the crash comes it has neither. That is the regime's question -- what a CDP borrower's +# defences are actually worth -- and it is why one of them gets liquidated and the other does not. +# +# Funding is WETH-heavy because a Trove needs collateral. That reintroduces price drift into +# netPnlUsdc; read alphaUsdc, and read scores within a run rather than across price paths. +# +# **Recovery Mode is not reachable here** and the regime does not pretend otherwise. The genesis +# Trove is 250 ETH against 250k eUSD (300% ICR) and dwarfs anything agents can open with this +# funding: measured at seed 501, the system TCR bottoms at 2.244 against a CCR of 1.5. Reaching it +# needs the system's debt roughly tripled, which dilutes the redemption fee curve and the Stability +# Pool's relative depth -- both of them properties #39 measured -- so it is issue #59 rather than a +# constant in this file. + +run: + blocks: 120 + seconds: 900 # a stress run ends by block count (ADR 0009) + blockTimeSec: 2 # fixed to the regime (ADR 0016 §2) + # uniswap is not optional: both the Stability Pool and a closed Trove pay out in native ETH, and + # without a spot market an agent has to keep the exposure instead of banking it. + protocols: [uniswap, balancer, curve, liquity] + economicGas: false + localDeploy: true + reportDir: ./runs + +funding: + # A Trove needs collateral, so unlike the evaluation profile this hands out WETH (ADR 0017 §4 + # applies to the competition set; this regime is venue verification). + wethWei: "20000000000000000000" # 20 WETH + usdcUnits: "25000000000" + +limits: + agentWethWei: "5000000000000000000" + agentUsdcUnits: "5000000000" + +flow: + uninformedMaxWethWei: "1000000000000000000" + informedMaxWethWei: "2000000000000000000" + balancerMaxWethWei: "1000000000000000000" + curveMaxWethWei: "1000000000000000000" + informedArbFeeBps: 30 + uninformedArrivalRate: "0.9" + uninformedSizeSigma: "1.0" + +stress: + events: + # Deep enough to take a thin Trove through MCR, and no deeper. `trove-manager-spent` opens at + # 125%, so 15% puts it at 106% and 22% at 98% -- under the 110% floor across the whole range, + # which is what makes the liquidation path a property of the regime rather than of the seed. + # The Trove that kept its eUSD sits at 200% and comes through every draw. + - { + type: crash, + magnitudeRange: [0.15, 0.22], + windowFrac: [0.3, 0.65], + rampBlocks: 4, + holdBlocks: 12, + decayBlocks: 15, + } + +agents: + - id: noop + wallet: AGENT1_PRIVATE_KEY + baseline: true + description: does nothing (baseline) + - id: trove-manager + wallet: AGENT2_PRIVATE_KEY + description: borrows at 200% and keeps the eUSD, so repayment stays available as a defence + - id: trove-manager-spent + dir: trove-manager + wallet: AGENT3_PRIVATE_KEY + description: borrows at 125%, posts everything and sells the eUSD — the borrower with no defences left + env: + ERIS_TROVE_TARGET_ICR: "1.25" + ERIS_TROVE_FLOOR_ICR: "1.15" + ERIS_TROVE_OPEN_BPS: "10000" + ERIS_TROVE_SPEND_DEBT: "1" + - id: sp-underwriter + wallet: AGENT4_PRIVATE_KEY + description: underwrites the Stability Pool, calls the liquidation and banks the collateral diff --git a/config/regimes/liquity.yaml b/config/regimes/liquity.yaml new file mode 100644 index 0000000..7891df3 --- /dev/null +++ b/config/regimes/liquity.yaml @@ -0,0 +1,87 @@ +# config/regimes/liquity.yaml — the CDP stablecoin venue (issue #39). ADR 0016 §2 +# +# Not part of the ADR 0017 competition set (the seven regimes there predate this venue). Kept as an +# official regime for venue-level verification, and it follows the same discipline: ranges rather +# than values, blockTimeSec fixed to the production value, seed supplied at run time. +# Run: npm run backtest -- --regime liquity --seed 401 [--agents ] +# +# What this regime tests that the others do not: a dislocation against a price the protocol +# *enforces* rather than one the market has to agree on. eUSD is always exchangeable for $1 of +# collateral against the riskiest Trove, so a discount on its pool is an α with a floor under it — +# bounded by a redemption fee that every redemption in the run raises for everyone after it. +# +# Funding stays USDC-only (ADR 0017 §4): the whole loop is USDC -> eUSD -> ETH -> USDC, so nobody +# has to start with price exposure to trade it. + +run: + blocks: 120 # long enough for the depeg window plus the recovery after it + seconds: 900 # a stress run ends by block count (ADR 0009) + blockTimeSec: 2 # fixed to the regime (ADR 0016 §2) + # uniswap is not optional: redemption pays native ETH, and without a spot market an agent has to + # keep the price exposure it just earned rather than banking the arb. + protocols: [uniswap, balancer, curve, liquity] + economicGas: false + localDeploy: true # backtest runs on a local anvil loaded with the distributed state + 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 eUSD flow bot: the peg moves only when the stress event below pushes it or an agent + # trades it. That is why the event is part of the regime rather than optional decoration — at par + # the venue is correctly inert. + +stress: + events: + # Calibration, measured on the deployed pool (100k/100k, A=100). magnitude is the fraction of + # its seeded eUSD depth the environment has sold at the top of the trapezoid, and what that + # produces is the *marginal* discount an agent would trade against: 40k sold moves it 114bps, + # 50k moves it 175bps, 60k moves it 282bps. + # + # The costs it has to clear: a 50bps redemption fee floor, ~30bps to sell the redeemed ETH back + # to USDC, and whatever safety margin the agent keeps. So the low end of this range barely pays + # and the high end pays well -- and the trapezoid's ramp passes through every level in between, + # where the right move is to wait. A range where every draw pays would measure reaction time + # rather than judgement. + # + # One more thing this scale makes sharp: with a 250k eUSD supply, Liquity's baseRate rises about + # 100bps per 5k redeemed, so the *first* redemption is cheap and the next one may not clear at + # all. That is the venue's own timing pressure, not a calibration artefact. + - { + type: eusdDepeg, + magnitudeRange: [0.4, 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: redemption-arb + wallet: AGENT2_PRIVATE_KEY + description: buys eUSD below par and redeems it against the riskiest Trove + - id: redemption-arb-eager + dir: redemption-arb + wallet: AGENT3_PRIVATE_KEY + description: the same strategy with a thinner safety margin + env: { ERIS_REDEMPTION_SAFETY_BPS: "5" } + - id: venue-arb + wallet: AGENT4_PRIVATE_KEY + description: WETH-only cross-venue arbitrage (keeps the AMM venues honest) diff --git a/core/package.json b/core/package.json index 01e6786..80c3297 100644 --- a/core/package.json +++ b/core/package.json @@ -2,6 +2,7 @@ "name": "@eris/core", "version": "0.1.0", "private": true, + "license": "MIT", "type": "module", "description": "eris-competition environment daemon + scoring (ADR 0015). Off-limits to participants. Importing from example is forbidden (checked by scripts/checkImportBoundaries.ts).", "dependencies": { diff --git a/core/src/backtest/shared.ts b/core/src/backtest/shared.ts index 0a059b7..4ac41c4 100644 --- a/core/src/backtest/shared.ts +++ b/core/src/backtest/shared.ts @@ -197,6 +197,7 @@ const VENUE_TO_DEPLOYMENT_KEY: Record = { gmx: "gmxV2", aave: "aaveV3", lst: "lst", + liquity: "liquity", }; // Whether the venues the regime requires are all present in the state dump (deployments bundled in diff --git a/core/src/realtime/agentProcess.ts b/core/src/realtime/agentProcess.ts index 9250c81..f70a841 100644 --- a/core/src/realtime/agentProcess.ts +++ b/core/src/realtime/agentProcess.ts @@ -70,7 +70,7 @@ export class RealtimeAgentProcess { args = spec.args ?? []; } else { // Convention resolution (ADR 0015 §6): id (or the dir override) points to //, and - // bot.ts drives its contents (agent.ts decide/run, plus improve.md when self-improving). + // bot.ts drives its contents (agent.ts decide/run, plus prompt.md when self-improving). const agentDir = resolve(agentsDir, spec.dir ?? spec.id); if (!existsSync(agentDir)) { throw new Error( diff --git a/core/src/realtime/coordinator.ts b/core/src/realtime/coordinator.ts index b98b796..e49a529 100644 --- a/core/src/realtime/coordinator.ts +++ b/core/src/realtime/coordinator.ts @@ -88,8 +88,19 @@ import { setupLiquidityPull, type LiquidityPullRuntime, } from "./liquidity.js"; +import { + liquityBlockEvent, + watchLiquityEvents, + reconcileEusdDepeg, + restoreEusdDepeg, + setupEusdDepeg, + setupLiquity, + type EusdDepegRuntime, + type LiquityRuntime, +} from "./liquity.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"; import { VulnSchedule } from "./vulnEvents.js"; import { deployVulnPools, @@ -686,32 +697,72 @@ export async function runRealtimeSimulation( ? await setupLst(ctx, logger) : null; - // ---- liquidity-pull stress event (issue #52): find the environment-owned depth this run may - // withdraw, and refuse to start if the schedule asks for depth nobody here owns. Like the LST - // setup this sends mined transactions (standing approvals for the restore leg), so it belongs - // before interval mining starts. - let liquidityPullRuntime: LiquidityPullRuntime | null = null; - if (schedule.hasLiquidityPull()) { - // The seeded positions belong to the deployer, which is the anvil default account 0 (ADR 0016 - // §4). An agent bound to AGENT0_PRIVATE_KEY is that same account, and two senders on one key - // race on the nonce — the failure mode that once froze the LST redemption rate for a whole run. - const lpOwnerPk = DEFAULT_ANVIL_PRIVATE_KEYS[0]; + // ---- Liquity venue (issue #39): point the CDP's permanent oracle adapter at this run's + // PriceFeed, and refuse to start on a venue that would mark Troves against another run's price + // or on a peg that is already broken. A mined setup transaction, so it belongs before interval + // mining starts -- and after the prewarm, whose trading is what settles the fair price it checks. + const liquityRuntime: LiquityRuntime | null = enabledIds.includes("liquity") + ? await setupLiquity( + ctx, + { priceFeed: priceFeedAddress, fairPrice: latestFairPrice }, + logger, + ) + : null; + + // The environment's depth and its eUSD both belong to the deployer, which is the anvil default + // account 0 (ADR 0016 §4). An agent bound to AGENT0_PRIVATE_KEY is that same account, and two + // 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()) { const clash = agentRuntimes.find( - (a) => a.privateKey.toLowerCase() === lpOwnerPk.toLowerCase(), + (a) => a.privateKey.toLowerCase() === deployerPk.toLowerCase(), ); if (clash) { throw new Error( - `stress event liquidityPull withdraws depth as the deployer account, but agent "${clash.id}" ` + - "is bound to the same key (AGENT0_PRIVATE_KEY = anvil account 0). Move that agent to " + - "another wallet, or to AUTO", + `a stress event trades as the deployer account, but agent "${clash.id}" is bound to the ` + + "same key (AGENT0_PRIVATE_KEY = anvil account 0). Move that agent to another wallet, " + + "or to AUTO", ); } + } + + // ---- 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; + if (schedule.hasEusdDepeg()) { + if (!liquityRuntime) { + throw new Error( + "stress event eusdDepeg needs the liquity venue: add it to run.protocols (it is the " + + "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(), { + ownerId: "liquity-depeg", + role: "system", + }); + } + + // ---- liquidity-pull stress event (issue #52): find the environment-owned depth this run may + // withdraw, and refuse to start if the schedule asks for depth nobody here owns. Like the LST + // setup this sends mined transactions (standing approvals for the restore leg), so it belongs + // before interval mining starts. + let liquidityPullRuntime: LiquidityPullRuntime | null = null; + if (schedule.hasLiquidityPull()) { liquidityPullRuntime = await setupLiquidityPull( ctx, schedule, { localDeploy: config.localDeploy, - ownerPk: lpOwnerPk, + ownerPk: deployerPk, // Only the venues this run turned on: an event that names no venue thins every book, and // asking for one that is not deployed would fail the discovery check for no reason. enabledVenues: PULL_VENUES.filter((v) => enabledIds.includes(v)), @@ -1305,6 +1356,12 @@ export async function runRealtimeSimulation( // source for whether the venue behaved (issue #38). const lstState = stateById.get("lst") as LstState | undefined; if (lstState) logger.event(lstBlockEvent(lstState, bn)); + // Liquity telemetry rides on the same read: where the peg sat, how the fee curves moved + // and whether the system ever entered Recovery Mode (issue #39). + const liquityState = stateById.get("liquity") as + | LiquityState + | undefined; + if (liquityState) logger.event(liquityBlockEvent(liquityState, bn)); const uni = stateById.get("uniswap") as { priceUsdcPerWeth?: number } | undefined; latestHistory.push({ @@ -1418,6 +1475,58 @@ 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", + }); + } + } catch (error) { + logger.event({ + type: "stress_eusd_depeg_task_failed", + blockIndex, + blockNumber: bn, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + // Liquity ground truth (issue #39): a Trove that disappeared could have been closed, + // redeemed away or liquidated, and only the venue's own logs say which. The open question + // the issue leaves -- whether Liquity's ordering sensitivity needs special handling when + // the oracle is rewritten every block ahead of every agent -- is about liquidations, so + // they have to be counted rather than inferred from the block state. + const liquityWatchTask = async (): Promise => { + if (!liquityRuntime || fromBlock > bn) return; + try { + await watchLiquityEvents(ctx, fromBlock, bn, logger); + } catch (error) { + logger.event({ + type: "liquity_watch_failed", + blockNumber: bn, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + // Record each task's duration (for diagnosing the environment loop's bottleneck; the measurement source for ADR 0006 "judgment metrics"). const timed = async (task: () => Promise): Promise => { const t0 = Date.now(); @@ -1434,6 +1543,8 @@ export async function runRealtimeSimulation( if (stressVictims.length > 0) tasks.push(timed(victimTask)); if (vulnRuntime) tasks.push(timed(vulnTask)); if (liquidityPullRuntime) tasks.push(timed(liquidityTask)); + if (eusdDepegRuntime) tasks.push(timed(depegTask)); + if (liquityRuntime) tasks.push(timed(liquityWatchTask)); const results = await Promise.all(tasks); const [keeperMs, oracleMs, stateFlowMs] = results; let taskIdx = 3; @@ -1443,6 +1554,8 @@ export async function runRealtimeSimulation( const liquidityMs = liquidityPullRuntime ? results[taskIdx++] : undefined; + const depegMs = eusdDepegRuntime ? results[taskIdx++] : undefined; + const liquityMs = liquityRuntime ? results[taskIdx++] : undefined; logger.event({ type: "round_timing", blockNumber: bn, @@ -1453,6 +1566,8 @@ export async function runRealtimeSimulation( ...(victimMs !== undefined ? { victimMs } : {}), ...(vulnMs !== undefined ? { vulnMs } : {}), ...(liquidityMs !== undefined ? { liquidityMs } : {}), + ...(depegMs !== undefined ? { depegMs } : {}), + ...(liquityMs !== undefined ? { liquityMs } : {}), totalMs: Date.now() - roundStart, }); @@ -1502,6 +1617,20 @@ 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) { + try { + await restoreEusdDepeg(ctx, eusdDepegRuntime, logger); + } catch (error) { + logger.event({ + type: "stress_eusd_depeg_teardown_failed", + error: error instanceof Error ? error.message : String(error), + }); + } + } + // ---- 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()); diff --git a/core/src/realtime/events.ts b/core/src/realtime/events.ts index 4a48de2..de6d9de 100644 --- a/core/src/realtime/events.ts +++ b/core/src/realtime/events.ts @@ -30,10 +30,20 @@ import type { TokenSymbol } from "@eris/sdk/types.js"; // envelope drives a target depth instead of a price. Composed with crash on the // same window it is what makes regime 6 a crash rather than a larger opportunity -- // the gap says what is on offer, the depth says how much of it anyone can take. +// eusdDepeg eUSD is sold into its own market for the length of a window and bought back +// afterwards (issue #39). Also a state: the envelope drives how much of the pool's +// 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. // 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 = - "spike" | "crash" | "lstSlash" | "whale" | "liquidityPull"; + | "spike" + | "crash" + | "lstSlash" + | "whale" + | "liquidityPull" + | "eusdDepeg"; // How the run consumes each type: // overlay a multiplier layered on the fair price every block of its window (`at()`) @@ -50,6 +60,7 @@ const EVENT_KIND: Record = { lstSlash: "point", whale: "point", liquidityPull: "state", + eusdDepeg: "state", }; const isPointEvent = (type: StressEventType): boolean => @@ -70,6 +81,9 @@ export type StressEventConfig = { // For lstSlash this is the fraction of the staking pool burnt (0.02 = a 2% slash). // For liquidityPull this is the fraction of the seeded pool depth withdrawn at the top of the // trapezoid (0.5 = half the book gone while the window holds). + // For eusdDepeg this is the fraction of the eUSD/USDC pool's seeded eUSD depth the environment has + // sold into it at the top of the trapezoid (0.3 = 30k eUSD dumped into a 100k pool). The discount + // that produces is a property of the curve, not of this number. // For whale this is the order size in whole base units (30 = a 30 WETH market order). Absolute // 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. @@ -258,6 +272,28 @@ export class EventSchedule { return this.events.some((ev) => ev.type === "liquidityPull"); } + // Whether the run needs the eUSD depeg machinery at all (issue #39). Like the liquidity pull, the + // coordinator only stages the actor and tracks its inventory when this is true. + hasEusdDepeg(): boolean { + return this.events.some((ev) => ev.type === "eusdDepeg"); + } + + // 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 + // costs a block of lag instead of leaving the peg stuck wherever it happened to be. + eusdDepegFractionAt(blockIndex: number): number { + let sold = 0; + for (const ev of this.events) { + if (ev.type !== "eusdDepeg") continue; + const e = envelope(ev, blockIndex); + if (e === 0) continue; + // Additive rather than multiplicative: two overlapping dumps sell two amounts of eUSD. + sold += ev.magnitude * e; + } + return sold; + } + // The bases a liquidityPull targets. The coordinator needs them at setup, before any window opens, // to fail fast on a venue it cannot withdraw from. liquidityPullBases(): string[] { @@ -395,10 +431,11 @@ function parseOne(raw: unknown, i: number): StressEventConfig { o.type !== "crash" && o.type !== "lstSlash" && o.type !== "whale" && - o.type !== "liquidityPull" + o.type !== "liquidityPull" && + o.type !== "eusdDepeg" ) { throw new Error( - `${label}.type must be "spike", "crash", "lstSlash", "whale" or "liquidityPull"`, + `${label}.type must be "spike", "crash", "lstSlash", "whale", "liquidityPull" or "eusdDepeg"`, ); } if (o.alignWith !== undefined) { @@ -407,7 +444,8 @@ function parseOne(raw: unknown, i: number): StressEventConfig { o.alignWith !== "crash" && o.alignWith !== "lstSlash" && o.alignWith !== "whale" && - o.alignWith !== "liquidityPull" + o.alignWith !== "liquidityPull" && + o.alignWith !== "eusdDepeg" ) { throw new Error(`${label}.alignWith must be a stress event type`); } @@ -452,7 +490,12 @@ function parseOne(raw: unknown, i: number): StressEventConfig { // depth at all, every swap reverts, and the venue stops existing for the window. That is not // a thin book an agent has to size against -- it is an outage, and the regime is about the // former (issue #52: "how much of the gap can I actually take"). - ...(o.type === "lstSlash" || o.type === "liquidityPull" + // + // An eusdDepeg is bounded the same way once more: selling the pool's entire eUSD side leaves + // nothing to buy, so the discount stops being a price and becomes an outage. + ...(o.type === "lstSlash" || + o.type === "liquidityPull" || + o.type === "eusdDepeg" ? { max: 1, exclusiveMax: true } : {}), }, diff --git a/core/src/realtime/liquity.ts b/core/src/realtime/liquity.ts new file mode 100644 index 0000000..75a9caa --- /dev/null +++ b/core/src/realtime/liquity.ts @@ -0,0 +1,690 @@ +// Liquity venue: the environment's side of the CDP stablecoin (issue #39). +// +// Two jobs, and they are the two things about this venue that cannot live in the adapter. +// +// *The oracle.* Liquity renounces ownership once wired, so the price feed address baked into +// TroveManager is permanent -- while this environment deploys a fresh PriceFeed every run. The +// LiquityPriceFeedAdapter sits between them and each run points it at its own feed. Until that +// happens the venue marks collateral at the price it was deployed with, which would silently make +// every ratio in the observation fiction, so the setup below refuses to start without it. +// +// *The peg.* eUSD trades against USDC on the stableswap pool the deploy seeded at par, and at par +// there is nothing to trade: redemption arb only exists when the market has moved away from the $1 +// the protocol will always redeem at. `eusdDepeg` is the stress event that puts it there -- the +// environment sells eUSD into the pool for the length of a window and buys it back afterwards, the +// 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 { liquityPriceFeedAdapterAbi } from "@eris/sdk/abis.js"; +import { LIQUITY, requireEusdMarket } 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"; + +// 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 +// not, and if it does not, every Trove is marked against a price from another run entirely. +const ORACLE_TOLERANCE_BPS = 100; + +// The eUSD market is seeded at par by the deploy, so a gap this large at startup means the pool is +// not the one the deployment thinks it is (or the run inherited a chain a previous run left +// depegged). Either way the redemption arb would open as a freebie for whoever looks first. +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; + // Whether the environment's admin key could repoint the oracle. False means the run is marking + // against whatever the adapter already served, which the setup refuses -- kept for the log. + oracleRepointed: boolean; +}; + +/// Point Liquity's permanent oracle at this run's PriceFeed, then refuse to start on a venue that +/// would trade against the wrong price or a peg that is already broken. +export async function setupLiquity( + ctx: SimContext, + opts: { priceFeed: Address; fairPrice: number }, + logger: RunLogger, +): Promise { + if (!LIQUITY) { + throw new Error( + "the liquity protocol is enabled but no Liquity deployment is available: the venue exists " + + "only under local deploy (issue #39). Enable run.localDeploy with a state dump that " + + "includes liquity, or drop liquity from run.protocols.", + ); + } + const admin = accountAddress(ctx.adminPk); + const operator = (await ctx.publicClient.readContract({ + address: LIQUITY.priceFeed, + abi: liquityPriceFeedAdapterAbi, + functionName: "operator", + })) as Address; + if (operator.toLowerCase() !== admin.toLowerCase()) { + // Without the operator key the oracle keeps serving whatever it last served. Liquity would still + // run -- it never reverts on a stale price, by design -- and every ICR, TCR and liquidation in + // the run would be computed against a price this run never set. + throw new Error( + `the Liquity oracle adapter (${LIQUITY.priceFeed}) is owned by ${operator}, but this run's ` + + `admin key is ${admin}, so the venue cannot be pointed at this run's PriceFeed. It would ` + + "mark every Trove against the price baked in at deploy time (deployer/contracts/LiquityPriceFeedAdapter.sol).", + ); + } + await sendAndMine( + ctx.publicClient, + ctx.walletClient, + ctx.chain, + ctx.adminPk, + { + to: LIQUITY.priceFeed, + data: encodeFunctionData({ + abi: liquityPriceFeedAdapterAbi, + functionName: "setSource", + args: [opts.priceFeed], + }), + }, + ); + + // What the venue would actually serve now. Simulated rather than read: `fetchPrice` caches, so + // `lastGoodPrice` only tells us what some earlier transaction saw. + const served = ( + await ctx.publicClient.simulateContract({ + account: admin, + address: LIQUITY.priceFeed, + abi: liquityPriceFeedAdapterAbi, + functionName: "fetchPrice", + }) + ).result as bigint; + const servedUsd = Number(served) / 1e18; + const driftBps = + opts.fairPrice > 0 + ? Math.abs((servedUsd - opts.fairPrice) / opts.fairPrice) * 10_000 + : 10_000; + if (driftBps > ORACLE_TOLERANCE_BPS) { + throw new Error( + `the Liquity oracle serves ${servedUsd.toFixed(2)} USD/ETH while this run's fair price is ` + + `${opts.fairPrice.toFixed(2)} (${driftBps.toFixed(0)}bps apart, limit ${ORACLE_TOLERANCE_BPS}). ` + + `The adapter was pointed at ${opts.priceFeed}; a gap here means it is not reading it.`, + ); + } + + const state = await getLiquityState(ctx, opts.fairPrice); + logger.event({ + type: "liquity_setup", + troveManager: LIQUITY.troveManager, + eusd: LIQUITY.eusd, + market: LIQUITY.eusdUsdcPool ?? null, + priceFeed: opts.priceFeed, + oracleServedUsd: servedUsd, + oracleDriftBps: Number(driftBps.toFixed(2)), + tcr: state.tcr, + recoveryMode: state.recoveryMode, + troveCount: state.troveCount, + totalDebtEusdWei: state.totalDebtEusdWei.toString(), + borrowingRateBps: state.borrowingRateBps, + redemptionRateBps: state.redemptionRateBps, + stabilityPoolEusdWei: state.spTotalDepositsEusdWei.toString(), + marketPriceUsdc: state.midPriceUsdc, + marketQuoted: state.marketQuoted, + discountBps: state.discountBps, + }); + + // Recovery Mode at block zero would make the whole run about the seeded Troves rather than about + // the agents: borrowing is restricted and everything under CCR is liquidatable from the start. + if (state.recoveryMode) { + throw new Error( + `the Liquity system opens in Recovery Mode (TCR ${state.tcr.toFixed(3)} < CCR ${state.ccr}). ` + + "The genesis Trove is calibrated against the deploy-time price, so this usually means the " + + "run's fair price is far below it (deployer/src/protocols/liquity.ts GENESIS_PRICE_USD).", + ); + } + if (state.market && !state.marketQuoted) { + throw new Error( + "the eUSD/USDC pool did not quote at startup: it reverted or has no liquidity at probe size. " + + "Check that the deploy seeded it (deployer/src/protocols/liquity.ts seedEusdPool).", + ); + } + const absDiscount = Math.abs(state.discountBps); + if (absDiscount > LIQUITY_STARTUP_FAIL_BPS) { + throw new Error( + `liquity no-arbitrage check failed at startup: eUSD trades ${state.discountBps.toFixed(1)}bps ` + + `off par (limit ${LIQUITY_STARTUP_FAIL_BPS}bps). The pool is seeded at par, so this is a ` + + "dirty chain or a mis-deploy, and it would open as a risk-free redemption for whoever looks first.", + ); + } + if (absDiscount > LIQUITY_STARTUP_WARN_BPS) { + console.warn( + `[liquity] eUSD opens ${state.discountBps.toFixed(1)}bps off par (warn above ${LIQUITY_STARTUP_WARN_BPS}bps)`, + ); + } + + return { + troveManager: LIQUITY.troveManager, + priceFeedAdapter: LIQUITY.priceFeed, + oracleRepointed: true, + }; +} + +/// Per-block telemetry. Cheap (the coordinator already reads this state) and the primary post-run +/// source for whether the venue behaved: where the peg sat, how the fee curve moved, and whether the +/// system ever went into Recovery Mode. +export function liquityBlockEvent( + state: LiquityState, + blockNumber: number, +): Record { + return { + type: "liquity_block", + blockNumber, + priceUsd: state.priceUsd, + tcr: state.tcr, + recoveryMode: state.recoveryMode, + troveCount: state.troveCount, + totalDebtEusdWei: state.totalDebtEusdWei.toString(), + marketPriceUsdc: state.midPriceUsdc, + discountBps: state.discountBps, + redemptionRateBps: state.redemptionRateBps, + borrowingRateBps: state.borrowingRateBps, + stabilityPoolEusdWei: state.spTotalDepositsEusdWei.toString(), + riskiestIcr: state.troves[0]?.icr ?? null, + }; +} + +/// What the venue actually did in a range of blocks, from its own logs. +/// +/// The block telemetry above can only say that a Trove disappeared; it cannot say whether it was +/// closed, redeemed away or liquidated. Issue #39's open question -- whether Liquity's ordering +/// sensitivity needs special handling in an environment that rewrites the oracle every block ahead +/// of every agent -- is a question about liquidations specifically, so it has to be counted rather +/// than inferred. +/// +/// Scanned over a range because the coordinator drops block notifications while it is busy, the same +/// reason every other catch-up consumer here takes fromBlock..toBlock. +export async function watchLiquityEvents( + ctx: SimContext, + fromBlock: number, + toBlock: number, + logger: RunLogger, +): Promise { + if (!LIQUITY || fromBlock > toBlock) return; + const range = { + address: LIQUITY.troveManager, + fromBlock: BigInt(fromBlock), + toBlock: BigInt(toBlock), + } as const; + const [liquidated, redeemed] = await Promise.all([ + ctx.publicClient.getLogs({ + ...range, + event: troveManagerAbi.find( + (e) => e.type === "event" && e.name === "TroveLiquidated", + ) as never, + strict: false, + }), + ctx.publicClient.getLogs({ + ...range, + event: troveManagerAbi.find( + (e) => e.type === "event" && e.name === "Redemption", + ) as never, + strict: false, + }), + ]); + for (const raw of liquidated) { + const log = raw as unknown as { + args?: Record; + blockNumber?: bigint; + transactionHash?: string; + }; + const args = log.args ?? {}; + logger.event({ + type: "liquity_liquidation", + blockNumber: Number(log.blockNumber ?? 0n), + borrower: String(args._borrower ?? ""), + debtEusdWei: String(args._debt ?? ""), + collWei: String(args._coll ?? ""), + // Liquity's TroveManagerOperation: 0 = applyPendingRewards, 1 = liquidateInNormalMode, + // 2 = liquidateInRecoveryMode, 3 = redeemCollateral. Which mode it was is the finding. + operation: Number(args._operation ?? 0), + txHash: log.transactionHash, + }); + } + for (const raw of redeemed) { + const log = raw as unknown as { + args?: Record; + blockNumber?: bigint; + transactionHash?: string; + }; + const args = log.args ?? {}; + logger.event({ + type: "liquity_redemption", + blockNumber: Number(log.blockNumber ?? 0n), + attemptedEusdWei: String(args._attemptedLUSDAmount ?? ""), + actualEusdWei: String(args._actualLUSDAmount ?? ""), + ethSentWei: String(args._ETHSent ?? ""), + ethFeeWei: String(args._ETHFee ?? ""), + txHash: log.transactionHash, + }); + } +} + +// --------------------------------------------------------------------------- +// eUSD depeg (the stress overlay's eusdDepeg, issue #39) +// --------------------------------------------------------------------------- + +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; +}; + +/// Stage the account that will move the peg, and record what it has to work with. +/// +/// 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 +/// participant and is excluded from scoring, the same arrangement as the ADR 0009 stress victims. +export async function setupEusdDepeg( + ctx: SimContext, + opts: { localDeploy: boolean; actorPk: Hex }, + logger: RunLogger, +): Promise { + if (!opts.localDeploy) { + throw new Error( + "stress event eusdDepeg requires run.localDeploy: the liquity venue and its eUSD market exist " + + "only under local deploy (issue #39)", + ); + } + 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], + }), + }, + ); + } + + 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(), + }); +} diff --git a/deployer/.gitignore b/deployer/.gitignore index 2585aa6..f405553 100644 --- a/deployer/.gitignore +++ b/deployer/.gitignore @@ -19,6 +19,8 @@ vendor/aave/typechain-types/ # - gmx-src: localhost support is kept as vendor/gmx-localhost.patch # - curve-src: build-only (runtime uses the bytecode in vendor/curve) # - twocrypto-src: same (build lite-0.3.10 with Docker vyper 0.3.10 -> vendor/curve) +# - liquity-src: built in place with foundry (solc 0.6.11); the deployer reads its out/ (issue #39) vendor/gmx-src/ vendor/curve-src/ vendor/twocrypto-src/ +vendor/liquity-src/ diff --git a/deployer/contracts/LiquityPriceFeedAdapter.sol b/deployer/contracts/LiquityPriceFeedAdapter.sol new file mode 100644 index 0000000..0d9b699 --- /dev/null +++ b/deployer/contracts/LiquityPriceFeedAdapter.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IErisPriceFeed { + function latestAnswer() external view returns (int256); +} + +/// @title LiquityPriceFeedAdapter +/// @notice Liquity's `IPriceFeed`, served from the environment's own PriceFeed (ADR 0006 §3). +/// +/// Two constraints meet here. Liquity renounces ownership once its contracts are wired, so +/// the oracle address baked into TroveManager and BorrowerOperations is permanent. The +/// environment, meanwhile, deploys a fresh PriceFeed at the start of every run. This sits +/// between them: Liquity holds this address forever, and each run points it at whatever +/// PriceFeed it just deployed. +/// +/// Shipping Liquity's own `PriceFeedTestnet` instead was not an option -- its `setPrice` is +/// unpermissioned, so any agent could set the oracle it is being liquidated against. +/// +/// Repointing is gated on the simulation's admin key rather than the deployer's, the same +/// way the LST vault takes an operator (issue #38), so a run can rewire the venue without +/// holding the key that deployed it. +contract LiquityPriceFeedAdapter { + /// The simulation's admin account (`keccak256("eris-role:admin")`), fixed at deploy time. + address public immutable operator; + + /// The run's PriceFeed. Zero until a run points it somewhere. + address public source; + + /// Last price actually served, in Liquity's 1e18 scale. + uint256 public lastGoodPrice; + + event LastGoodPriceUpdated(uint256 _lastGoodPrice); + event SourceUpdated(address indexed source); + + constructor(address _operator, uint256 _initialPrice) { + require(_operator != address(0), "LiquityPriceFeed: no operator"); + require(_initialPrice > 0, "LiquityPriceFeed: no initial price"); + operator = _operator; + lastGoodPrice = _initialPrice; + } + + function setSource(address _source) external { + require(msg.sender == operator, "LiquityPriceFeed: not operator"); + source = _source; + emit SourceUpdated(_source); + } + + /// @notice Liquity calls this on every state-changing path -- opening, adjusting, liquidating, + /// redeeming. It must not revert: a revert here would freeze the whole venue, including + /// the liquidations that a falling price is supposed to trigger. An unset or unreadable + /// source therefore keeps serving the last price it did serve, which is also how + /// Liquity's own PriceFeed behaves when Chainlink breaks. + function fetchPrice() external returns (uint256) { + uint256 p = _read(); + if (p != 0) { + lastGoodPrice = p; + emit LastGoodPriceUpdated(p); + } + return lastGoodPrice; + } + + function _read() internal view returns (uint256) { + if (source == address(0)) return 0; + try IErisPriceFeed(source).latestAnswer() returns (int256 answer) { + if (answer <= 0) return 0; + // The environment's feed is 8-decimal fixed point (USDC per WETH); Liquity works in 1e18. + return uint256(answer) * 1e10; + } catch { + return 0; + } + } +} diff --git a/deployer/contracts/LiquityRedemptionHelper.sol b/deployer/contracts/LiquityRedemptionHelper.sol new file mode 100644 index 0000000..67c7715 --- /dev/null +++ b/deployer/contracts/LiquityRedemptionHelper.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); +} + +interface IPriceFeed { + function fetchPrice() external returns (uint256); +} + +interface IHintHelpers { + function getRedemptionHints(uint256 amount, uint256 price, uint256 maxIterations) + external + view + returns (address firstRedemptionHint, uint256 partialRedemptionHintNICR, uint256 truncatedAmount); +} + +interface ISortedTroves { + function findInsertPosition(uint256 nicr, address prevId, address nextId) + external + view + returns (address, address); +} + +interface ITroveManager { + function redeemCollateral( + uint256 amount, + address firstRedemptionHint, + address upperPartialRedemptionHint, + address lowerPartialRedemptionHint, + uint256 partialRedemptionHintNICR, + uint256 maxIterations, + uint256 maxFeePercentage + ) external; +} + +/// @title LiquityRedemptionHelper +/// @notice Computes a redemption's hints in the same transaction that uses them (issue #39). +/// +/// Liquity's partial redemption is checked against a hint the caller supplies: +/// `_redeemCollateralFromTrove` recomputes the last Trove's nominal ICR from the price the +/// redemption fetched and cancels the partial unless it matches `partialRedemptionHintNICR` +/// exactly. The hint therefore depends on the oracle price at *execution*, and this +/// environment writes a new price every block (ADR 0006 §3) — always ahead of an agent's +/// transaction, since the oracle write bids the top of the block. Hints computed off-chain +/// are stale by construction, and a redemption built from them reverts with +/// "TroveManager: Unable to redeem any amount". Measured, not assumed: every redemption in +/// the first live run of the venue failed that way. +/// +/// So the hints are computed here instead, after `fetchPrice()` has already cached the +/// price this transaction will redeem at. Nothing about Liquity changes — this is periphery +/// in exactly the sense HintHelpers is, and it holds no funds between transactions. +contract LiquityRedemptionHelper { + ITroveManager public immutable troveManager; + IHintHelpers public immutable hintHelpers; + ISortedTroves public immutable sortedTroves; + IPriceFeed public immutable priceFeed; + IERC20 public immutable eusd; + + /// The whole redemption call, resolved at execution. A struct rather than five locals because + /// Liquity's redemption takes seven arguments and solc 0.8.20 runs out of stack otherwise. + struct Plan { + address firstHint; + address upperHint; + address lowerHint; + uint256 partialNICR; + uint256 truncated; + } + + event Redeemed(address indexed redeemer, uint256 eusdIn, uint256 eusdRedeemed, uint256 ethOut); + + constructor( + address _troveManager, + address _hintHelpers, + address _sortedTroves, + address _priceFeed, + address _eusd + ) { + troveManager = ITroveManager(_troveManager); + hintHelpers = IHintHelpers(_hintHelpers); + sortedTroves = ISortedTroves(_sortedTroves); + priceFeed = IPriceFeed(_priceFeed); + eusd = IERC20(_eusd); + } + + /// @notice Redeem eUSD for collateral, hinted at the price this transaction will use. + /// @param amount eUSD to redeem. The sorted list may absorb less (a Trove that would be left + /// under MIN_NET_DEBT is skipped), in which case the remainder is returned unspent. + /// @param maxFeePercentage slippage bound on the redemption fee, in 1e18 scale. + /// @param maxIterations cap on how many Troves the redemption walks (0 = no cap). + function redeem(uint256 amount, uint256 maxFeePercentage, uint256 maxIterations) + external + returns (uint256 redeemed, uint256 ethOut) + { + require(amount > 0, "RedemptionHelper: zero amount"); + require(eusd.transferFrom(msg.sender, address(this), amount), "RedemptionHelper: transfer in failed"); + + Plan memory plan = _plan(amount, maxIterations); + ethOut = address(this).balance; + troveManager.redeemCollateral( + plan.truncated, + plan.firstHint, + plan.upperHint, + plan.lowerHint, + plan.partialNICR, + maxIterations, + maxFeePercentage + ); + ethOut = address(this).balance - ethOut; + redeemed = plan.truncated; + + // Whatever the list could not absorb goes straight back: an agent that asked to redeem more + // than the system could take should be short the difference in eUSD, not in trust. + uint256 leftover = eusd.balanceOf(address(this)); + if (leftover > 0) { + require(eusd.transfer(msg.sender, leftover), "RedemptionHelper: refund failed"); + } + if (ethOut > 0) { + (bool ok,) = msg.sender.call{value: ethOut}(""); + require(ok, "RedemptionHelper: eth payout failed"); + } + emit Redeemed(msg.sender, amount, redeemed, ethOut); + } + + /// Resolve the redemption against the price this transaction will use. + /// + /// `fetchPrice` is what caches it: Liquity's own `redeemCollateral` calls the same function, and + /// within one transaction it returns the same number, so the hint below and the redemption that + /// checks it cannot disagree. + function _plan(uint256 amount, uint256 maxIterations) internal returns (Plan memory plan) { + uint256 price = priceFeed.fetchPrice(); + (plan.firstHint, plan.partialNICR, plan.truncated) = + hintHelpers.getRedemptionHints(amount, price, maxIterations); + require(plan.truncated > 0, "RedemptionHelper: nothing redeemable"); + (plan.upperHint, plan.lowerHint) = + sortedTroves.findInsertPosition(plan.partialNICR, address(0), address(0)); + } + + receive() external payable {} +} diff --git a/deployer/package.json b/deployer/package.json index 3a7090f..5f74a61 100644 --- a/deployer/package.json +++ b/deployer/package.json @@ -2,6 +2,7 @@ "name": "eris-app-deployer", "version": "0.1.0", "private": true, + "license": "MIT", "type": "module", "description": "Orchestrator that deploys Uniswap V3 / Balancer V2 / Aave V3 / Curve / GMX V2 from scratch onto an empty anvil chain", "scripts": { diff --git a/deployer/scripts/setup-vendors.sh b/deployer/scripts/setup-vendors.sh index 43212dd..599e55b 100755 --- a/deployer/scripts/setup-vendors.sh +++ b/deployer/scripts/setup-vendors.sh @@ -43,6 +43,60 @@ echo " yarn install (this takes a while)" echo "==> Setting up Aave (hardhat subproject)" (cd vendor/aave && npm install) +# --- Liquity V1 (issue #39) --------------------------------------------------- +# Source rather than prebuilt bytecode, unlike Curve: foundry compiles the whole system with +# solc 0.6.11 in a few seconds and Liquity has no external dependencies (its SafeMath/Ownable are +# vendored copies under Dependencies/), so there is nothing to gain by committing artifacts -- and +# not committing them keeps someone else's code out of this repository. +# +# The repository is GPL-3.0 but every contract source carries `SPDX-License-Identifier: MIT`, +# which is the file this build consumes. +LIQUITY_REPO="https://github.com/liquity/dev.git" +LIQUITY_SHA="3e64ee1b52c50d51587c64c1cf75e0ba82934979" # pinned so the ABI cannot shift under a redeploy +LIQUITY_DIR="vendor/liquity-src" + +echo "==> Setting up Liquity V1" +if [ ! -d "$LIQUITY_DIR/.git" ]; then + echo " clone $LIQUITY_REPO (sparse: packages/contracts)" + git clone --filter=blob:none --sparse "$LIQUITY_REPO" "$LIQUITY_DIR" + git -C "$LIQUITY_DIR" sparse-checkout set packages/contracts +fi +if ! git -C "$LIQUITY_DIR" checkout -q "$LIQUITY_SHA" 2>/dev/null; then + echo " pinned commit not present, fetching" + git -C "$LIQUITY_DIR" fetch origin + git -C "$LIQUITY_DIR" checkout -q "$LIQUITY_SHA" +fi + +# LUSD -> eUSD. The token's name and symbol are the only source-level change the simulation makes; +# everything the mechanism depends on (Recovery Mode, redistribution, the fee curves, the sorted +# list) is untouched. sed rather than a patch file so a re-clone cannot leave it half-applied. +LUSD_SRC="$LIQUITY_DIR/packages/contracts/contracts/LUSDToken.sol" +if grep -q '"LUSD Stablecoin"' "$LUSD_SRC"; then + sed -i.bak 's/"LUSD Stablecoin"/"eUSD Stablecoin"/; s/_SYMBOL = "LUSD"/_SYMBOL = "eUSD"/' "$LUSD_SRC" + rm -f "$LUSD_SRC.bak" + echo " renamed the stablecoin to eUSD" +fi + +# Its own foundry project: solc 0.6.11 against the deployer's 0.8.20, and the test/proxy/LP trees +# pull in solc ^0.4.23 and @openzeppelin, neither of which this deployment needs. +cat > "$LIQUITY_DIR/packages/contracts/foundry.toml" <<'TOML' +[profile.default] +src = "contracts" +out = "out" +libs = [] +solc = "0.6.11" +optimizer = true +optimizer_runs = 100 +skip = [ + "contracts/TestContracts/**", + "contracts/LPRewards/**", + "contracts/Proxy/**", + "contracts/Integrations/**", +] +TOML +echo " forge build (solc 0.6.11)" +(cd "$LIQUITY_DIR/packages/contracts" && forge build) + echo "==> Done. Curve (stableswap-ng / twocrypto-ng) ships bytecode in vendor/curve, so no extra work is needed." echo " Rebuild steps (Docker vyper 0.3.10):" echo " stableswap-ng: curvefi/stableswap-ng -> vendor/curve/CurveStableSwapNG*.json" diff --git a/deployer/src/index.ts b/deployer/src/index.ts index c1aba15..0f68a1a 100644 --- a/deployer/src/index.ts +++ b/deployer/src/index.ts @@ -12,17 +12,29 @@ import { deployAaveV3 } from "./protocols/aave-v3.js"; import { deployCurve } from "./protocols/curve.js"; import { deployGmxV2 } from "./protocols/gmx-v2.js"; import { deployLst } from "./protocols/lst.js"; +import { deployLiquityVenue } from "./protocols/liquity.js"; -type ProtocolName = "uniswap" | "balancer" | "aave" | "gmx" | "curve" | "lst"; +type ProtocolName = + | "uniswap" + | "balancer" + | "aave" + | "gmx" + | "curve" + | "lst" + | "liquity"; // gmx takes several minutes via hardhat-deploy, so put it last in ALL. // lst reuses the stableswap-ng factory for its secondary market, so it comes after curve. +// liquity does too (its eUSD/USDC market), and it warps the chain 14 days forward to clear the +// bootstrap period -- harmless for everything already deployed, but time only moves forward, so it +// goes after everything that cares (issue #39). const ALL: ProtocolName[] = [ "uniswap", "balancer", "aave", "curve", "lst", + "liquity", "gmx", ]; @@ -35,6 +47,7 @@ const DEPLOYERS: Record< aave: deployAaveV3, curve: deployCurve, lst: deployLst, + liquity: deployLiquityVenue, gmx: deployGmxV2, }; diff --git a/deployer/src/protocols/liquity.ts b/deployer/src/protocols/liquity.ts new file mode 100644 index 0000000..132d951 --- /dev/null +++ b/deployer/src/protocols/liquity.ts @@ -0,0 +1,559 @@ +// Liquity V1 as the CDP stablecoin venue (issue #39), issuing eUSD. +// +// The core is forked essentially unmodified -- TroveManager / BorrowerOperations / StabilityPool / +// SortedTroves and the pools keep Recovery Mode, debt and collateral redistribution, the sorted +// list, and the dynamic borrowing and redemption fees. The interaction between those *is* the game +// the venue adds; simplifying any of it produces something materially different. The only +// source-level change is the token's name and symbol (see scripts/setup-vendors.sh). +// +// Two things are ours rather than Liquity's: +// - the oracle, because Liquity's own testnet feed has an unpermissioned setter, which would let +// any agent set the price it is being liquidated against (see LiquityPriceFeedAdapter.sol) +// - the genesis Trove, because LUSDToken has no admin mint: every eUSD in existence has to come +// out of somebody's Trove, including the supply that seeds the market and the Stability Pool +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + keccak256, + parseEther, + parseUnits, + toBytes, + type Abi, + type Address, + type Hex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { accounts, deployerWallet, publicClient } from "../clients.js"; +import { anvilChain } from "../config.js"; +import { approve } from "../erc20.js"; +import { getRegistry, setProtocol, token } from "../registry.js"; +import { ROOT, assert, info, loadForgeArtifact, ok, waitTx } from "../util.js"; + +const dep = accounts.deployer; +const ZERO = "0x0000000000000000000000000000000000000000" as Address; + +// --------------------------------------------------------------------------- +// Calibration (issue #39) +// --------------------------------------------------------------------------- + +/// Price the genesis Trove is opened against, matching what the other venues are seeded at. +/// The run's own oracle takes over from the first block; this only has to be right enough that the +/// starting Trove is not already near liquidation. +const GENESIS_PRICE_USD = 3000n; + +/// The genesis Trove. Deliberately over-collateralized: it is not a participant, and it exists to +/// mint the eUSD that seeds the market. At 300% it is also the *last* Trove redemptions would +/// reach, so agent-opened Troves are what redemption arb actually competes over. +/// +/// It mints more than the market and the Stability Pool need, and the surplus is the point: eUSD +/// only exists if it came out of a Trove, so the environment's own inventory -- what the `eusdDepeg` +/// stress event sells to push the peg off par (issue #39 phase 5) -- has to be minted here too. +const GENESIS_COLL_ETH = 250n; +const GENESIS_DEBT_EUSD = 250_000n; // 250 * 3000 / 250000 = 300% ICR + +/// How the minted supply is placed. What is left stays with the deployer, which is where the +/// coordinator can draw from if a run needs to top anything up. +const POOL_EUSD = 100_000n; // eUSD/USDC curve pool, matched with USDC +const STABILITY_POOL_EUSD = 50_000n; // pre-seeded underwriting depth + +/// Redemptions revert during Liquity's 14-day BOOTSTRAP_PERIOD, measured from a constructor-set +/// deployment timestamp. Warping past it costs nothing and keeps the fork diff at zero -- the +/// alternative would be patching a constant in TroveManager. +const BOOTSTRAP_SECONDS = 14n * 24n * 60n * 60n + 3600n; + +/// Borrowing fee ceiling for the genesis open. baseRate starts at zero so the actual fee is the +/// 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. +// +// 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 +// 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 +// shape -- eUSD is sticky near par and gives way when it is really pushed -- at a scale agents +// holding 25k of capital can actually interact with. +const A = 100n; +const FEE = 1_000_000n; // 0.01% +const OFFPEG = 20_000_000_000n; +const MA_EXP_TIME = 866n; + +const LIQUITY_OUT = resolve(ROOT, "vendor/liquity-src/packages/contracts/out"); + +function liquityArtifact(name: string): { abi: Abi; bytecode: Hex } { + const j = JSON.parse( + readFileSync(resolve(LIQUITY_OUT, `${name}.sol/${name}.json`), "utf8"), + ); + return { + abi: j.abi as Abi, + bytecode: (j.bytecode?.object ?? j.bytecode) as Hex, + }; +} + +async function deployLiquity( + label: string, + name: string, + args: unknown[] = [], +): Promise
{ + const a = liquityArtifact(name); + const hash = await deployerWallet.deployContract({ + abi: a.abi, + bytecode: a.bytecode, + args, + account: dep, + chain: anvilChain, + }); + const rc = await waitTx(hash); + const address = rc.contractAddress as Address; + assert(Boolean(address), `${label} was not deployed`); + ok(label, address); + return address; +} + +async function call( + address: Address, + abiName: string, + functionName: string, + args: unknown[], + value?: bigint, +): Promise { + const hash = await deployerWallet.writeContract({ + address, + abi: liquityArtifact(abiName).abi, + functionName, + args, + account: dep, + chain: anvilChain, + ...(value === undefined ? {} : { value }), + }); + await waitTx(hash); +} + +/// The simulation's admin account, derived the same way sdk/src/config.ts derives it. It owns the +/// oracle adapter so a run can repoint it at the PriceFeed it just deployed, without holding the +/// deployer key (same arrangement as the LST vault, issue #38). +function envOperatorAddress(): Address { + return privateKeyToAccount(keccak256(toBytes("eris-role:admin"))).address; +} + +export async function deployLiquityVenue({ seed }: { seed: boolean }) { + info("Deploying Liquity V1 (CDP stablecoin venue, eUSD)"); + + // --- core, then the LQTY side it insists on being wired to --------------- + // Everything here takes no constructor arguments; the wiring happens through setAddresses below, + // after which each contract renounces ownership. + const sortedTroves = await deployLiquity("SortedTroves", "SortedTroves"); + const troveManager = await deployLiquity("TroveManager", "TroveManager"); + const activePool = await deployLiquity("ActivePool", "ActivePool"); + const stabilityPool = await deployLiquity("StabilityPool", "StabilityPool"); + const gasPool = await deployLiquity("GasPool", "GasPool"); + const defaultPool = await deployLiquity("DefaultPool", "DefaultPool"); + const collSurplusPool = await deployLiquity( + "CollSurplusPool", + "CollSurplusPool", + ); + const borrowerOperations = await deployLiquity( + "BorrowerOperations", + "BorrowerOperations", + ); + const hintHelpers = await deployLiquity("HintHelpers", "HintHelpers"); + + // LQTY exists only because core address-wiring requires it. It gets no market and no valuation: + // CommunityIssuance pays out on a one-year half-life, so emission over a few hundred blocks is + // indistinguishable from zero. + const communityIssuance = await deployLiquity( + "CommunityIssuance", + "CommunityIssuance", + ); + const lqtyStaking = await deployLiquity("LQTYStaking", "LQTYStaking"); + const lockupContractFactory = await deployLiquity( + "LockupContractFactory", + "LockupContractFactory", + ); + const lqtyToken = await deployLiquity("LQTYToken", "LQTYToken", [ + communityIssuance, + lqtyStaking, + lockupContractFactory, + dep.address, // bounty + dep.address, // lp rewards + dep.address, // multisig + ]); + + const eusd = await deployLiquity("eUSD (LUSDToken)", "LUSDToken", [ + troveManager, + stabilityPool, + borrowerOperations, + ]); + + // The oracle Liquity will hold forever. It starts on its constructor price and is repointed at + // each run's PriceFeed by the coordinator. + const priceFeedArtifact = loadForgeArtifact( + "LiquityPriceFeedAdapter", + "LiquityPriceFeedAdapter", + ); + const priceFeedHash = await deployerWallet.deployContract({ + abi: priceFeedArtifact.abi, + bytecode: priceFeedArtifact.bytecode, + args: [envOperatorAddress(), parseEther(GENESIS_PRICE_USD.toString())], + account: dep, + chain: anvilChain, + }); + const priceFeed = (await waitTx(priceFeedHash)).contractAddress as Address; + ok("LiquityPriceFeedAdapter", priceFeed); + + // --- wiring (order and arguments follow Liquity's own deploymentHelpers) --- + info("Liquity: wiring contracts"); + await call(sortedTroves, "SortedTroves", "setParams", [ + // Unbounded list: Liquity mainnet passes maxBytes32 for the same reason. A cap would make the + // last agent to open a Trove fail for a reason that has nothing to do with their strategy. + 2n ** 256n - 1n, + troveManager, + borrowerOperations, + ]); + await call(troveManager, "TroveManager", "setAddresses", [ + borrowerOperations, + activePool, + defaultPool, + stabilityPool, + gasPool, + collSurplusPool, + priceFeed, + eusd, + sortedTroves, + lqtyToken, + lqtyStaking, + ]); + await call(borrowerOperations, "BorrowerOperations", "setAddresses", [ + troveManager, + activePool, + defaultPool, + stabilityPool, + gasPool, + collSurplusPool, + priceFeed, + sortedTroves, + eusd, + lqtyStaking, + ]); + await call(stabilityPool, "StabilityPool", "setAddresses", [ + borrowerOperations, + troveManager, + activePool, + eusd, + sortedTroves, + priceFeed, + communityIssuance, + ]); + await call(activePool, "ActivePool", "setAddresses", [ + borrowerOperations, + troveManager, + stabilityPool, + defaultPool, + ]); + await call(defaultPool, "DefaultPool", "setAddresses", [ + troveManager, + activePool, + ]); + await call(collSurplusPool, "CollSurplusPool", "setAddresses", [ + borrowerOperations, + troveManager, + activePool, + ]); + await call(hintHelpers, "HintHelpers", "setAddresses", [ + sortedTroves, + troveManager, + ]); + await call( + lockupContractFactory, + "LockupContractFactory", + "setLQTYTokenAddress", + [lqtyToken], + ); + await call(lqtyStaking, "LQTYStaking", "setAddresses", [ + lqtyToken, + eusd, + troveManager, + borrowerOperations, + activePool, + ]); + await call(communityIssuance, "CommunityIssuance", "setAddresses", [ + lqtyToken, + stabilityPool, + ]); + ok("wiring", "core + LQTY connected"); + + // Periphery: computes a redemption's hints inside the transaction that uses them. Liquity checks + // a partial redemption against a hint derived from the *execution* price, and this environment + // moves the oracle every block, so hints computed off-chain are stale by construction and every + // redemption reverts (measured on the venue's first live run). See the contract's own notes. + const redemptionHelperArtifact = loadForgeArtifact( + "LiquityRedemptionHelper", + "LiquityRedemptionHelper", + ); + const redemptionHelperHash = await deployerWallet.deployContract({ + abi: redemptionHelperArtifact.abi, + bytecode: redemptionHelperArtifact.bytecode, + args: [troveManager, hintHelpers, sortedTroves, priceFeed, eusd], + account: dep, + chain: anvilChain, + }); + const redemptionHelper = (await waitTx(redemptionHelperHash)) + .contractAddress as Address; + ok("LiquityRedemptionHelper", redemptionHelper); + + setProtocol("liquity", { + troveManager, + borrowerOperations, + stabilityPool, + sortedTroves, + activePool, + defaultPool, + collSurplusPool, + gasPool, + hintHelpers, + priceFeed, + redemptionHelper, + eusd, + lqtyToken, + lqtyStaking, + communityIssuance, + }); + + if (!seed) return; + await seedLiquity({ + borrowerOperations, + stabilityPool, + troveManager, + hintHelpers, + sortedTroves, + eusd, + }); +} + +/// Mint the venue's initial eUSD from a genesis Trove and place it: half into an eUSD/USDC market so +/// the peg has somewhere to trade, and a slice into the Stability Pool so the first liquidation has +/// something to absorb it. Both are the environment's, not a participant's -- the deployer account +/// is excluded from scoring the same way the ADR 0009 stress victims are. +async function seedLiquity(addrs: { + borrowerOperations: Address; + stabilityPool: Address; + troveManager: Address; + hintHelpers: Address; + sortedTroves: Address; + eusd: Address; +}) { + info("Liquity: opening the genesis Trove"); + const coll = parseEther(GENESIS_COLL_ETH.toString()); + const debt = parseEther(GENESIS_DEBT_EUSD.toString()); + await call( + addrs.borrowerOperations, + "BorrowerOperations", + "openTrove", + // No hints: an empty sorted list has nowhere to insert but the head. + [MAX_BORROW_FEE, debt, ZERO, ZERO], + coll, + ); + const icr = (GENESIS_COLL_ETH * GENESIS_PRICE_USD * 100n) / GENESIS_DEBT_EUSD; + ok( + "genesis Trove", + `${GENESIS_COLL_ETH} ETH / ${GENESIS_DEBT_EUSD} eUSD (ICR ${icr}%)`, + ); + + const minted = (await publicClient.readContract({ + address: addrs.eusd, + abi: liquityArtifact("LUSDToken").abi, + functionName: "balanceOf", + args: [dep.address], + })) as bigint; + assert( + minted >= parseEther((POOL_EUSD + STABILITY_POOL_EUSD).toString()), + `genesis Trove minted ${minted} eUSD, too little to seed the venue`, + ); + + await seedEusdPool(); + + info("Liquity: seeding the Stability Pool"); + await call(addrs.stabilityPool, "StabilityPool", "provideToSP", [ + parseEther(STABILITY_POOL_EUSD.toString()), + ZERO, // no front-end tag + ]); + ok("stability pool", `${STABILITY_POOL_EUSD} eUSD`); + + // Liquity refuses redemptions for 14 days after deployment. Warping past it here means the state + // dump (ADR 0016) is baked with the period already served, so a run never has to think about it. + await publicClient.request({ + method: "evm_increaseTime" as never, + params: [Number(BOOTSTRAP_SECONDS)] as never, + }); + await publicClient.request({ + method: "evm_mine" as never, + params: [] as never, + }); + ok("bootstrap period", "warped past"); + + await verifyLiquity(addrs); +} + +/// Prove the venue works before anything is baked into a state dump. Each check stands for a +/// specific way this deployment can come out looking fine and be useless: +/// - the rename is a `sed` in setup-vendors.sh, which silently does nothing against a source tree +/// that has already been renamed or has moved on +/// - a Trove that starts near liquidation, or a system already in Recovery Mode, would make every +/// run about the genesis position rather than about the agents +/// - redemptions are the whole point of the venue (issue #39's redemption arb), and they revert +/// for 14 days after deployment. If the warp above ever stops working, everything else here +/// still passes and the venue quietly loses its reason to exist. +async function verifyLiquity(addrs: { + troveManager: Address; + hintHelpers: Address; + sortedTroves: Address; + eusd: Address; +}) { + const price = parseEther(GENESIS_PRICE_USD.toString()); + const symbol = (await publicClient.readContract({ + address: addrs.eusd, + abi: liquityArtifact("LUSDToken").abi, + functionName: "symbol", + })) as string; + assert( + symbol === "eUSD", + `the stablecoin is "${symbol}", not eUSD — the rename in setup-vendors.sh did not apply`, + ); + + const troveManagerAbi = liquityArtifact("TroveManager").abi; + const troves = (await publicClient.readContract({ + address: addrs.troveManager, + abi: troveManagerAbi, + functionName: "getTroveOwnersCount", + })) as bigint; + assert(troves === 1n, `expected the genesis Trove alone, found ${troves}`); + + const tcr = (await publicClient.readContract({ + address: addrs.troveManager, + abi: troveManagerAbi, + functionName: "getTCR", + args: [price], + })) as bigint; + // CCR is 150%: at or below it the system opens in Recovery Mode, where borrowing is restricted + // and every Trove under 150% is liquidatable. + assert( + tcr > parseEther("1.5"), + `the system opens in Recovery Mode (TCR ${tcr})`, + ); + + // A simulated redemption, not a real one: the dump has to be taken on a venue nobody has traded. + const probe = parseEther("1000"); + const [firstHint, partialNICR] = (await publicClient.readContract({ + address: addrs.hintHelpers, + abi: liquityArtifact("HintHelpers").abi, + functionName: "getRedemptionHints", + args: [probe, price, 0n], + })) as [Address, bigint, bigint]; + const [upperHint, lowerHint] = (await publicClient.readContract({ + address: addrs.sortedTroves, + abi: liquityArtifact("SortedTroves").abi, + functionName: "findInsertPosition", + args: [partialNICR, ZERO, ZERO], + })) as [Address, Address]; + await publicClient.simulateContract({ + account: dep, + address: addrs.troveManager, + abi: troveManagerAbi, + functionName: "redeemCollateral", + args: [ + probe, + firstHint, + upperHint, + lowerHint, + partialNICR, + 0n, + MAX_BORROW_FEE, + ], + }); + ok( + "redemptions", + `open (TCR ${(tcr * 100n) / 10n ** 18n}%, ${troves} trove)`, + ); +} + +/// eUSD/USDC on the stableswap factory that already hosts USDC/DAI. Real LUSD's dominant venue was +/// Curve, and its peg-then-cliff shape is what makes a depeg legible as a redemption opportunity +/// rather than as noise. +async function seedEusdPool() { + const registry = getRegistry(); + const curve = registry.protocols.curve as { factory?: Address } | undefined; + if (!curve?.factory) { + info("Liquity: no curve factory in the registry, skipping the eUSD market"); + return; + } + info("Liquity: creating the eUSD/USDC pool"); + const factoryAbi = curveArtifact("CurveStableSwapFactoryNG").abi; + const eusd = (getRegistry().protocols.liquity as { eusd: Address }).eusd; + const usdc = token("USDC"); + const coins = [eusd, usdc] as Address[]; + + const deployHash = await deployerWallet.writeContract({ + address: curve.factory, + abi: factoryAbi, + functionName: "deploy_plain_pool", + args: [ + "Eris eUSD/USDC", + "eUSDUSDC", + coins, + A, + FEE, + OFFPEG, + MA_EXP_TIME, + 0n, + [0, 0], + ["0x00000000", "0x00000000"], + [ZERO, ZERO], + ], + account: dep, + chain: anvilChain, + }); + await waitTx(deployHash); + + const count = (await publicClient.readContract({ + address: curve.factory, + abi: factoryAbi, + functionName: "pool_count", + })) as bigint; + const pool = (await publicClient.readContract({ + address: curve.factory, + abi: factoryAbi, + functionName: "pool_list", + args: [count - 1n], + })) as Address; + assert(pool !== ZERO, "the eUSD/USDC pool was not created"); + ok("eUSD/USDC pool", pool); + + const eusdAmt = parseEther(POOL_EUSD.toString()); + const usdcAmt = parseUnits(POOL_EUSD.toString(), 6); + await approve(eusd, pool, eusdAmt); + await approve(usdc, pool, usdcAmt); + const addHash = await deployerWallet.writeContract({ + address: pool, + abi: curveArtifact("CurveStableSwapNG").abi, + functionName: "add_liquidity", + args: [[eusdAmt, usdcAmt], 0n, dep.address], + account: dep, + chain: anvilChain, + }); + await waitTx(addHash); + ok("add_liquidity", `${POOL_EUSD} eUSD / ${POOL_EUSD} USDC (at par)`); + + setProtocol("liquity", { eusdUsdcPool: pool, eusdIndex: 0, usdcIndex: 1 }); +} + +function curveArtifact(name: string): { abi: Abi; bytecode: Hex } { + const j = JSON.parse( + readFileSync(resolve(ROOT, `vendor/curve/${name}.json`), "utf8"), + ); + return { + abi: j.abi as Abi, + bytecode: (j.bytecode ?? j.blueprintBytecode) as Hex, + }; +} diff --git a/docs/adr/0018-llm-rewrites-the-strategy.md b/docs/adr/0018-llm-rewrites-the-strategy.md index 28501b6..2ec0f0c 100644 --- a/docs/adr/0018-llm-rewrites-the-strategy.md +++ b/docs/adr/0018-llm-rewrites-the-strategy.md @@ -4,6 +4,25 @@ Accepted(2026-08-10 実装。Phase 1-2。branch feat/lst-venue) +**Amendment 1(2026-08-12): 自己改善型のファイル名を `improve.md` から `prompt.md` へ戻す。** +本文 §1 は「意味が入れ替わるので名前を分ける」と決めたが、agent ディレクトリに置く「LLM への指示」は +1 種類しか存在しなくなったので、参加者から見て名前を 2 つ覚える理由が無い。決定を覆すのは名前だけで、 +**意味は §1 のまま**(prompt.md = 「いつ・何を根拠に・どう直すか」であって「この observation でどう動くか」 +ではない)。 + +名前を再利用する以上、旧形式との衝突は設計で塞ぐ: + +- frontmatter に **`kind: improve` を必須**とする。旧 prompt.md も `name` / `description` を持つので、 + キーの有無では区別できない(実測: `git show f42fd2a^:example/agents/arb-bot/prompt.md`) +- マーカーの無い `prompt.md` は**起動時に fail-fast**。黙って読むと、旧形式の「gap が 5bps を超えたら + swap しろ」といった取引指示が、改訂方針として system prompt の `## The operator's instructions` に + 入る。旧形式 19 個は f42fd2a で削除済みだが、git 履歴と Amendment 以前に取られた bundle には残る +- `improve.md` だけがあるディレクトリも fail-fast。無視すると戦略は取引を続け、LLM が一度も動かなかった + ことが run のどこにも出ない(黙った縮退は本 repo が #44 以降一貫して禁じている失敗の形) + +実装は `example/agents/runtime/improve.ts`(`IMPROVE_KIND` / `improvePolicyState`)と +`bot.ts`。検証は `test/improve.test.ts`。 + ### 最初の実走(存在証明であって、有効性の証明ではない) `calm#101` / R=150 / claude-cli で、同一戦略の自己改善版と frozen 版を同居させた: @@ -154,6 +173,10 @@ rollback する。** 書く判断プロンプトで、improve.md は「どういうときに、何を根拠に、戦略をどう直すか」を書く メタプロンプトである。意味が入れ替わるので、同梱 19 agent の prompt.md は転用せず整理する。 +> **Amendment 1 でファイル名は `prompt.md` に戻した**(Status 参照)。上の「意味が入れ替わる」という +> 判断はそのまま有効で、変わったのは名前だけ。旧形式との衝突は frontmatter の `kind: improve` と +> 起動時 fail-fast で塞ぐ。以降この節の `improve.md` は `prompt.md` と読み替える。 + ### 2. 書き換えの対象と実行 LLM は次を返す(削除済み `src/llm` の `Strategy` を復元・簡素化したもの): diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index e1852a7..01c1b84 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -61,7 +61,7 @@ Drop exactly one of the following into `example/agents//` and add the id to |---|---|---| | `agent.ts` (exports `decide(obs, ctx)`) | rule strategy | bot.ts drives a read→decide→send loop (interval can be set via `export const config = { intervalMs }`) | | `agent.ts` (exports `run(ctx)`) | self-driven | bot.ts does not loop; it delegates by passing ctx (clients / latestObservation / onObservation / submit / log) (e.g. liquidator) | -| `agent.ts` + `improve.md` (frontmatter: name/description required) | self-improving | decide() drives every block as usual, and an LLM periodically rewrites the strategy out of the trade path ([Self-improving agents](llm-agents.md)) | +| `agent.ts` + `prompt.md` (frontmatter: kind: improve / name / description required) | self-improving | decide() drives every block as usual, and an LLM periodically rewrites the strategy out of the trade path ([Self-improving agents](llm-agents.md)) | runtime/send.ts appends mempool activity (`kind:"mempool"`: submitted / submit_failed / rejected) to `runs//agents/.jsonl` as a self-report (closing the gap where the coordinator can no longer count submissions). diff --git a/docs/guide/llm-agents.md b/docs/guide/llm-agents.md index 290f90b..e9568d6 100644 --- a/docs/guide/llm-agents.md +++ b/docs/guide/llm-agents.md @@ -1,14 +1,15 @@ [← README](../../README.md) -# Self-improving agents (agent.ts + improve.md) +# Self-improving agents (agent.ts + prompt.md) -An LLM in this simulator **rewrites the strategy; it does not make the trades**. Put an `improve.md` +An LLM in this simulator **rewrites the strategy; it does not make the trades**. Put a `prompt.md` beside your `agent.ts` and the agent becomes self-improving: `decide()` runs every block exactly as fast as any rule agent, and periodically the model is handed the current strategy source plus how it has been doing, and may return a replacement. ```markdown --- +kind: improve # required — says which contract this file is written against name: my-arb # required description: cross-venue arb that widens its margin under adverse selection # required reviseEveryBlocks: 60 # blocks between revision opportunities (optional; default 60) @@ -22,8 +23,16 @@ model: gpt-oss:120b # optional ("claude..." = Anthropic API, "code > **Prompt mode was removed (ADR 0018).** Until recently an agent could be a `prompt.md` that the LLM > consulted for *every action*. Measured at production settings, that managed one decision every > 8-28 blocks and **1/64 the actions** of the same strategy in rule mode — it could not compete. -> `improve.md` is not a renamed `prompt.md`: the old file answered "given this observation, what do -> you do", the new one answers "when, on what evidence, and how should the strategy change". +> +> **This file has the same name and the opposite meaning.** The old `prompt.md` answered "given this +> observation, what do you do"; this one answers "when, on what evidence, and how should the strategy +> change". Nineteen files of the old kind were deleted when prompt mode went, and they still exist in +> git history and in any bundle taken before it — both formats carry the same `name` / `description` +> frontmatter, so nothing but `kind: improve` can tell them apart. A `prompt.md` without the marker is +> **refused at startup** rather than loaded, because loading one would hand the reviser a set of +> trading instructions as its brief and say nothing about it. (The file was briefly called +> `improve.md`; that name is also refused, so a directory that still uses it fails instead of quietly +> running with no LLM at all.) ## How it runs @@ -61,7 +70,7 @@ version number — the context it receives lists every version, when it went in, was worth at the time. An automatic "revert when value went down" would need a threshold and there is no defensible one: the previous implementation's never fired in 18 runs, and the obvious opposite (any loss at all) reverts every revision in a regime where everyone is losing. Whether a dip is the -strategy or the market is a judgment, so `improve.md` is where you state how to make it. +strategy or the market is a judgment, so `prompt.md` is where you state how to make it. ## What the generated code may do @@ -95,7 +104,7 @@ Revision outcomes (installed / declined / rejected / reverted, with the model's `ERIS_IMPROVE_LOG_CALLS: "1"` additionally writes the raw exchange — the system prompt, the context that was sent, and the response — to `runs//agents/.llm.jsonl`. Off by default because -it holds every generated strategy in full. It is the log to turn on when tuning `improve.md`. +it holds every generated strategy in full. It is the log to turn on when tuning `prompt.md`. ## Backends (runtime/llm.ts) @@ -145,7 +154,7 @@ Notes: ```yaml # roster in config/local.yaml agents: - - id: venue-arb # example/agents/venue-arb/ (agent.ts + improve.md) + - id: venue-arb # example/agents/venue-arb/ (agent.ts + prompt.md) wallet: AGENT1_PRIVATE_KEY env: { ERIS_LLM_MODEL: "claude-cli", ERIS_IMPROVE_LOG_CALLS: "1" } - id: venue-arb-frozen # the control: same strategy, no improvement loop diff --git a/docs/guide/repository-layout.md b/docs/guide/repository-layout.md index 7752bdf..536bccc 100644 --- a/docs/guide/repository-layout.md +++ b/docs/guide/repository-layout.md @@ -21,7 +21,7 @@ core/src/ environment daemon + scoring (participants do not touch this example/agents/ participant template (1 agent = 1 directory is the unit of copy and submission) runtime/ generic driver scripts (bot / read / send / llm / prompt / agentLog; reserved names) lib/ shared strategy helpers (markets.ts, etc.; reserved name) - / the agent itself (agent.ts's decide/run, plus improve.md to self-improve) + / the agent itself (agent.ts's decide/run, plus prompt.md to self-improve) contracts/ PriceFeed + mock oracles + FlashArb (Foundry) deployer/ bundled deploy orchestrator (self-contained subpackage that deploys all 5 venues to an empty anvil) config/ YAML config (example.yaml = template / vuln-test.yaml / regimes/ = official regimes; ADR 0016) diff --git a/docs/guide/writing-agents.md b/docs/guide/writing-agents.md index 5740ee7..715dfcb 100644 --- a/docs/guide/writing-agents.md +++ b/docs/guide/writing-agents.md @@ -13,7 +13,7 @@ There are 3 types (details in [Architecture](architecture.md)). This page follow |---|---|---| | rule strategy | `agent.ts` (`decide(obs, ctx)`) | most strategies; observe → decide each block | | self-driven | `agent.ts` (`run(ctx)`) | custom loops / event-driven (e.g. liquidator) | -| self-improving | `agent.ts` + `improve.md` | trade at rule speed while an LLM rewrites the strategy in-run (see [Self-improving agents](llm-agents.md)) | +| self-improving | `agent.ts` + `prompt.md` | trade at rule speed while an LLM rewrites the strategy in-run (see [Self-improving agents](llm-agents.md)) | ## Step 1: The minimal agent diff --git a/example/agents/lst-carry/improve.md b/example/agents/lst-carry/prompt.md similarity index 99% rename from example/agents/lst-carry/improve.md rename to example/agents/lst-carry/prompt.md index be7f24d..c059030 100644 --- a/example/agents/lst-carry/improve.md +++ b/example/agents/lst-carry/prompt.md @@ -1,4 +1,5 @@ --- +kind: improve name: lst-carry description: Liquid staking — stake for yield, or trade the redemption/market gap. The LLM tunes the strategy in-run. reviseEveryBlocks: 60 diff --git a/example/agents/multi-arb/improve.md b/example/agents/multi-arb/prompt.md similarity index 99% rename from example/agents/multi-arb/improve.md rename to example/agents/multi-arb/prompt.md index f070575..f616b88 100644 --- a/example/agents/multi-arb/improve.md +++ b/example/agents/multi-arb/prompt.md @@ -1,4 +1,5 @@ --- +kind: improve name: multi-arb description: Base-agnostic cross-venue arbitrage. The LLM tunes the strategy in-run; the strategy itself trades every block. reviseEveryBlocks: 60 diff --git a/example/agents/my-arb/improve.md b/example/agents/my-arb/prompt.md similarity index 99% rename from example/agents/my-arb/improve.md rename to example/agents/my-arb/prompt.md index aca55af..7a934ac 100644 --- a/example/agents/my-arb/improve.md +++ b/example/agents/my-arb/prompt.md @@ -1,4 +1,5 @@ --- +kind: improve name: my-arb description: The starting-point sample — a naive cross-venue arb, with an LLM improving it in-run. reviseEveryBlocks: 60 diff --git a/example/agents/redemption-arb/agent.ts b/example/agents/redemption-arb/agent.ts new file mode 100644 index 0000000..01eebf1 --- /dev/null +++ b/example/agents/redemption-arb/agent.ts @@ -0,0 +1,335 @@ +/** + * redemption-arb: the CDP stablecoin venue's headline trade (issue #39). + * + * eUSD is redeemable against the riskiest Trove for exactly $1 of collateral, minus a redemption + * fee. So whenever it trades below par on the Curve pool by more than that fee, there is a closed + * loop that does not depend on the price going anywhere: + * + * buy spend USDC on eUSD while the pool is discounted + * redeem exchange it for ETH at the oracle price, paying `redemptionRateBps` out of the ETH + * unwind sell the ETH back to USDC, because the profit is in USDC and ETH is a price bet + * + * Three things make it a decision rather than a formula, and all three are in the observation: + * + * the fee is not fixed. Every redemption in the run raises `baseRate`, and it decays on a ~12h + * half-life -- which inside a 300-second run means it effectively only rises. Redeeming early is + * cheap and redeeming after someone else is not, so the first mover prices everyone behind them. + * + * the discount is not free to take. Buying eUSD pushes the pool back toward par, so an order + * sized at the whole balance closes the very dislocation it is trading. + * + * the exit costs too. Redemption pays *ETH*, and turning that back into USDC pays an AMM fee. An + * agent that compares the discount against the redemption fee alone takes trades that lose money + * on the way out. + * + * The mirror trade -- minting eUSD and selling it when the pool is at a premium -- needs a Trove and + * therefore WETH, which the evaluation regimes deliberately do not hand out (ADR 0017 §4). What is + * here instead is the exit side of it: an inventory bought at a discount is sold rather than + * redeemed once the peg has recovered past par, because at that point the pool pays more than the + * protocol does. + */ +import type { + AgentAction, + AgentContext, + AgentObservation, + LiquityObservation, +} from "@eris/sdk"; +import { TOKENS } from "@eris/sdk/constants.js"; + +// Edge demanded on top of every known cost before buying. The discount is measured at probe size +// and the fee curve moves under other people's redemptions, so this is the margin for both. +const SAFETY_BPS = Number(process.env.ERIS_REDEMPTION_SAFETY_BPS ?? "20"); + +// What it costs to turn redeemed ETH back into USDC, in bps of notional. The local Uniswap pool is +// 0.3%, so this is deliberately not optional: at a 60bps discount and a 50bps fee the trade looks +// profitable and is not. +const EXIT_COST_BPS = Number(process.env.ERIS_REDEMPTION_EXIT_COST_BPS ?? "35"); + +// Share of the free USDC balance to commit to one purchase. Buying pushes the pool back toward par, +// so the whole balance in one order is the reliable way to pay for a discount that is no longer +// there by the end of the fill. +const BUY_FRACTION_BPS = Number( + process.env.ERIS_REDEMPTION_BUY_FRACTION_BPS ?? "3000", +); + +// Never let one purchase exceed this share of the pool's eUSD depth, whatever the balance says. +const MAX_POOL_SHARE_BPS = Number( + process.env.ERIS_REDEMPTION_MAX_POOL_SHARE_BPS ?? "1000", +); + +// How much steeper than proportional the stableswap curve is around a broken peg. Measured on the +// deployed pool: an imbalance of 42k eUSD showed 120bps, and a purchase that took a quarter of that +// imbalance out closed 36% of the discount -- so the discount moves roughly 1.5x the share of the +// imbalance a trade removes. Used to size the buy, not to price it. +const IMPACT_CONVEXITY = Number( + process.env.ERIS_REDEMPTION_IMPACT_CONVEXITY ?? "1.5", +); + +const SLIPPAGE_BPS = Number(process.env.ERIS_REDEMPTION_SLIPPAGE_BPS ?? "100"); + +// Hold the redeemed ETH instead of selling it back to USDC. Off by default: holding ETH is a +// directional bet, and this agent is meant to be the venue's α rather than its β. +const HOLD_ETH = process.env.ERIS_REDEMPTION_HOLD_ETH === "1"; + +// Dust floors. Below these an action costs more in gas than it can earn. +const MIN_EUSD_WEI = 200n * 10n ** 18n; // redeeming less than this is not worth the gas +const MIN_USDC_UNITS = 200n * 10n ** 6n; +const MIN_WETH_WEI = 10n ** 16n; // 0.01 WETH + +/// What this agent started with in each of the two forms redemption pays out in. +/// +/// Native ETH: the balance *above* the endowment is what is tradable, because the endowment is what +/// pays for gas -- selling into it is how an agent strands itself with a position it can no longer +/// close (issue #39). +/// +/// WETH: the same rule for a different reason. A funding profile that hands out WETH (the template +/// config does, for the LST venue) is not redemption proceeds, and dumping it into USDC pays the +/// AMM's fee on inventory the agent never chose to hold. Under the evaluation profile this is zero +/// and nothing changes; without it, an agent funded in WETH spends its whole run trying to sell it. +let baselineEthWei: bigint | null = null; +let baselineWethWei: bigint | null = null; + +function minBI(a: bigint, b: bigint): bigint { + return a < b ? a : b; +} + +function fraction(amount: bigint, bps: number): bigint { + return (amount * BigInt(Math.max(0, Math.round(bps)))) / 10_000n; +} + +/// The discount at which redeeming beats selling into the pool. Redemption pays par minus the +/// protocol's fee, and the ETH it pays in still has to be sold; the pool pays the market price +/// directly. So the two exits are equal exactly here, and below it the pool is the better door. +export function redeemThresholdBps(l: LiquityObservation): number { + return l.redemptionRateBps + EXIT_COST_BPS; +} + +/// How far the pool is from balanced, in USDC-equivalent units. Near par the two coins trade +/// one-for-one, so half the difference between the legs is what a trade has to move to close the +/// dislocation. Zero when the observation carried no reserves. +export function poolImbalanceUsdc(l: LiquityObservation): bigint { + if (!l.poolReserves) return 0n; + const eusd = BigInt(l.poolReserves.eusd) / 10n ** 12n; // to USDC's 6 decimals + const usdc = BigInt(l.poolReserves.usdc); + return eusd > usdc ? (eusd - usdc) / 2n : 0n; +} + +export type RedemptionDecision = + | { kind: "buy"; usdcIn: bigint; edgeBps: number } + | { kind: "redeem"; eusdIn: bigint; edgeBps: number } + | { kind: "sell"; eusdIn: bigint; premiumBps: number } + | { kind: "wrap"; ethWei: bigint } + | { kind: "unwind"; wethWei: bigint } + | { kind: "hold"; reason: string }; + +/// The decision, separated from the observation plumbing so the economics can be reasoned about +/// (and tested) without a chain. +/// +/// Order matters as much as the thresholds. Recycling capital comes before finding new trades: an +/// agent that buys again while its last redemption is still sitting in ETH is running a price bet +/// it never decided to take, and it has less USDC to trade the next dislocation with. +export function decideRedemption(input: { + liquity: LiquityObservation; + usdcUnits: bigint; + wethWei: bigint; + ethWei: bigint; + ethBaselineWei: bigint; + wethBaselineWei: bigint; + maxUsdcPerRound: bigint; + maxWethPerRound: bigint; + canSellEth: boolean; +}): RedemptionDecision { + const l = input.liquity; + const eusd = BigInt(l.eusdBalanceWei); + + // 1. Sell the proceeds of the last redemption. WETH first, then wrap whatever native ETH the + // redemption left above the gas endowment. + if (input.canSellEth && !HOLD_ETH) { + const sellable = + input.wethWei > input.wethBaselineWei + ? input.wethWei - input.wethBaselineWei + : 0n; + if (sellable >= MIN_WETH_WEI) { + // Capped at the per-round swap limit rather than sent whole. A redemption large enough to + // exceed it would otherwise be rejected by the runtime every block, and because this branch + // comes first the agent would never reach any of its other decisions -- measured on the + // template config, where a WETH-funded wallet spent all 40 blocks being rejected. + const size = + input.maxWethPerRound > 0n && sellable > input.maxWethPerRound + ? input.maxWethPerRound + : sellable; + return { kind: "unwind", wethWei: size }; + } + const reserve = + input.ethBaselineWei > BigInt(l.suggestedGasReserveWei) + ? input.ethBaselineWei + : BigInt(l.suggestedGasReserveWei); + if (input.ethWei > reserve + MIN_WETH_WEI) + return { kind: "wrap", ethWei: input.ethWei - reserve }; + } + + if (!l.marketQuoted) { + return { + kind: "hold", + reason: + "the eUSD/USDC pool did not quote: there is no market price to trade against", + }; + } + + // 2. eUSD in hand goes out the better of the two doors. The protocol pays par minus the + // redemption fee; the pool pays whatever it is trading at. Above par the pool wins, and + // redeeming there would be paying a fee to receive less. + if (eusd >= MIN_EUSD_WEI) { + const redeemEdge = l.redemptionEdgeBps - EXIT_COST_BPS; + if (l.discountBps <= 0) + return { kind: "sell", eusdIn: eusd, premiumBps: -l.discountBps }; + if (redeemEdge > 0) + return { kind: "redeem", eusdIn: eusd, edgeBps: redeemEdge }; + // Bought into a discount that has since narrowed but not closed. Selling here pays the pool's + // spread on the way out for nothing, so hold and let the position work. + return { + kind: "hold", + reason: `holding ${(Number(eusd) / 1e18).toFixed(0)} eUSD: redeeming nets ${redeemEdge.toFixed(1)}bps after the ${EXIT_COST_BPS}bps exit, and the pool is still ${l.discountBps.toFixed(1)}bps below par`, + }; + } + + // 3. Buy the discount, if it clears every cost between here and USDC. + const buyEdge = + l.discountBps - l.redemptionRateBps - EXIT_COST_BPS - SAFETY_BPS; + if (buyEdge > 0 && input.usdcUnits >= MIN_USDC_UNITS) { + let size = minBI( + fraction(input.usdcUnits, BUY_FRACTION_BPS), + input.usdcUnits, + ); + if (input.maxUsdcPerRound > 0n) size = minBI(size, input.maxUsdcPerRound); + // The pool's own depth is the harder bound: past a share of it the purchase is bidding against + // itself and the average fill is nowhere near the quoted discount. + const poolEusd = l.poolReserves ? BigInt(l.poolReserves.eusd) : 0n; + if (poolEusd > 0n) { + // eUSD is 18-decimal and USDC is 6; near par the two are interchangeable at 1e12. + const capUsdc = fraction(poolEusd, MAX_POOL_SHARE_BPS) / 10n ** 12n; + if (capUsdc > 0n) size = minBI(size, capUsdc); + } + // The binding cap, and the one that took a live run to find: buy so much that the purchase + // itself pushes the discount under the redemption threshold and the loop never closes. Measured + // on the first run of this agent -- it bought at 120bps, its own fill left 78bps, and 85bps was + // needed to redeem, so it sat on the inventory and exited through the pool instead. Sizing to + // the headroom above that threshold is what turns the trade back into a redemption. + const room = l.discountBps - redeemThresholdBps(l) - SAFETY_BPS; + const imbalanceUsdc = poolImbalanceUsdc(l); + if (imbalanceUsdc > 0n && l.discountBps > 0) { + const affordable = + room > 0 + ? (imbalanceUsdc * BigInt(Math.floor(room * 1000))) / + BigInt(Math.ceil(2 * IMPACT_CONVEXITY * l.discountBps * 1000)) + : 0n; + size = minBI(size, affordable); + } + if (size >= MIN_USDC_UNITS) + return { kind: "buy", usdcIn: size, edgeBps: buyEdge }; + } + + return { + kind: "hold", + reason: + `eUSD is ${l.discountBps.toFixed(1)}bps off par; redeeming costs ${l.redemptionRateBps.toFixed(1)}bps ` + + `and the exit ${EXIT_COST_BPS}bps, so the edge is ${buyEdge.toFixed(1)}bps after ${SAFETY_BPS}bps of safety`, + }; +} + +export function decide( + obs: AgentObservation, + ctx?: AgentContext, +): AgentAction | Record | null { + const liquity = obs.protocols.liquity; + if (!liquity) { + return { + type: "noop", + reason: "the liquity venue is not enabled this run", + }; + } + const ethWei = BigInt(obs.balances.ethWei || "0"); + const wethWei = BigInt(obs.balances.wethWei || "0"); + if (baselineEthWei === null) baselineEthWei = ethWei; + if (baselineWethWei === null) baselineWethWei = wethWei; + + const decision = decideRedemption({ + liquity, + usdcUnits: BigInt(obs.balances.usdcUnits || "0"), + wethWei, + ethWei, + ethBaselineWei: baselineEthWei, + wethBaselineWei: baselineWethWei, + maxUsdcPerRound: BigInt(obs.limits.maxUsdcInUnits || "0"), + maxWethPerRound: BigInt(obs.limits.maxWethInWei || "0"), + // Turning ETH back into USDC needs a spot market. Without one the proceeds stay in ETH, which + // is a worse position than the agent chose but better than a swap that reverts every block. + canSellEth: obs.enabledProtocols.includes("uniswap"), + }); + const fee = obs.limits.defaultPriorityFeePerGasWei; + + // Record why, every cycle. Sitting out is the correct move most of the time here -- the peg is + // near par by construction until something moves it -- and without this a run where the agent + // correctly did nothing is indistinguishable from one where it was broken. + ctx?.log({ + round: obs.round, + reason: decision.kind === "hold" ? decision.reason : decision.kind, + signals: { + discountBps: Number(liquity.discountBps.toFixed(2)), + redemptionRateBps: Number(liquity.redemptionRateBps.toFixed(2)), + redemptionEdgeBps: Number(liquity.redemptionEdgeBps.toFixed(2)), + eusd: Number((Number(liquity.eusdBalanceWei) / 1e18).toFixed(2)), + tcr: Number(liquity.tcr.toFixed(3)), + recoveryMode: liquity.recoveryMode ? 1 : 0, + }, + }); + + switch (decision.kind) { + case "buy": + return { + type: "liquitySwapEusd", + tokenIn: "USDC", + amountIn: decision.usdcIn.toString(), + slippageBps: SLIPPAGE_BPS, + maxPriorityFeePerGasWei: fee, + }; + case "sell": + return { + type: "liquitySwapEusd", + tokenIn: "EUSD", + amountIn: decision.eusdIn.toString(), + slippageBps: SLIPPAGE_BPS, + maxPriorityFeePerGasWei: fee, + }; + case "redeem": + // The adapter computes the HintHelpers hints and truncates to what the sorted list can + // absorb; an unhinted redemption walks the list on chain and is prohibitively expensive. + return { + type: "liquityRedeem", + amountEusdWei: decision.eusdIn.toString(), + maxPriorityFeePerGasWei: fee, + }; + case "wrap": + // No action type wraps ETH -- every other venue is WETH-denominated, so nothing has needed + // it. Redemption is the one payout in native ETH, so this agent does it itself. + return { + type: "rawTx", + tx: { + to: TOKENS.WETH.address, + data: "0xd0e30db0", // WETH9.deposit() + value: decision.ethWei.toString(), + }, + maxPriorityFeePerGasWei: fee, + }; + case "unwind": + return { + type: "swap", + tokenIn: "WETH", + amountIn: decision.wethWei.toString(), + slippageBps: SLIPPAGE_BPS, + maxPriorityFeePerGasWei: fee, + }; + default: + return { type: "noop", reason: decision.reason }; + } +} diff --git a/example/agents/redemption-arb/prompt.md b/example/agents/redemption-arb/prompt.md new file mode 100644 index 0000000..d880e86 --- /dev/null +++ b/example/agents/redemption-arb/prompt.md @@ -0,0 +1,54 @@ +--- +kind: improve +name: redemption-arb +description: CDP stablecoin arb — buy eUSD below par and redeem it against the riskiest Trove. The LLM tunes the strategy in-run. +reviseEveryBlocks: 60 +--- + +You are maintaining a redemption-arbitrage strategy on a Liquity-style CDP. It runs on every block +without you. + +The venue's guarantee is that eUSD can always be exchanged for $1 of collateral against the riskiest +Trove, minus a redemption fee. So a discount on the eUSD/USDC pool is not a price forecast — it is a +gap against something the protocol enforces. What makes it a decision rather than a formula: + +- **The fee moves, and inside a run it effectively only rises.** `redemptionRateBps` is driven by + `baseRate`, which every redemption raises and which decays on a ~12h half-life. Whoever redeems + first pays 50bps; whoever follows pays more. +- **Taking the discount closes it.** Buying eUSD pushes the pool back toward par. `poolReserves` + says how much depth there is to trade against. +- **The exit is not free.** Redemption pays *native ETH*, and turning that back into USDC costs an + AMM fee. The comparison is discount vs (redemption fee + exit cost), never discount vs fee. +- **Collateral and gas come out of the same balance.** `ethBalanceWei` pays for transactions. + `suggestedGasReserveWei` is what to keep back. + +## When to leave it alone + +Return `"executorTs": null` unless you can point at the problem. A run where eUSD stayed at par is a +run with nothing to trade, and holding through it is correct — check `discountBps` in the log before +concluding the strategy is broken. + +## What is worth changing + +- **Thresholds that never fire, or fire into a loss.** The log records `discountBps`, + `redemptionRateBps` and `redemptionEdgeBps` every block. If the discount opened and the agent + never acted, the safety margin is too wide; if it acted and lost, the exit cost is understated. +- **Sizing.** One order that takes the whole discount pays the average, not the quoted price. One + order that is too small leaves the rest to somebody else. +- **Sitting on eUSD.** Inventory bought at a discount is only profit once it leaves — through + redemption, or through the pool if the peg has recovered past par. +- **Ignoring `recoveryMode` / `tcr`.** In Recovery Mode the whole system's risk moves at once. It + changes what a Trove is worth and what a liquidation pays, and it is visible before it matters. + +## Constraints + +- Only `obs`, `ctx` and standard JavaScript. No `require`, `import`, `process` or `fetch`. +- Check balances before choosing a direction; a leg the runtime rejects scores like doing nothing. +- Respect `obs.limits`. +- Return one action object or `null`. `ctx.log({ reason })` records why. + +## Undoing a change + +Nothing reverts automatically. If one of your rewrites made things worse, return +`{"notes": "...", "revertTo": }` — the context lists every version, when it went in, and +what the agent was worth at the time. diff --git a/example/agents/runtime/bot.ts b/example/agents/runtime/bot.ts index 55fcb11..301f063 100644 --- a/example/agents/runtime/bot.ts +++ b/example/agents/runtime/bot.ts @@ -6,17 +6,19 @@ * env ERIS_AGENT_DIR. bot.ts decides how to run from that directory's contents: * - agent.ts exports run(ctx) -> self-driven: pass ctx and delegate (no loop) * - agent.ts exports decide() -> rule strategy: drive a read->decide->send loop - * - agent.ts + improve.md -> self-improving: the same loop, plus an LLM that periodically + * - agent.ts + prompt.md -> self-improving: the same loop, plus an LLM that periodically * rewrites the strategy out of the trade path (ADR 0018) * * Prompt mode (an LLM producing an action every decision) was removed in ADR 0018: measured at * 8-28 blocks per decision and 1/64 the actions of the same strategy in rule mode, it could not - * compete. The LLM now improves the strategy instead of driving it. + * compete. The LLM now improves the strategy instead of driving it -- and prompt.md now holds the + * policy for *that*, not per-decision instructions, which is why it must declare `kind: improve` + * (ADR 0018 Amendment 1). A file without the marker is refused rather than reinterpreted. * - * ERIS_AGENT_FROZEN=1 ignore improve.md and run the strategy unchanged. This is the + * ERIS_AGENT_FROZEN=1 ignore prompt.md and run the strategy unchanged. This is the * frozen control every roster needs (ADR 0018 §5), without * duplicating the agent directory - * ERIS_LLM_MODEL= backend for the revision call (improve.md frontmatter wins) + * ERIS_LLM_MODEL= backend for the revision call (prompt.md frontmatter wins) * ERIS_IMPROVE_LOG_CALLS=1 record the raw revision exchange (system / context / response) * to runs//agents/.llm.jsonl. Off by default: it holds * every generated strategy in full @@ -54,6 +56,7 @@ import { buildRevisionSystem, compileExecutor, effectiveReviseInterval, + improvePolicyState, loadImproveAgent, MAX_REVISIONS_PER_RUN, parseRevision, @@ -63,7 +66,7 @@ import { import { createMempoolLog, Sender } from "./send.js"; import { Reader } from "./read.js"; -// Backend for the revision call when neither improve.md nor the roster names one. +// Backend for the revision call when neither prompt.md nor the roster names one. const DEFAULT_IMPROVE_MODEL = "gpt-oss:120b"; async function main(): Promise { @@ -135,7 +138,7 @@ async function main(): Promise { }); // ---- resolve the agent module (1 agent = 1 directory) ---- - // agent.ts is always the strategy (ADR 0015 §2). If improve.md sits beside it, the same strategy + // agent.ts is always the strategy (ADR 0015 §2). If prompt.md sits beside it, the same strategy // runs at the same speed and an LLM is periodically offered the chance to rewrite it (ADR 0018). // The retired prompt mode put the LLM in the trade path instead, which cost 8-28 blocks per // decision -- 1/64 the actions of the same strategy in rule mode (ADR 0017 §5 B1). @@ -150,7 +153,7 @@ async function main(): Promise { if (retired.length > 0) { process.stderr.write( `[bot] ${retired.join(", ")} is retired (ADR 0018 removed prompt mode). An agent is agent.ts, ` + - `optionally with improve.md beside it for LLM-driven self-improvement; ` + + `optionally with prompt.md (kind: improve) beside it for LLM-driven self-improvement; ` + `use ERIS_AGENT_FROZEN=1 to run it without the improvement loop\n`, ); process.exit(1); @@ -158,7 +161,20 @@ async function main(): Promise { } const agentTsPath = join(agentDir, "agent.ts"); const hasAgentTs = existsSync(agentTsPath); - const hasImprove = existsSync(join(agentDir, "improve.md")); + const policy = improvePolicyState(agentDir); + const hasImprove = policy === "present"; + // The improvement policy was called improve.md until ADR 0018 Amendment 1. A directory still + // carrying the old name would otherwise run as a plain rule agent: the strategy trades, nothing + // ever revises it, and no line of output says the LLM was never involved. Refuse instead. + if (policy === "renamed") { + process.stderr.write( + `[bot] ${agentDir} has improve.md, which was renamed prompt.md (ADR 0018 Amendment 1). ` + + `Rename it and add \`kind: improve\` to its frontmatter, or delete it to run the strategy ` + + `unchanged\n`, + ); + process.exit(1); + return; + } // Opt out of the improvement loop while keeping the same directory: the frozen control that // ADR 0018 §5 requires in every roster is this flag, not a second copy of the agent. const frozen = process.env.ERIS_AGENT_FROZEN === "1"; @@ -166,7 +182,7 @@ async function main(): Promise { process.stderr.write( existsSync(join(agentDir, "prompt.md")) ? `[bot] ${agentDir} has prompt.md but no agent.ts. Prompt mode was removed (ADR 0018): ` + - `an agent is agent.ts, optionally with improve.md beside it\n` + `an agent is agent.ts, and prompt.md is the policy for revising it, not a strategy\n` : `[bot] ${agentDir} has no agent.ts (ADR 0015 §2 / ADR 0018 §1)\n`, ); process.exit(1); @@ -186,10 +202,14 @@ async function main(): Promise { process.exit(1); return; } + // Read the policy before a single block is traded. Loading it inside the improvement loop meant a + // malformed or unmarked prompt.md was only discovered once the agent was already trading, which + // makes a configuration error look like a mid-run crash. + const improveAgent = mode === "improve" ? loadImproveAgent(agentDir) : null; if (hasImprove && typeof agentModule.run === "function") { // run(ctx) owns its own loop, so there is no decide to swap out. process.stderr.write( - `[bot] ${agentDir} has improve.md but exports run(ctx); self-improvement applies to ` + + `[bot] ${agentDir} has prompt.md but exports run(ctx); self-improvement applies to ` + `decide() strategies only (ADR 0018 §1)\n`, ); process.exit(1); @@ -362,7 +382,7 @@ async function main(): Promise { // back is better. Every accept, decline, rejection and rollback is logged, because the previous // attempt at this (deleted src/llm) shipped a rollback that never once fired and nobody noticed. async function runImproveLoop(): Promise { - const improveAgent = loadImproveAgent(agentDir); + if (!improveAgent) return; const model = improveAgent.model ?? process.env.ERIS_LLM_MODEL ?? DEFAULT_IMPROVE_MODEL; // The raw exchange, opt-in. The outcome log says a revision was rejected or rolled back; only diff --git a/example/agents/runtime/improve.ts b/example/agents/runtime/improve.ts index 2c44a73..bac0b4e 100644 --- a/example/agents/runtime/improve.ts +++ b/example/agents/runtime/improve.ts @@ -24,7 +24,7 @@ import { findCheatcodeUsage } from "@eris/sdk/strategyStaticCheck.js"; import type { AgentContext } from "@eris/sdk/agent.js"; import type { AgentAction, AgentObservation } from "@eris/sdk/types.js"; -// How often the LLM is offered a chance to revise, in blocks, when improve.md does not say. +// How often the LLM is offered a chance to revise, in blocks, when prompt.md does not say. export const DEFAULT_REVISE_EVERY_BLOCKS = 60; // Wall-clock bound on one call into a generated strategy. Blocks are 2 s in production, so a // strategy that has not answered in this long has already missed its block. @@ -53,7 +53,7 @@ export type ImproveAgent = { // down" needs a threshold, and there is no defensible one: the previous implementation's never // fired in 18 runs, and the obvious opposite (any loss at all) reverts every revision in a regime // where everyone is losing. The model already sees the PnL since each revision and the notes it -// wrote at the time, so the judgment belongs there -- and improve.md is where a participant states +// wrote at the time, so the judgment belongs there -- and prompt.md is where a participant states // how to make it. Timing is unchanged either way: both fire at a revision opportunity. export type StrategyRevision = { version: number; @@ -78,21 +78,56 @@ export type RevisionOutcome = | { kind: "rejected"; reason: string } | { kind: "reverted"; to: number; from: number; notes: string }; -// improve.md: the improvement prompt. Not a renamed prompt.md -- prompt.md said "given this -// observation, what do you do", improve.md says "when, on what evidence, and how should the strategy -// change" (ADR 0018 §1). +// prompt.md: the improvement policy (ADR 0018 §1, renamed from improve.md in ADR 0018 Amendment 1). +// +// The file name is reused from the retired per-decision prompt, and the two mean opposite things: +// the old one said "given this observation, what do you do", this one says "when, on what evidence, +// and how should the strategy change". Nineteen files of the old kind were deleted in f42fd2a and +// still exist in git history and in every bundle taken before it, so the name alone cannot say which +// contract a file is written against -- and both formats carry the same `name` / `description` +// frontmatter, so that cannot either. +// +// Hence IMPROVE_KIND: the file declares its own contract. Without it, an old prompt.md would be +// loaded as an improvement policy and the model would be handed trading instructions as its brief, +// with nothing anywhere saying so. A missing marker is refused rather than guessed at. +export const IMPROVE_KIND = "improve"; + +// What a directory says about the improvement loop, before anything is parsed. +// present prompt.md is there and the loop applies +// renamed only the pre-Amendment-1 improve.md is there -- the participant meant to opt in +// absent neither, so the strategy runs unrevised, which is a legitimate agent +export type ImprovePolicyState = "present" | "renamed" | "absent"; + +/// Distinguish "no improvement policy" from "a policy under the old file name". +/// +/// Without the middle case a renamed-away improve.md is simply ignored: the strategy trades, no LLM +/// ever touches it, and nothing in the run says so. Silence is the worst of the three outcomes, so +/// the caller is given enough to refuse. +export function improvePolicyState(agentDir: string): ImprovePolicyState { + if (existsSync(join(agentDir, "prompt.md"))) return "present"; + if (existsSync(join(agentDir, "improve.md"))) return "renamed"; + return "absent"; +} + export function loadImproveAgent(agentDir: string): ImproveAgent { - const path = join(agentDir, "improve.md"); - if (!existsSync(path)) throw new Error(`improve.md not found in ${agentDir}`); + const path = join(agentDir, "prompt.md"); + if (!existsSync(path)) throw new Error(`prompt.md not found in ${agentDir}`); const raw = readFileSync(path, "utf8"); const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!m) throw new Error( - `${path}: frontmatter (---) is required (name / description mandatory)`, + `${path}: frontmatter (---) is required (kind / name / description mandatory)`, ); const fm = parseYaml(m[1]) as Record | null; if (!fm || typeof fm !== "object") throw new Error(`${path}: frontmatter must be a YAML mapping`); + if (fm.kind !== IMPROVE_KIND) + throw new Error( + `${path}: frontmatter "kind: ${IMPROVE_KIND}" is required. A prompt.md without it is the ` + + "retired per-decision prompt (ADR 0018 removed prompt mode, deleting 19 of them in f42fd2a); " + + "loading one as an improvement policy would hand the model trading instructions as its brief. " + + "If this file really is an improvement policy, add the marker — see example/agents/venue-arb/prompt.md", + ); if (typeof fm.name !== "string" || fm.name.trim() === "") throw new Error(`${path}: frontmatter "name" is required`); if (typeof fm.description !== "string" || fm.description.trim() === "") @@ -280,7 +315,7 @@ export function compileExecutor(source: string): CompileResult { } } -// The system prompt for a revision. The participant's improve.md is the policy; this frames what the +// The system prompt for a revision. The participant's prompt.md is the policy; this frames what the // model is being asked to produce and what it is allowed to see. export function buildRevisionSystem( agent: ImproveAgent, diff --git a/example/agents/sp-underwriter/agent.ts b/example/agents/sp-underwriter/agent.ts new file mode 100644 index 0000000..f458496 --- /dev/null +++ b/example/agents/sp-underwriter/agent.ts @@ -0,0 +1,247 @@ +/** + * sp-underwriter: the Stability Pool side of the CDP venue (issue #39). + * + * A Stability Pool deposit is an underwriting position, not a yield position. It sits there doing + * nothing until a Trove falls under 110%, and then it is *spent*: the pool burns your eUSD against + * the liquidated debt and hands you the collateral that backed it. Because Liquity only liquidates + * below MCR, that collateral is worth more than the debt it cancelled — the difference, minus the + * price move you now carry, is the whole return. + * + * So there are three decisions, and they are not the same decision: + * + * how much to deposit the pool pays out in proportion to your share, so depth is the position. + * But deposited eUSD cannot be spent on anything else, and a run with no + * liquidations pays nothing at all. + * whether to liquidate Liquity's liquidation is permissionless and pays the caller a gas + * compensation plus 0.5% of the collateral. A depositor has a second reason + * to call it: nothing pays out until somebody does. + * when to take the ETH the gain accrues as collateral, which is a price bet the agent never + * chose. Claiming and selling converts it back to the unit it is scored in. + * + * The agent buys its eUSD on the market rather than borrowing it, so it never has a Trove of its + * own and never competes with itself: being liquidated while underwriting liquidations would be a + * different (and much worse) strategy. + */ +import type { + AgentAction, + AgentContext, + AgentObservation, + LiquityObservation, +} from "@eris/sdk"; +import { TOKENS } from "@eris/sdk/constants.js"; + +// Share of the USDC book to convert into eUSD and underwrite with. Not all of it: a pool deposit is +// illiquid for as long as it is there, and the run may never liquidate anything. +const UNDERWRITE_BPS = Number(process.env.ERIS_SP_UNDERWRITE_BPS ?? "5000"); + +// Never buy eUSD above this premium. Underwriting at a premium means paying more than a dollar for +// a claim the protocol values at a dollar, which the liquidation discount then has to earn back. +const MAX_PREMIUM_BPS = Number(process.env.ERIS_SP_MAX_PREMIUM_BPS ?? "10"); + +// Liquidate anything whose ICR is under MCR by this margin. A Trove exactly at the line is a race +// against the next oracle write, and a liquidation that arrives after the price recovers reverts. +const LIQUIDATION_MARGIN = Number(process.env.ERIS_SP_LIQ_MARGIN ?? "0.005"); + +// Claim the ETH gain once it is worth more than this in USD -- below it, the gas and the AMM fee on +// the way back to USDC eat the gain. +const MIN_CLAIM_USD = Number(process.env.ERIS_SP_MIN_CLAIM_USD ?? "50"); + +const SLIPPAGE_BPS = Number(process.env.ERIS_SP_SLIPPAGE_BPS ?? "100"); +const MIN_USDC_UNITS = 200n * 10n ** 6n; +const MIN_EUSD_WEI = 200n * 10n ** 18n; +const MIN_WETH_WEI = 10n ** 16n; + +/// The native ETH the agent started with. The Stability Pool pays out in native ETH, so what is +/// tradable is the balance above the endowment that pays for gas. +let baselineEthWei: bigint | null = null; +let baselineWethWei: bigint | null = null; + +function minBI(a: bigint, b: bigint): bigint { + return a < b ? a : b; +} + +function fraction(amount: bigint, bps: number): bigint { + return (amount * BigInt(Math.max(0, Math.round(bps)))) / 10_000n; +} + +export type UnderwriterDecision = + | { kind: "liquidate"; borrower: string; icr: number } + | { kind: "claim"; gainUsd: number } + | { kind: "buy"; usdcIn: bigint } + | { kind: "deposit"; amountEusdWei: bigint } + | { kind: "wrap"; ethWei: bigint } + | { kind: "unwind"; wethWei: bigint } + | { kind: "hold"; reason: string }; + +/// The decision, separated from the observation plumbing so it can be reasoned about (and tested) +/// without a chain. +/// +/// Liquidating comes first because it is the only branch with a deadline: a Trove under MCR is a +/// race, both against the price recovering and against every other agent watching the same list. +/// Everything else here can wait a block without costing anything. +export function decideUnderwriting(input: { + liquity: LiquityObservation; + usdcUnits: bigint; + wethWei: bigint; + ethWei: bigint; + ethBaselineWei: bigint; + wethBaselineWei: bigint; + maxUsdcPerRound: bigint; + maxWethPerRound: bigint; + canSellEth: boolean; +}): UnderwriterDecision { + const l = input.liquity; + + // 1. Somebody is under water. Nothing in the pool pays out until this call is made, and whoever + // makes it also takes the gas compensation. + const riskiest = l.riskiestTrove; + if (riskiest && riskiest.icr < l.mcr - LIQUIDATION_MARGIN) { + return { kind: "liquidate", borrower: riskiest.owner, icr: riskiest.icr }; + } + + // 2. Convert what the last liquidation paid back into the unit this is scored in. + if (input.canSellEth) { + const sellable = + input.wethWei > input.wethBaselineWei + ? input.wethWei - input.wethBaselineWei + : 0n; + if (sellable >= MIN_WETH_WEI) { + const size = + input.maxWethPerRound > 0n && sellable > input.maxWethPerRound + ? input.maxWethPerRound + : sellable; + return { kind: "unwind", wethWei: size }; + } + const reserve = + input.ethBaselineWei > BigInt(l.suggestedGasReserveWei) + ? input.ethBaselineWei + : BigInt(l.suggestedGasReserveWei); + if (input.ethWei > reserve + MIN_WETH_WEI) + return { kind: "wrap", ethWei: input.ethWei - reserve }; + } + + // 3. The gain accrues as collateral inside the pool, which is a price bet the agent never took. + // Withdrawing zero claims it without touching the deposit. + const gainUsd = (Number(l.spEthGainWei) / 1e18) * l.priceUsd; + if (gainUsd >= MIN_CLAIM_USD) return { kind: "claim", gainUsd }; + + // 4. Build the position. eUSD first, then depth. + const eusd = BigInt(l.eusdBalanceWei); + if (eusd >= MIN_EUSD_WEI) return { kind: "deposit", amountEusdWei: eusd }; + + const target = fraction(input.usdcUnits, UNDERWRITE_BPS); + if (target >= MIN_USDC_UNITS && l.marketQuoted) { + // discountBps is negative at a premium, so this is "not much above par". + if (-l.discountBps > MAX_PREMIUM_BPS) + return { + kind: "hold", + reason: `eUSD is ${(-l.discountBps).toFixed(1)}bps above par; underwriting at a premium means the liquidation discount has to earn it back first`, + }; + const size = + input.maxUsdcPerRound > 0n + ? minBI(target, input.maxUsdcPerRound) + : target; + if (size >= MIN_USDC_UNITS) return { kind: "buy", usdcIn: size }; + } + + return { + kind: "hold", + reason: + `${(Number(l.spDepositEusdWei) / 1e18).toFixed(0)} eUSD underwriting ` + + `${(Number(l.spShareBps) / 100).toFixed(1)}% of the pool; riskiest Trove at ICR ` + + `${(riskiest?.icr ?? 0).toFixed(2)} against MCR ${l.mcr}`, + }; +} + +export function decide( + obs: AgentObservation, + ctx?: AgentContext, +): AgentAction | Record | null { + const liquity = obs.protocols.liquity; + if (!liquity) { + return { + type: "noop", + reason: "the liquity venue is not enabled this run", + }; + } + const ethWei = BigInt(obs.balances.ethWei || "0"); + const wethWei = BigInt(obs.balances.wethWei || "0"); + if (baselineEthWei === null) baselineEthWei = ethWei; + if (baselineWethWei === null) baselineWethWei = wethWei; + + const decision = decideUnderwriting({ + liquity, + usdcUnits: BigInt(obs.balances.usdcUnits || "0"), + wethWei, + ethWei, + ethBaselineWei: baselineEthWei, + wethBaselineWei: baselineWethWei, + maxUsdcPerRound: BigInt(obs.limits.maxUsdcInUnits || "0"), + maxWethPerRound: BigInt(obs.limits.maxWethInWei || "0"), + canSellEth: obs.enabledProtocols.includes("uniswap"), + }); + const fee = obs.limits.defaultPriorityFeePerGasWei; + + ctx?.log({ + round: obs.round, + reason: decision.kind === "hold" ? decision.reason : decision.kind, + signals: { + spDeposit: Number((Number(liquity.spDepositEusdWei) / 1e18).toFixed(0)), + spShareBps: liquity.spShareBps, + spEthGain: Number((Number(liquity.spEthGainWei) / 1e18).toFixed(4)), + riskiestIcr: Number((liquity.riskiestTrove?.icr ?? 0).toFixed(3)), + mcr: liquity.mcr, + recoveryMode: liquity.recoveryMode ? 1 : 0, + }, + }); + + switch (decision.kind) { + case "liquidate": + return { + type: "liquityLiquidate", + borrowers: [decision.borrower], + maxPriorityFeePerGasWei: fee, + }; + case "claim": + // Zero is the protocol's own idiom for "pay me the gain, leave the deposit". + return { + type: "liquityWithdrawFromSP", + amountEusdWei: "0", + maxPriorityFeePerGasWei: fee, + }; + case "buy": + return { + type: "liquitySwapEusd", + tokenIn: "USDC", + amountIn: decision.usdcIn.toString(), + slippageBps: SLIPPAGE_BPS, + maxPriorityFeePerGasWei: fee, + }; + case "deposit": + return { + type: "liquityProvideToSP", + amountEusdWei: decision.amountEusdWei.toString(), + maxPriorityFeePerGasWei: fee, + }; + case "wrap": + return { + type: "rawTx", + tx: { + to: TOKENS.WETH.address, + data: "0xd0e30db0", // WETH9.deposit() + value: decision.ethWei.toString(), + }, + maxPriorityFeePerGasWei: fee, + }; + case "unwind": + return { + type: "swap", + tokenIn: "WETH", + amountIn: decision.wethWei.toString(), + slippageBps: SLIPPAGE_BPS, + maxPriorityFeePerGasWei: fee, + }; + default: + return { type: "noop", reason: decision.reason }; + } +} diff --git a/example/agents/sp-underwriter/prompt.md b/example/agents/sp-underwriter/prompt.md new file mode 100644 index 0000000..d9515e1 --- /dev/null +++ b/example/agents/sp-underwriter/prompt.md @@ -0,0 +1,62 @@ +--- +kind: improve +name: sp-underwriter +description: CDP underwriting — deposit eUSD into the Stability Pool, liquidate what falls under MCR, and bank the collateral. +reviseEveryBlocks: 60 +--- + +You are maintaining a Stability Pool underwriting strategy on a Liquity-style CDP. It runs on every +block without you. + +A Stability Pool deposit is not a yield position. It sits idle until a Trove falls under a 110% ICR, +and then it is *spent*: the pool burns the deposited eUSD against that Trove's debt and pays out the +collateral that backed it. Because liquidation only happens below 110%, the collateral is worth more +than the debt it cancelled — that difference is the entire return, and a run with no liquidations +pays nothing at all. + +Three separate decisions: + +- **Depth.** `spShareBps` is the share of the next liquidation this agent receives. Deposited eUSD + cannot be used for anything else while it is in the pool. +- **Whether to liquidate.** Liquity's liquidation is permissionless and pays the caller a gas + compensation plus 0.5% of the collateral — and nothing in the pool pays out until somebody makes + the call. `riskiestTrove.icr` against `mcr` is the whole trigger. The oracle is one block stale + for everyone, so a Trove barely under the line may be back above it by the time the call lands; + that attempt reverts and costs gas. +- **When to take the ETH.** `spEthGainWei` accrues as collateral — a price bet the agent never + chose. Withdrawing `"0"` claims it without touching the deposit. + +Recovery Mode (`recoveryMode`, below a 150% system TCR) changes the game: a Trove becomes +liquidatable once its ICR is under the *current* TCR rather than under 110%, so the pool can be +spent far faster than usual. Two things change with it — the payout is capped at 110% of the debt +(the borrower keeps the surplus), and the liquidation only goes through if the pool can absorb that +Trove's whole debt. + +## When to leave it alone + +Return `"executorTs": null` unless you can point at the problem. A run where nothing was ever +liquidatable is a run where sitting on the deposit was correct. + +## What is worth changing + +- **Paying up for the eUSD.** Underwriting at a premium to par means the liquidation discount has + to earn that back before anything is profit. `discountBps` is negative at a premium. +- **Depth that is wrong for the run.** All of it in the pool and there is nothing left to trade + with; too little and a liquidation that finally arrives barely pays. +- **Liquidation attempts that revert.** The log records `riskiestIcr` and `mcr` every block. A + string of failures means the margin is too thin for a one-block-stale oracle. +- **Sitting on the collateral.** An unclaimed or unsold ETH gain is directional exposure the + strategy never decided to take. + +## Constraints + +- Only `obs`, `ctx` and standard JavaScript. No `require`, `import`, `process` or `fetch`. +- Check balances before choosing a direction; a leg the runtime rejects scores like doing nothing. +- Respect `obs.limits`. +- Return one action object or `null`. `ctx.log({ reason })` records why. + +## Undoing a change + +Nothing reverts automatically. If one of your rewrites made things worse, return +`{"notes": "...", "revertTo": }` — the context lists every version, when it went in, and +what the agent was worth at the time. diff --git a/example/agents/trove-manager/agent.ts b/example/agents/trove-manager/agent.ts new file mode 100644 index 0000000..27cdf14 --- /dev/null +++ b/example/agents/trove-manager/agent.ts @@ -0,0 +1,326 @@ +/** + * trove-manager: the borrower side of the CDP venue (issue #39). + * + * Opening a Trove is easy. What the venue actually asks is whether you can hold one through a + * price path, and it asks it in three ways that no other venue here does: + * + * liquidation under a 110% ICR the Trove is taken: the collateral goes to the Stability Pool + * and you keep the eUSD. That is a real loss of the difference, and the price at + * which it happens is `liquidationPriceUsd` -- one number, in the observation. + * + * redemption anyone holding eUSD can exchange it for *your* collateral at the oracle price if + * you are the riskiest Trove in the list. It is not a loss (you get par for what + * they take) but it shrinks the position you chose, and it hits the bottom of the + * list first. `positionFromRiskiest` and `redeemedAheadEusdWei` say how exposed you + * are, and adding collateral moves you up. Defending that position is the skill the + * issue names as having no equivalent anywhere else in this simulator. + * + * Recovery Mode below a system-wide 150% TCR the threshold stops being 110%: a Trove is + * liquidatable once its ICR is under the *current TCR*, and only if the Stability + * Pool can absorb the whole debt (the seizure is capped at 110%, the rest is a + * claimable surplus). Nothing you did moves that line and nothing you do stops it, + * so the only response is to already be above it when it arrives. + * + * What it does with the eUSD it draws is one switch, `ERIS_TROVE_SPEND_DEBT`, and it is the switch + * that decides whether the other three matter. Held, the eUSD is the repayment that raises a failing + * ratio for free. Spent -- sold for USDC, which is what borrowing dollars against ETH actually means + * -- the only defence left is collateral, and a borrower who posted all of it has none. Default off, + * because the interesting comparison is between two agents that differ in exactly that. + */ +import type { + AgentAction, + AgentContext, + AgentObservation, + LiquityObservation, +} from "@eris/sdk"; + +// The ICR to open at. Well clear of MCR: at 110% a Trove is liquidatable the moment the price +// twitches, and the borrowing fee makes reopening expensive. +const TARGET_ICR = Number(process.env.ERIS_TROVE_TARGET_ICR ?? "2.0"); + +// Add collateral below this. Above MCR by a wide margin on purpose -- the oracle this venue reads +// is one block stale for everyone, so a Trove sitting just above the line is already past it. +const FLOOR_ICR = Number(process.env.ERIS_TROVE_FLOOR_ICR ?? "1.5"); + +// In Recovery Mode the whole system's floor rises to CCR, so the response is to clear CCR with +// room, not to creep to it. +const RECOVERY_MARGIN = Number(process.env.ERIS_TROVE_RECOVERY_MARGIN ?? "1.1"); + +// Defend the sorted-list position when this little debt sits between you and a redemption. Measured +// against the whole system's debt, because "how much can be redeemed before it reaches me" is only +// meaningful next to how much eUSD exists to redeem with. +const REDEMPTION_SHIELD_BPS = Number( + process.env.ERIS_TROVE_SHIELD_BPS ?? "200", +); + +// Share of the WETH balance to post as collateral when opening. The rest is kept for the top-ups +// the two defences above will want; an agent that posts everything can only watch. +const OPEN_FRACTION_BPS = Number(process.env.ERIS_TROVE_OPEN_BPS ?? "6000"); + +// What to do with the eUSD the Trove draws. Holding it is the conservative choice and the default: +// it is also the repayment that defends the position when the price falls. Spending it -- selling it +// for USDC, which is what borrowing dollars against ETH actually means -- is a real strategy and a +// materially different risk, because the only defence left is collateral the agent may not have. +const SPEND_DEBT = process.env.ERIS_TROVE_SPEND_DEBT === "1"; + +// Blocks before the end of the run to stop opening. A Trove opened at the last moment pays the +// borrowing fee and cannot be managed afterwards. +const CLOSING_BLOCKS = Number(process.env.ERIS_TROVE_CLOSING_BLOCKS ?? "10"); + +const MIN_TOPUP_WEI = 10n ** 16n; // 0.01 WETH: below this the gas costs more than the defence + +function minBI(a: bigint, b: bigint): bigint { + return a < b ? a : b; +} + +function fraction(amount: bigint, bps: number): bigint { + return (amount * BigInt(Math.max(0, Math.round(bps)))) / 10_000n; +} + +export type TroveDecision = + | { kind: "open"; collateralWei: bigint; debtEusdWei: bigint; icr: number } + | { kind: "spend"; amountEusdWei: bigint } + | { kind: "buyToClose"; usdcIn: bigint; shortfallEusdWei: bigint } + | { kind: "topUp"; collateralWei: bigint; reason: string } + | { kind: "repay"; amountEusdWei: bigint; reason: string } + | { kind: "close" } + | { kind: "hold"; reason: string }; + +/// Collateral needed to bring a Trove to `targetIcr` at the current price. +export function topUpForIcr(input: { + collWei: bigint; + debtEusdWei: bigint; + priceUsd: number; + targetIcr: number; +}): bigint { + if (input.priceUsd <= 0) return 0n; + const debt = Number(input.debtEusdWei) / 1e18; + const needCollEth = (debt * input.targetIcr) / input.priceUsd; + const haveCollEth = Number(input.collWei) / 1e18; + if (needCollEth <= haveCollEth) return 0n; + return BigInt(Math.ceil((needCollEth - haveCollEth) * 1e18)); +} + +/// The decision, separated from the observation plumbing so the risk rules can be reasoned about +/// (and tested) without a chain. +/// +/// Order is the risk order, not the profit order: being liquidated costs the most, being caught by +/// Recovery Mode costs the same for a reason you did not cause, and being redeemed against merely +/// resizes you. Anything that raises the ratio serves all three at once, which is why collateral +/// goes in before debt comes off -- repaying needs eUSD the agent may have already spent. +export function decideTrove(input: { + liquity: LiquityObservation; + wethWei: bigint; + usdcUnits: bigint; + blocksRemaining: number | undefined; +}): TroveDecision { + const l = input.liquity; + const trove = l.trove; + const late = + input.blocksRemaining !== undefined && + input.blocksRemaining <= CLOSING_BLOCKS; + + if (!trove || trove.status !== 1) { + if (late) + return { + kind: "hold", + reason: `${input.blocksRemaining} blocks left: a Trove opened now pays the borrowing fee and cannot be managed`, + }; + // Size the position off the collateral, then draw the debt the target ratio allows. + const collateralWei = minBI( + fraction(input.wethWei, OPEN_FRACTION_BPS), + input.wethWei, + ); + const collUsd = (Number(collateralWei) / 1e18) * l.priceUsd; + const debt = collUsd / TARGET_ICR; + const debtEusdWei = BigInt(Math.floor(debt * 1e18)); + const minNetDebt = BigInt(l.minNetDebtEusdWei); + if (debtEusdWei < minNetDebt) + return { + kind: "hold", + reason: `collateral supports ${debt.toFixed(0)} eUSD of debt, under the ${(Number(minNetDebt) / 1e18).toFixed(0)} minimum`, + }; + // Recovery Mode raises the bar for opening from MCR to CCR, and there is no reason to open into + // it at all: the system is already one price move from liquidating Troves that were fine. + if (l.recoveryMode) + return { + kind: "hold", + reason: "the system is in Recovery Mode; not opening into it", + }; + return { kind: "open", collateralWei, debtEusdWei, icr: TARGET_ICR }; + } + + const collWei = BigInt(trove.collWei); + const debtWei = BigInt(trove.debtEusdWei); + const topUpTo = (target: number): bigint => + topUpForIcr({ + collWei, + debtEusdWei: debtWei, + priceUsd: l.priceUsd, + targetIcr: target, + }); + + // 1. Recovery Mode: the floor is CCR for everyone, whatever your own ratio was a block ago. + if (l.recoveryMode && trove.icr < l.ccr * RECOVERY_MARGIN) { + const need = topUpTo(l.ccr * RECOVERY_MARGIN); + const size = minBI(need, input.wethWei); + if (size >= MIN_TOPUP_WEI) + return { + kind: "topUp", + collateralWei: size, + reason: `Recovery Mode: ICR ${trove.icr.toFixed(2)} is inside the CCR ${l.ccr} band that is liquidatable while it lasts`, + }; + // Out of collateral. Debt is the other side of the same ratio, and repaying needs only eUSD. + return { + kind: "repay", + amountEusdWei: BigInt(l.eusdBalanceWei), + reason: `Recovery Mode with no WETH left: repaying to raise ICR ${trove.icr.toFixed(2)}`, + }; + } + + // 2. Ordinary liquidation risk. + if (trove.icr < FLOOR_ICR) { + const need = topUpTo(TARGET_ICR); + const size = minBI(need, input.wethWei); + if (size >= MIN_TOPUP_WEI) + return { + kind: "topUp", + collateralWei: size, + reason: `ICR ${trove.icr.toFixed(2)} under the ${FLOOR_ICR} floor; liquidation at ${trove.liquidationPriceUsd.toFixed(0)} USD`, + }; + if (BigInt(l.eusdBalanceWei) > 0n) + return { + kind: "repay", + amountEusdWei: BigInt(l.eusdBalanceWei), + reason: `ICR ${trove.icr.toFixed(2)} under the floor and no WETH left; repaying instead`, + }; + } + + // 3. Redemption exposure: how much eUSD can be redeemed before the walk reaches this Trove. Being + // at the bottom of the list is not a loss, but it is the position getting resized by somebody + // else's trade -- and the fix is the same top-up that serves the two risks above. + if (trove.positionKnown && !l.recoveryMode) { + const systemDebt = BigInt(l.totalDebtEusdWei); + const shield = fraction(systemDebt, REDEMPTION_SHIELD_BPS); + if (BigInt(trove.redeemedAheadEusdWei) < shield) { + const need = topUpTo(Math.max(TARGET_ICR, trove.icr * 1.1)); + const size = minBI(need, input.wethWei); + if (size >= MIN_TOPUP_WEI) + return { + kind: "topUp", + collateralWei: size, + reason: `position ${trove.positionFromRiskiest} from the front of the redemption queue with only ${(Number(trove.redeemedAheadEusdWei) / 1e18).toFixed(0)} eUSD ahead`, + }; + } + } + + // 4. Spend the proceeds, if that is the strategy. After the defences above, never before: eUSD in + // hand is the cheapest way to raise a failing ratio, and selling it first is what turns a + // manageable Trove into a liquidation. + if (SPEND_DEBT && !late) { + const idle = BigInt(l.eusdBalanceWei); + if (idle >= 10n ** 20n) return { kind: "spend", amountEusdWei: idle }; + } + + // 5. End of the run: close if the eUSD to repay is in hand. Scoring marks an open Trove at + // collateral minus debt either way, so this is about not leaving a position nobody manages. + if (late) { + const held = BigInt(l.eusdBalanceWei); + const owed = BigInt(trove.netDebtEusdWei); + if (held >= owed) return { kind: "close" }; + // Drawing 4,000 eUSD books 4,020 of debt: the borrowing fee is added to what is owed and never + // handed over, so a borrower who kept every unit it drew is still short by the fee and can + // never close. Buying the difference is the only way out, and it is what closing actually costs. + const shortfall = owed - held; + if (l.marketQuoted && input.usdcUnits > 0n) { + // eUSD is 18-decimal and USDC is 6; near par they are interchangeable at 1e12, plus a little + // headroom for the pool's price and fee. + const needUsdc = (shortfall / 10n ** 12n) + (shortfall / 10n ** 14n); + const size = minBI(needUsdc, input.usdcUnits); + if (size > 0n) + return { kind: "buyToClose", usdcIn: size, shortfallEusdWei: shortfall }; + } + } + + return { + kind: "hold", + reason: `ICR ${trove.icr.toFixed(2)} (floor ${FLOOR_ICR}, liquidation at ${trove.liquidationPriceUsd.toFixed(0)} USD), ${trove.positionFromRiskiest} from the redemption queue`, + }; +} + +export function decide( + obs: AgentObservation, + ctx?: AgentContext, +): AgentAction | Record | null { + const liquity = obs.protocols.liquity; + if (!liquity) { + return { + type: "noop", + reason: "the liquity venue is not enabled this run", + }; + } + const wethWei = BigInt(obs.balances.wethWei || "0"); + const decision = decideTrove({ + liquity, + wethWei, + usdcUnits: BigInt(obs.balances.usdcUnits || "0"), + blocksRemaining: obs.blocksRemaining, + }); + const fee = obs.limits.defaultPriorityFeePerGasWei; + + ctx?.log({ + round: obs.round, + reason: decision.kind === "hold" ? decision.reason : decision.kind, + signals: { + icr: Number((liquity.trove?.icr ?? 0).toFixed(3)), + tcr: Number(liquity.tcr.toFixed(3)), + recoveryMode: liquity.recoveryMode ? 1 : 0, + liquidationPriceUsd: Math.round(liquity.trove?.liquidationPriceUsd ?? 0), + priceUsd: Math.round(liquity.priceUsd), + queuePosition: liquity.trove?.positionFromRiskiest ?? -1, + }, + }); + + switch (decision.kind) { + case "open": + return { + type: "liquityOpenTrove", + collateralWethWei: decision.collateralWei.toString(), + debtEusdWei: decision.debtEusdWei.toString(), + maxPriorityFeePerGasWei: fee, + }; + case "topUp": + return { + type: "liquityAdjustTrove", + addCollateralWethWei: decision.collateralWei.toString(), + maxPriorityFeePerGasWei: fee, + }; + case "repay": + return { + type: "liquityAdjustTrove", + debtChangeEusdWei: decision.amountEusdWei.toString(), + isDebtIncrease: false, + maxPriorityFeePerGasWei: fee, + }; + case "buyToClose": + return { + type: "liquitySwapEusd", + tokenIn: "USDC", + amountIn: decision.usdcIn.toString(), + slippageBps: 100, + maxPriorityFeePerGasWei: fee, + }; + case "spend": + return { + type: "liquitySwapEusd", + tokenIn: "EUSD", + amountIn: decision.amountEusdWei.toString(), + slippageBps: 100, + maxPriorityFeePerGasWei: fee, + }; + case "close": + return { type: "liquityCloseTrove", maxPriorityFeePerGasWei: fee }; + default: + return { type: "noop", reason: decision.reason }; + } +} diff --git a/example/agents/trove-manager/prompt.md b/example/agents/trove-manager/prompt.md new file mode 100644 index 0000000..883dee5 --- /dev/null +++ b/example/agents/trove-manager/prompt.md @@ -0,0 +1,61 @@ +--- +kind: improve +name: trove-manager +description: CDP borrower — open a Trove and hold it through the price path, against liquidation, redemption and Recovery Mode. +reviseEveryBlocks: 60 +--- + +You are maintaining the borrower side of a Liquity-style CDP. The strategy runs on every block +without you. + +Opening a Trove is one decision; holding one is the strategy. Three things can take it away, and +they are not the same risk: + +- **Liquidation.** Under a 110% ICR the Trove is seized: the collateral goes to the Stability Pool + and the borrower keeps the eUSD. `trove.liquidationPriceUsd` is the collateral price at which + that happens — one number, no derivation needed. +- **Redemption.** Anyone holding eUSD can exchange it for *this* Trove's collateral at the oracle + price, starting from the riskiest Trove in the list. It is not a loss (par is paid for what is + taken) but it resizes a position somebody else chose to shrink. + `trove.positionFromRiskiest` and `trove.redeemedAheadEusdWei` say how exposed the Trove is; more + collateral moves it up the list. +- **Recovery Mode.** Below a system-wide 150% TCR the threshold stops being 110%: a Trove becomes + liquidatable once its ICR is under the *current* TCR, and only if the Stability Pool can absorb + its whole debt. What is seized is capped at 110% of the debt and the rest is claimable, so it is + cheaper than an ordinary liquidation — but the line moves for everyone at once. `recoveryMode` + and `tcr` are visible before it arrives, and nothing the borrower does causes it or stops it. + +The oracle is one block stale for everyone, so a ratio that is barely above a threshold is +effectively already through it. + +## When to leave it alone + +Return `"executorTs": null` unless you can point at the problem. A run where the price never moved +much and the Trove sat untouched is a run that went correctly. + +## What is worth changing + +- **A target ratio that is wrong for the path.** Too thin and the top-ups never keep up; too thick + and the Trove borrows almost nothing and the position is pointless. The log records `icr`, `tcr` + and `liquidationPriceUsd` every block. +- **Defending too late.** Collateral added after the price has moved costs the same as collateral + added before, but only one of them works. +- **Spending the proceeds.** If `ERIS_TROVE_SPEND_DEBT` is on, the eUSD is gone and repayment is no + longer available as a defence. That is a legitimate strategy, but it has to be priced. +- **Ignoring the queue.** Sitting at the front of the redemption walk with almost no debt ahead is + a choice; making it by accident is not. + +## Constraints + +- Only `obs`, `ctx` and standard JavaScript. No `require`, `import`, `process` or `fetch`. +- Check balances before choosing a direction; a leg the runtime rejects scores like doing nothing. +- Collateral is native ETH and so is gas. Posting everything strands the agent with a position it + can no longer manage — `suggestedGasReserveWei` is what to keep back. +- Respect `obs.limits`. +- Return one action object or `null`. `ctx.log({ reason })` records why. + +## Undoing a change + +Nothing reverts automatically. If one of your rewrites made things worse, return +`{"notes": "...", "revertTo": }` — the context lists every version, when it went in, and +what the agent was worth at the time. diff --git a/example/agents/venue-arb/improve.md b/example/agents/venue-arb/prompt.md similarity index 99% rename from example/agents/venue-arb/improve.md rename to example/agents/venue-arb/prompt.md index 3145404..67a0921 100644 --- a/example/agents/venue-arb/improve.md +++ b/example/agents/venue-arb/prompt.md @@ -1,4 +1,5 @@ --- +kind: improve name: venue-arb description: WETH-only cross-venue arbitrage. The LLM tunes the strategy in-run; the strategy itself trades every block. reviseEveryBlocks: 60 diff --git a/example/package.json b/example/package.json index 8a9a39a..5041c7b 100644 --- a/example/package.json +++ b/example/package.json @@ -2,6 +2,7 @@ "name": "eris-example-agents", "version": "0.1.0", "private": true, + "license": "MIT", "type": "module", "description": "Participant template (ADR 0015). agents/ is the unit of copy/submission. Depends only on sdk (importing core is forbidden).", "dependencies": { diff --git a/package.json b/package.json index 1f80e44..15afb77 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "eris-competition", "version": "0.1.0", "private": true, + "license": "MIT", "type": "module", "workspaces": [ "sdk", diff --git a/scripts/genLocalConstants.ts b/scripts/genLocalConstants.ts index e99bb80..c05b749 100644 --- a/scripts/genLocalConstants.ts +++ b/scripts/genLocalConstants.ts @@ -44,6 +44,7 @@ type Deployments = { }; aaveV3?: Record; lst?: Record; + liquity?: Record; }; }; @@ -83,9 +84,71 @@ function readLst( ? { aaveAggregator: getAddress(String(lst.aaveAggregator)), aaveAToken: getAddress(String(lst.aaveAToken)), - aaveVariableDebtToken: getAddress( - String(lst.aaveVariableDebtToken), - ), + aaveVariableDebtToken: getAddress(String(lst.aaveVariableDebtToken)), + } + : {}), + }; +} + +// Issue #39: the Liquity venue. Local-only for the same reason as the LST vault, and a partial +// deploy may omit it, so the whole block is emitted only when the core addresses are present. +type LiquityInfo = { + troveManager: Address; + borrowerOperations: Address; + stabilityPool: Address; + sortedTroves: Address; + activePool: Address; + defaultPool: Address; + collSurplusPool: Address; + gasPool: Address; + hintHelpers: Address; + priceFeed: Address; + redemptionHelper?: Address; + eusd: Address; + lqtyToken: Address; + lqtyStaking: Address; + communityIssuance: Address; + eusdUsdcPool?: Address; + eusdIndex?: number; + usdcIndex?: number; + stable?: Address; +}; + +function readLiquity( + liquity: Record | undefined, + usdc: Address, +): LiquityInfo | undefined { + if (!liquity?.troveManager || !liquity?.eusd) return undefined; + const at = (key: string): Address => + getAddress(String(need(liquity[key], `liquity.${key}`))); + return { + troveManager: at("troveManager"), + borrowerOperations: at("borrowerOperations"), + stabilityPool: at("stabilityPool"), + sortedTroves: at("sortedTroves"), + activePool: at("activePool"), + defaultPool: at("defaultPool"), + collSurplusPool: at("collSurplusPool"), + gasPool: at("gasPool"), + hintHelpers: at("hintHelpers"), + priceFeed: at("priceFeed"), + // Absent on a deployment that predates the helper; the adapter fails fast rather than building + // a redemption whose hints cannot survive the next oracle write. + ...(liquity.redemptionHelper + ? { redemptionHelper: getAddress(String(liquity.redemptionHelper)) } + : {}), + eusd: at("eusd"), + lqtyToken: at("lqtyToken"), + lqtyStaking: at("lqtyStaking"), + communityIssuance: at("communityIssuance"), + // The market is a separate concern: a Trove and the Stability Pool work without one, and a + // deploy with no curve factory legitimately has none. + ...(liquity.eusdUsdcPool + ? { + eusdUsdcPool: getAddress(String(liquity.eusdUsdcPool)), + eusdIndex: Number(need(liquity.eusdIndex, "liquity.eusdIndex")), + usdcIndex: Number(need(liquity.usdcIndex, "liquity.usdcIndex")), + stable: usdc, } : {}), }; @@ -275,6 +338,7 @@ export function generateLocalConstants(deploymentsPath?: string): { }, wbtc: wbtcInfo, lst: readLst(p.lst), + liquity: readLiquity(p.liquity, usdc), }); const target = resolve(ROOT, "sdk", "src", "constants.local.ts"); @@ -296,6 +360,12 @@ export function generateLocalConstants(deploymentsPath?: string): { ? ` LST=${lstInfo.vault} (pool=${lstInfo.pool} apy=${lstInfo.targetApyBps}bps clock=${lstInfo.simulatedSecondsPerBlock}s/block)` : ` LST: none (the lst venue is unavailable in this deployment)`, ); + const liquityInfo = readLiquity(p.liquity, usdc); + console.log( + liquityInfo + ? ` LIQUITY=${liquityInfo.troveManager} (eUSD=${liquityInfo.eusd} market=${liquityInfo.eusdUsdcPool ?? "none"})` + : ` LIQUITY: none (the liquity venue is unavailable in this deployment)`, + ); console.log(` local run: set ERIS_LOCAL_DEPLOY=1 to use`); return { target, deploymentsPath: path, fingerprint }; } @@ -322,9 +392,45 @@ function render(d: { aave: Record; wbtc?: WbtcInfo; lst?: LstInfo; + liquity?: LiquityInfo; }): string { const a = (x: string) => `"${x}" as Address`; const w = d.wbtc; + const liq = d.liquity; + const liquityBlock = liq + ? ` + // Issue #39: Liquity V1 as the CDP stablecoin venue (eUSD). priceFeed is the adapter Liquity holds + // forever, which each run repoints at its own PriceFeed -- not the run's feed itself. + LIQUITY: { + troveManager: ${a(liq.troveManager)}, + borrowerOperations: ${a(liq.borrowerOperations)}, + stabilityPool: ${a(liq.stabilityPool)}, + sortedTroves: ${a(liq.sortedTroves)}, + activePool: ${a(liq.activePool)}, + defaultPool: ${a(liq.defaultPool)}, + collSurplusPool: ${a(liq.collSurplusPool)}, + gasPool: ${a(liq.gasPool)}, + hintHelpers: ${a(liq.hintHelpers)}, + priceFeed: ${a(liq.priceFeed)},${ + liq.redemptionHelper + ? ` + redemptionHelper: ${a(liq.redemptionHelper)},` + : "" + } + eusd: ${a(liq.eusd)}, + lqtyToken: ${a(liq.lqtyToken)}, + lqtyStaking: ${a(liq.lqtyStaking)}, + communityIssuance: ${a(liq.communityIssuance)},${ + liq.eusdUsdcPool + ? ` + eusdUsdcPool: ${a(liq.eusdUsdcPool)}, + eusdIndex: ${liq.eusdIndex}, + usdcIndex: ${liq.usdcIndex}, + stable: ${a(liq.stable!)},` + : "" + } + },` + : ""; const lstBlock = d.lst ? ` // Issue #38: the LST venue (wstETH-style vault + LST/WETH stableswap-ng secondary market). @@ -456,6 +562,28 @@ export type LocalDeployment = { aaveAToken?: Address; aaveVariableDebtToken?: Address; }; + // Issue #39: present only when the deploy included the liquity venue. + LIQUITY?: { + troveManager: Address; + borrowerOperations: Address; + stabilityPool: Address; + sortedTroves: Address; + activePool: Address; + defaultPool: Address; + collSurplusPool: Address; + gasPool: Address; + hintHelpers: Address; + priceFeed: Address; + redemptionHelper?: Address; + eusd: Address; + lqtyToken: Address; + lqtyStaking: Address; + communityIssuance: Address; + eusdUsdcPool?: Address; + eusdIndex?: number; + usdcIndex?: number; + stable?: Address; + }; }; export const LOCAL_DEPLOYMENT: LocalDeployment | null = { @@ -519,7 +647,7 @@ export const LOCAL_DEPLOYMENT: LocalDeployment | null = { AclAdmin: ${a(d.aave.AclAdmin)}, AclManager: ${a(d.aave.AclManager)}, PoolDataProvider: ${a(d.aave.PoolDataProvider)}, - },${lstBlock}${marketLegs} + },${lstBlock}${liquityBlock}${marketLegs} }; `; } diff --git a/sdk/package.json b/sdk/package.json index 8c332fd..49bd4f7 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -2,6 +2,7 @@ "name": "@eris/sdk", "version": "0.1.0", "private": true, + "license": "MIT", "type": "module", "description": "Contract layer for eris-competition (ADR 0015). Types, action schema, chain IO, and protocol adapters that both the environment (core) and participants (example) depend on.", "exports": { diff --git a/sdk/src/abis.ts b/sdk/src/abis.ts index 2eca264..ef61779 100644 --- a/sdk/src/abis.ts +++ b/sdk/src/abis.ts @@ -97,6 +97,10 @@ export const curveTwocryptoLiquidityAbi = parseAbi([ // stableswap and the crypto pools above disagree on that, so they cannot share an ABI. export const curveStableSwapNgAbi = parseAbi([ "function get_dy(int128 i, int128 j, uint256 dx) view returns (uint256)", + // The mirror of get_dy: how much of coin i it takes to obtain dy of coin j. Buying back a Trove's + // eUSD debt is quoted with this (issue #39) -- get_dy would answer a different question and + // marking the liability off it would understate what closing the position costs. + "function get_dx(int128 i, int128 j, uint256 dy) view returns (uint256)", "function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) returns (uint256)", "function coins(uint256 i) view returns (address)", "function balances(uint256 i) view returns (uint256)", @@ -150,6 +154,118 @@ export const lstVaultAbi = parseAbi([ "function surplusWeth() view returns (uint256)", ]); +// --------------------------------------------------------------------------- +// Liquity V1, the CDP stablecoin venue issuing eUSD (issue #39). +// +// The core is forked unmodified, so these are Liquity's own signatures -- including the LUSD names +// the source uses for what this venue calls eUSD. Renaming them here would make the ABI stop +// matching the deployed selectors. +// --------------------------------------------------------------------------- + +export const troveManagerAbi = parseAbi([ + // System state. Every ratio view takes the price explicitly rather than reading the oracle, which + // is what lets the observation and the scorer evaluate a *historical* block at that block's price. + "function getTCR(uint256 price) view returns (uint256)", + "function checkRecoveryMode(uint256 price) view returns (bool)", + "function getEntireSystemColl() view returns (uint256)", + "function getEntireSystemDebt() view returns (uint256)", + "function MCR() view returns (uint256)", + "function CCR() view returns (uint256)", + "function MIN_NET_DEBT() view returns (uint256)", + // The 200 eUSD held in the GasPool for the duration of a Trove. It is part of the Trove's debt but + // not of what the borrower has to repay, so a valuation that ignores it understates every Trove. + "function LUSD_GAS_COMPENSATION() view returns (uint256)", + // Per-Trove state. getEntireDebtAndColl includes pending redistribution rewards, which is what the + // system will actually charge, so it is the one to value against. + "function getEntireDebtAndColl(address borrower) view returns (uint256 debt, uint256 coll, uint256 pendingLUSDDebtReward, uint256 pendingETHReward)", + "function getCurrentICR(address borrower, uint256 price) view returns (uint256)", + "function getNominalICR(address borrower) view returns (uint256)", + "function getTroveStatus(address borrower) view returns (uint256)", + "function getTroveOwnersCount() view returns (uint256)", + "function getTroveFromTroveOwnersArray(uint256 index) view returns (address)", + // Both fee curves. They decay on a ~12h half-life, so within a run the first large redemption + // raises the cost for everyone who follows -- which is the timing decision the venue adds. + "function getBorrowingRateWithDecay() view returns (uint256)", + "function getRedemptionRateWithDecay() view returns (uint256)", + "function getBorrowingFeeWithDecay(uint256 debt) view returns (uint256)", + "function getRedemptionFeeWithDecay(uint256 ethDrawn) view returns (uint256)", + "function baseRate() view returns (uint256)", + "function BORROWING_FEE_FLOOR() view returns (uint256)", + "function REDEMPTION_FEE_FLOOR() view returns (uint256)", + // Ground truth for what the venue actually did, rather than what the block state implies. A Trove + // count that fell could be a close, a full redemption or a liquidation, and issue #39's open + // question about ordering can only be answered by counting the real ones. + "event TroveLiquidated(address indexed _borrower, uint256 _debt, uint256 _coll, uint8 _operation)", + "event Liquidation(uint256 _liquidatedDebt, uint256 _liquidatedColl, uint256 _collGasCompensation, uint256 _LUSDGasCompensation)", + "event Redemption(uint256 _attemptedLUSDAmount, uint256 _actualLUSDAmount, uint256 _ETHSent, uint256 _ETHFee)", + // Actions. + "function redeemCollateral(uint256 LUSDAmount, address firstRedemptionHint, address upperPartialRedemptionHint, address lowerPartialRedemptionHint, uint256 partialRedemptionHintNICR, uint256 maxIterations, uint256 maxFeePercentage)", + "function liquidate(address borrower)", + "function batchLiquidateTroves(address[] troveArray)", + "function liquidateTroves(uint256 n)", +]); + +export const borrowerOperationsAbi = parseAbi([ + // Collateral is native ETH (msg.value), which is why the adapter unwraps WETH first. + "function openTrove(uint256 maxFeePercentage, uint256 LUSDAmount, address upperHint, address lowerHint) payable", + "function addColl(address upperHint, address lowerHint) payable", + "function withdrawColl(uint256 amount, address upperHint, address lowerHint)", + "function withdrawLUSD(uint256 maxFeePercentage, uint256 amount, address upperHint, address lowerHint)", + "function repayLUSD(uint256 amount, address upperHint, address lowerHint)", + "function adjustTrove(uint256 maxFeePercentage, uint256 collWithdrawal, uint256 LUSDChange, bool isDebtIncrease, address upperHint, address lowerHint) payable", + "function closeTrove()", + "function claimCollateral()", + // Net debt plus the gas compensation, i.e. what a requested borrow actually books as debt. + "function getCompositeDebt(uint256 debt) pure returns (uint256)", +]); + +export const stabilityPoolAbi = parseAbi([ + "function provideToSP(uint256 amount, address frontEndTag)", + "function withdrawFromSP(uint256 amount)", + "function getCompoundedLUSDDeposit(address depositor) view returns (uint256)", + "function getDepositorETHGain(address depositor) view returns (uint256)", + "function getDepositorLQTYGain(address depositor) view returns (uint256)", + "function getTotalLUSDDeposits() view returns (uint256)", + "function getETH() view returns (uint256)", +]); + +export const sortedTrovesAbi = parseAbi([ + // Sorted by nominal ICR, descending: the head is the safest Trove and the tail is the one a + // redemption reaches first. + "function getFirst() view returns (address)", + "function getLast() view returns (address)", + "function getNext(address id) view returns (address)", + "function getPrev(address id) view returns (address)", + "function getSize() view returns (uint256)", + "function contains(address id) view returns (bool)", + "function findInsertPosition(uint256 NICR, address prevId, address nextId) view returns (address, address)", +]); + +// HintHelpers deliberately has no ABI here. A redemption's hints cannot be computed off chain in +// this environment -- they depend on the price the transaction itself fetches, and the oracle moves +// every block ahead of every agent -- so they are computed on chain by LiquityRedemptionHelper +// instead. An ABI here would invite exactly the stale-hint call that reverted every redemption in +// the venue's first live run. + +// The oracle Liquity holds forever (deployer/contracts/LiquityPriceFeedAdapter.sol). Each run points +// it at the PriceFeed it just deployed. `fetchPrice` is state-changing (it caches the last good +// price), but simulating it is the only way to ask what the venue would actually serve right now -- +// which is what the startup check compares against the run's fair price. +export const liquityPriceFeedAdapterAbi = parseAbi([ + "function operator() view returns (address)", + "function source() view returns (address)", + "function lastGoodPrice() view returns (uint256)", + "function setSource(address source)", + "function fetchPrice() returns (uint256)", +]); + +// LiquityRedemptionHelper (deployer/contracts/LiquityRedemptionHelper.sol). Liquity checks a partial +// redemption against a hint derived from the price the transaction itself fetches, so the hints have +// to be computed on chain: this environment writes a new price every block, ahead of every agent. +export const liquityRedemptionHelperAbi = parseAbi([ + "function redeem(uint256 amount, uint256 maxFeePercentage, uint256 maxIterations) returns (uint256 redeemed, uint256 ethOut)", +]); + // Curve CryptoSwap (tricrypto v0.2.x): exchange / get_dy / coins / balances export const curveTricryptoAbi = parseAbi([ "function get_dy(uint256 i, uint256 j, uint256 dx) view returns (uint256)", diff --git a/sdk/src/actionSchema.ts b/sdk/src/actionSchema.ts index 9bbddf8..2eb54e2 100644 --- a/sdk/src/actionSchema.ts +++ b/sdk/src/actionSchema.ts @@ -193,6 +193,118 @@ export const lstClaimWithdrawSchema = z.object({ ...priorityFee, }); +// Liquity venue (issue #39). Collateral is denominated in WETH wei even though the protocol takes +// native ETH -- the adapter unwraps -- so there is no `base` selector and no ETH/WETH choice to make. +const liquityMaxFeeBps = z + .number() + .int() + .positive() + .max(10_000) + .optional() + .describe( + "slippage bound on the protocol fee, in bps. Both fee curves rise with use, so a bound that is too tight reverts. Default 500 (5%).", + ); + +export const liquityOpenTroveSchema = z.object({ + type: z.literal("liquityOpenTrove"), + collateralWethWei: decimalString.describe( + "WETH to unwrap and post as collateral (wei). It also has to leave enough native ETH behind to pay for gas.", + ), + debtEusdWei: decimalString.describe( + "eUSD to draw (wei). Booked debt is this plus the borrowing fee plus 200 eUSD of gas compensation, and the total must clear MIN_NET_DEBT (1,800 eUSD).", + ), + maxFeeBps: liquityMaxFeeBps, + ...priorityFee, +}); + +export const liquityAdjustTroveSchema = z.object({ + type: z.literal("liquityAdjustTrove"), + addCollateralWethWei: decimalString + .optional() + .describe("WETH to unwrap and add as collateral (wei)."), + withdrawCollateralWei: decimalString + .optional() + .describe("collateral to take back out, paid in native ETH (wei)."), + debtChangeEusdWei: decimalString + .optional() + .describe("eUSD to draw or repay (wei); set isDebtIncrease to say which."), + isDebtIncrease: z + .boolean() + .optional() + .describe( + "true draws more eUSD, false repays. Required with a debt change.", + ), + maxFeeBps: liquityMaxFeeBps, + ...priorityFee, +}); + +export const liquityCloseTroveSchema = z.object({ + type: z.literal("liquityCloseTrove"), + ...priorityFee, +}); + +export const liquityRedeemSchema = z.object({ + type: z.literal("liquityRedeem"), + amountEusdWei: decimalString.describe( + "eUSD to redeem for collateral at the oracle price (wei). Worth doing when eUSD trades below par by more than redemptionRateBps.", + ), + maxIterations: z + .number() + .int() + .nonnegative() + .optional() + .describe("cap on how many Troves the redemption walks. 0 = no cap."), + maxFeeBps: liquityMaxFeeBps, + ...priorityFee, +}); + +export const liquityProvideToSpSchema = z.object({ + type: z.literal("liquityProvideToSP"), + amountEusdWei: decimalString.describe( + "eUSD to deposit into the Stability Pool (wei). It absorbs liquidated debt and pays out the collateral at a discount.", + ), + ...priorityFee, +}); + +export const liquityWithdrawFromSpSchema = z.object({ + type: z.literal("liquityWithdrawFromSP"), + amountEusdWei: decimalOrMax.describe( + 'eUSD to withdraw (wei), or "max". "0" claims the accrued ETH gain without touching the deposit.', + ), + ...priorityFee, +}); + +export const liquityLiquidateSchema = z.object({ + type: z.literal("liquityLiquidate"), + borrowers: z + .array(hexString) + .optional() + .describe("specific Trove owners to liquidate."), + maxTroves: z + .number() + .int() + .positive() + .optional() + .describe( + "instead of naming owners, sweep this many of the riskiest Troves.", + ), + ...priorityFee, +}); + +export const liquitySwapEusdSchema = z.object({ + type: z.literal("liquitySwapEusd"), + tokenIn: z + .enum(["USDC", "EUSD"]) + .describe( + 'the token you are sending: "USDC" buys eUSD from the pool, "EUSD" sells into it.', + ), + amountIn: decimalString.describe( + "units of tokenIn (USDC is 6-decimal, eUSD is 18-decimal)", + ), + slippageBps: z.number().int().nonnegative().optional(), + ...priorityFee, +}); + const rawTxSchema = z.object({ to: hexString, data: hexString, @@ -235,6 +347,16 @@ const LEAF_SCHEMAS_BY_PROTOCOL: Record = { lstRequestWithdrawSchema, lstClaimWithdrawSchema, ], + liquity: [ + liquityOpenTroveSchema, + liquityAdjustTroveSchema, + liquityCloseTroveSchema, + liquityRedeemSchema, + liquityProvideToSpSchema, + liquityWithdrawFromSpSchema, + liquityLiquidateSchema, + liquitySwapEusdSchema, + ], }; // GMX cannot be bundled because it requires keeper execution (same rule as bundleable in action.ts). diff --git a/sdk/src/agent.ts b/sdk/src/agent.ts index ed95440..ebe600a 100644 --- a/sdk/src/agent.ts +++ b/sdk/src/agent.ts @@ -2,7 +2,7 @@ // The agent.ts in example/agents// exports one of: // - decide(obs, ctx): rule strategy. runtime/bot.ts drives it in a read→decide→send loop // - run(ctx): self-driven (liquidator etc.). bot.ts does not loop and delegates by passing ctx -// A self-improving agent (agent.ts + improve.md) exports decide like any rule agent; the LLM swaps +// A self-improving agent (agent.ts + prompt.md) exports decide like any rule agent; the LLM swaps // that function out of band rather than producing actions itself (ADR 0018). import type { Address, PublicClient, WalletClient } from "viem"; import type { SimConfig } from "./config.js"; diff --git a/sdk/src/config.ts b/sdk/src/config.ts index 065a9ba..2e3f481 100644 --- a/sdk/src/config.ts +++ b/sdk/src/config.ts @@ -14,8 +14,9 @@ import type { ProtocolId } from "./types.js"; import type { OuParams } from "./rng.js"; import { baseTokens } from "./markets.js"; -// The lst venue is deliberately not in the default set: it exists only under local deploy (issue -// #38), so defaulting it on would break every fork run. Enable it explicitly via run.protocols. +// The lst and liquity venues are deliberately not in the default set: they exist only under local +// deploy (issues #38 / #39), so defaulting either on would break every fork run. Enable them +// explicitly via run.protocols. const ALL_PROTOCOLS: ProtocolId[] = [ "uniswap", "balancer", @@ -25,7 +26,7 @@ const ALL_PROTOCOLS: ProtocolId[] = [ ]; // Protocols that can be named in run.protocols but are not in the default set. -const OPT_IN_PROTOCOLS: ProtocolId[] = ["lst"]; +const OPT_IN_PROTOCOLS: ProtocolId[] = ["lst", "liquity"]; export type OuConfig = { global: OuParams; diff --git a/sdk/src/constants.local.ts b/sdk/src/constants.local.ts index a3c7f60..cc3340d 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:b7d41bba15bd2c1ce520982367e50e6f30381de94390fb4033fda3ce26633d5a"; +export const DEPLOYMENTS_FINGERPRINT = "sha256:c6bce1b47747bd3425512f1eff91693e69348db3fedd2566f9a4871312990f7d"; export type LocalDeployment = { CHAIN_ID: number; @@ -63,6 +63,28 @@ export type LocalDeployment = { aaveAToken?: Address; aaveVariableDebtToken?: Address; }; + // Issue #39: present only when the deploy included the liquity venue. + LIQUITY?: { + troveManager: Address; + borrowerOperations: Address; + stabilityPool: Address; + sortedTroves: Address; + activePool: Address; + defaultPool: Address; + collSurplusPool: Address; + gasPool: Address; + hintHelpers: Address; + priceFeed: Address; + redemptionHelper?: Address; + eusd: Address; + lqtyToken: Address; + lqtyStaking: Address; + communityIssuance: Address; + eusdUsdcPool?: Address; + eusdIndex?: number; + usdcIndex?: number; + stable?: Address; + }; }; export const LOCAL_DEPLOYMENT: LocalDeployment | null = { @@ -107,19 +129,19 @@ export const LOCAL_DEPLOYMENT: LocalDeployment | null = { usdcToken: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" as Address, }, GMX: { - RoleStore: "0xB06c856C8eaBd1d8321b687E188204C1018BC4E5" as Address, - DataStore: "0x71089Ba41e478702e1904692385Be3972B2cBf9e" as Address, - Oracle: "0xD6b040736e948621c5b6E0a494473c47a6113eA8" as Address, - EventEmitter: "0xf090f16dEc8b6D24082Edd25B1C8D26f2bC86128" as Address, - Router: "0x3904b8f5b0F49cD206b7d5AABeE5D1F37eE15D8d" as Address, - ExchangeRouter: "0x9C85258d9A00C01d00ded98065ea3840dF06f09c" as Address, - OrderHandler: "0x0Dd99d9f56A14E9D53b2DdC62D9f0bAbe806647A" as Address, - OrderVault: "0x8fC8CFB7f7362E44E472c690A6e025B80E406458" as Address, - LiquidationHandler: "0xF5b81Fe0B6F378f9E6A3fb6A6cD1921FCeA11799" as Address, - Reader: "0x6B21b3ae41f818Fc91e322b53f8D0773d31eCB75" as Address, - Config: "0x071586BA1b380B00B793Cc336fe01106B0BFbE6D" as Address, + RoleStore: "0x56fC17a65ccFEC6B7ad0aDe9BD9416CB365B9BE8" as Address, + DataStore: "0x51C65cd0Cdb1A8A8b79dfc2eE965B1bA0bb8fc89" as Address, + Oracle: "0x6A59CC73e334b018C9922793d96Df84B538E6fD5" as Address, + EventEmitter: "0x114e375B6FCC6d6fCb68c7A1d407E652C54F25FB" as Address, + Router: "0x967AB65ef14c58bD4DcfFeaAA1ADb40a022140E5" as Address, + ExchangeRouter: "0xB468647B04bF657C9ee2de65252037d781eABafD" as Address, + OrderHandler: "0xb868Cc77A95a65F42611724AF05Aa2d3B6Ec05F2" as Address, + OrderVault: "0x193521C8934bCF3473453AF4321911E7A89E0E12" as Address, + LiquidationHandler: "0x696358bBb1a743052E0E87BeD78AAd9d18f0e1F4" as Address, + Reader: "0xa195ACcEB1945163160CD5703Ed43E4f78176a54" as Address, + Config: "0x8D81A3DCd17030cD5F23Ac7370e4Efb10D2b3cA4" as Address, }, - GMX_MARKETS: { ETH_USD: "0xA5ecC14E7c21e0E4Fc9B41092F3db87d7B3c9865" as Address }, + GMX_MARKETS: { ETH_USD: "0xc89606Fce5eDcE056a5FC7A247e0fEA5f2a65168" as Address }, AAVE: { PoolAddressesProvider: "0xB0D4afd8879eD9F52b28595d31B441D079B2Ca07" as Address, Pool: "0x7B6fCB97Fc1B74e16CBe577054a4426d3487837C" as Address, @@ -143,6 +165,29 @@ export const LOCAL_DEPLOYMENT: LocalDeployment | null = { aaveAToken: "0xfB2C19FF34F419a02e4564e8Ce6A3448fAD93f8b" as Address, aaveVariableDebtToken: "0x555a114B12884781a975Fa45bDb4d3cC9ba1d640" as Address, }, + // Issue #39: Liquity V1 as the CDP stablecoin venue (eUSD). priceFeed is the adapter Liquity holds + // forever, which each run repoints at its own PriceFeed -- not the run's feed itself. + LIQUITY: { + troveManager: "0xaB7B4c595d3cE8C85e16DA86630f2fc223B05057" as Address, + borrowerOperations: "0x821f3361D454cc98b7555221A06Be563a7E2E0A6" as Address, + stabilityPool: "0x045857BDEAE7C1c7252d611eB24eB55564198b4C" as Address, + sortedTroves: "0xB06c856C8eaBd1d8321b687E188204C1018BC4E5" as Address, + activePool: "0xAD523115cd35a8d4E60B3C0953E0E0ac10418309" as Address, + defaultPool: "0x413b1AfCa96a3df5A686d8BFBF93d30688a7f7D9" as Address, + collSurplusPool: "0x02df3a3F960393F5B349E40A599FEda91a7cc1A7" as Address, + gasPool: "0x2b5A4e5493d4a54E717057B127cf0C000C876f9B" as Address, + hintHelpers: "0x1780bCf4103D3F501463AD3414c7f4b654bb7aFd" as Address, + priceFeed: "0x12Bcb546bC60fF39F1Adfc7cE4605d5Bd6a6A876" as Address, + redemptionHelper: "0xe039608E695D21aB11675EBBA00261A0e750526c" as Address, + eusd: "0xeF31027350Be2c7439C1b0BE022d49421488b72C" as Address, + lqtyToken: "0xC66AB83418C20A65C3f8e83B3d11c8C3a6097b6F" as Address, + lqtyStaking: "0x71089Ba41e478702e1904692385Be3972B2cBf9e" as Address, + communityIssuance: "0x5133BBdfCCa3Eb4F739D599ee4eC45cBCD0E16c5" as Address, + eusdUsdcPool: "0x4Bf20508953919be9691522ee74a7677c1a0a932" as Address, + eusdIndex: 0, + usdcIndex: 1, + stable: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" as Address, + }, MARKET_LEGS: { uniswap: { WETH: { pool: "0xe35086d02782CEC7D20b1a164dE141aa39CEe723" as Address, fee: 3000, tickSpacing: 60 }, @@ -157,8 +202,8 @@ export const LOCAL_DEPLOYMENT: LocalDeployment | null = { WBTC: { pool: "0xF2AdAad89d56D49C697B9907C7D66ef27d96f859" as Address, baseIndex: 1, quoteIndex: 0, stable: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" as Address }, }, gmx: { - WETH: { market: "0xA5ecC14E7c21e0E4Fc9B41092F3db87d7B3c9865" as Address }, - WBTC: { market: "0x2ffb9D923da1736ad51739B857cEf3a56EFD5f47" as Address }, + WETH: { market: "0xc89606Fce5eDcE056a5FC7A247e0fEA5f2a65168" as Address }, + WBTC: { market: "0x444836B743C4f79386C13Ce133dd8814ADfE2A1d" as Address }, }, aave: { WETH: {}, diff --git a/sdk/src/constants.ts b/sdk/src/constants.ts index df63de7..5d9211f 100644 --- a/sdk/src/constants.ts +++ b/sdk/src/constants.ts @@ -202,6 +202,102 @@ export function requireLst(): LstDeployment { return LST; } +// --------------------------------------------------------------------------- +// Liquity V1 as the CDP stablecoin venue, issuing eUSD (issue #39). +// +// Like the LST vault this has no Arbitrum counterpart to point at -- the core is deployed by the +// bundled deployer and baked into the ADR 0016 state dump -- so it is null on a fork and a run that +// enables the liquity protocol without local deploy fails fast rather than reading zeros. +// +// eUSD is deliberately absent from TOKENS. Registering it as a stable would price it at $1 in the +// scorer's spot sweep, which is exactly the phantom value a depegged CDP stablecoin must not be +// credited with; the adapter marks it at what the market would pay instead (see TokenKind). +// --------------------------------------------------------------------------- +export type LiquityDeployment = { + troveManager: Address; + borrowerOperations: Address; + stabilityPool: Address; + sortedTroves: Address; + activePool: Address; + defaultPool: Address; + collSurplusPool: Address; + gasPool: Address; + hintHelpers: Address; + // Computes a redemption's hints inside the transaction that uses them. Liquity checks a partial + // redemption against a hint derived from the execution price, and this environment moves the + // oracle every block, so an off-chain hint is stale before it lands (see the contract's notes). + // Absent on a deployment that predates it, in which case redemption is unavailable rather than + // silently reverting. + redemptionHelper?: Address; + // The LiquityPriceFeedAdapter, not the run's PriceFeed: Liquity renounces ownership, so the + // address baked into TroveManager is permanent and each run repoints this one at its own feed. + priceFeed: Address; + eusd: Address; + lqtyToken: Address; + lqtyStaking: Address; + communityIssuance: Address; + // The eUSD/USDC stableswap-ng market. Absent when the deploy had no curve factory to host it, in + // which case the venue still works and only the peg has nowhere to trade. + eusdUsdcPool?: Address; + eusdIndex?: number; + usdcIndex?: number; + // The pool's stable leg, which is the run's USDC. + stable?: Address; +}; + +export const LIQUITY: LiquityDeployment | null = L?.LIQUITY ?? null; + +export function requireLiquity(): LiquityDeployment { + if (!LIQUITY) + throw new Error( + "the liquity venue is not deployed in this environment: it exists only under local deploy " + + "(issue #39). Drop liquity from run.protocols to use a fork.", + ); + return LIQUITY; +} + +// The redemption helper, or a fail-fast naming what to do about it. Redemption is the venue's +// headline trade, so "the deployment has no helper" must not read as "the redemption failed". +export function requireRedemptionHelper(): Address { + const l = requireLiquity(); + if (!l.redemptionHelper) { + throw new Error( + "this liquity deployment has no LiquityRedemptionHelper: redemptions need their hints " + + "computed at execution (the oracle moves every block), so redeem it through a deployment " + + "that includes it — redeploy with `cd deployer && npm run deploy -- --keep-fresh`.", + ); + } + return l.redemptionHelper; +} + +// The eUSD market, or a fail-fast when the deploy produced none. Kept separate from the deployment +// because a Trove and the Stability Pool work without a market -- only the peg needs one. +export function requireEusdMarket(): { + pool: Address; + eusdIndex: number; + usdcIndex: number; + stable: Address; +} { + const l = requireLiquity(); + if ( + !l.eusdUsdcPool || + l.eusdIndex === undefined || + l.usdcIndex === undefined || + !l.stable + ) { + throw new Error( + "the liquity deployment has no eUSD/USDC market: the deploy ran without a curve factory to " + + "host it (deployer/src/protocols/liquity.ts seedEusdPool). Redeploy with curve enabled.", + ); + } + return { + pool: l.eusdUsdcPool, + eusdIndex: l.eusdIndex, + usdcIndex: l.usdcIndex, + stable: l.stable, + }; +} + // --------------------------------------------------------------------------- // Market leg registry (ADR 0013). Venue-specific leg per protocol × base. // The fork default is WETH/USDC only (built from the existing venue constants). Under local-deploy, diff --git a/sdk/src/protocols/liquity.ts b/sdk/src/protocols/liquity.ts new file mode 100644 index 0000000..64cf8de --- /dev/null +++ b/sdk/src/protocols/liquity.ts @@ -0,0 +1,1670 @@ +// Liquity V1 as the CDP stablecoin venue, issuing eUSD (issue #39). +// +// The core is forked unmodified, so what this adapter exposes is Liquity's own game: +// +// Troves borrow eUSD against native ETH at a minimum 110% collateral ratio, paying a +// one-off borrowing fee that rises with use +// redemption anyone holding eUSD may exchange it for collateral at the oracle price, always +// starting from the riskiest Trove. When eUSD trades below $1 that is a clean +// cross-venue α: buy the discount, redeem at par, pay the redemption fee +// Stability Pool deposit eUSD to absorb liquidated debt and receive the collateral at a discount +// Recovery Mode below a system-wide 150% TCR (CCR) the liquidation threshold stops being a +// constant: a Trove is liquidatable once its ICR is under the *current TCR*, and +// the seizure is capped at 110% of the debt with the surplus claimable. So the +// line moves for everyone at the same time -- a reflexive crash rather than +// Aave's per-position health factor +// +// Two things about the venue shape the code here more than anything else. +// +// *Collateral is native ETH.* Liquity takes it as `msg.value` and pays it back the same way. +// Converting the system to WETH would touch every pool and every payout path, so the adapter absorbs +// it instead: every collateral amount in an action is denominated in WETH wei and `buildTxs` emits +// `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. +import { + encodeFunctionData, + formatUnits, + maxUint256, + type Address, + type PublicClient, +} from "viem"; +import { + borrowerOperationsAbi, + curveStableSwapNgAbi, + erc20Abi, + liquityRedemptionHelperAbi, + sortedTrovesAbi, + stabilityPoolAbi, + troveManagerAbi, + wethAbi, +} from "../abis.js"; +import { + LIQUITY, + requireEusdMarket, + requireLiquity, + requireRedemptionHelper, + TOKENS, + type LiquityDeployment, +} from "../constants.js"; +import type { + AgentObservation, + BalanceSnapshot, + LeafAction, + LiquityAdjustTroveAction, + LiquityCloseTroveAction, + LiquityLiquidateAction, + LiquityObservation, + LiquityOpenTroveAction, + LiquityProvideToSpAction, + LiquityRedeemAction, + LiquitySwapEusdAction, + LiquityTroveObservation, + LiquityWithdrawFromSpAction, +} from "../types.js"; +import type { + AgentProtocolValue, + BuiltTx, + ProtocolAdapter, + SimContext, + UnpricedHoldingDetail, + ValidationResult, + ValuationContext, + ValuationRead, + ValuationRun, +} from "./types.js"; +import { approveTx } from "./uniswap.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; +const DEFAULT_SLIPPAGE_BPS = 50; + +// How much native ETH to suggest keeping back for gas. Collateral comes out of the same balance, so +// an agent that posts everything can no longer send the transaction that would close the position. +// A suggestion, not a rule: self-stranding is a legitimate way to lose (issue #39). +const SUGGESTED_GAS_RESERVE_WEI = WAD / 20n; // 0.05 ETH + +// Troves read per block to establish the redemption order. The sorted list is unbounded by design, +// but a roster is not: past this the position walk is reported as unknown rather than turning one +// observation into hundreds of reads. +const MAX_TROVE_SCAN = 64; + +// A ratio Liquity reports as "no debt" (uint256 max). Reported as this instead, because JSON has no +// infinity and a zero here would read as "totally undercollateralized" -- the opposite of the truth. +const NO_DEBT_RATIO = 1e6; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +// One Trove, in the order a redemption walks the list (riskiest first). +export type LiquityTroveRow = { + owner: Address; + collWei: bigint; + // Everything the system books, gas compensation and pending redistribution included. + debtEusdWei: bigint; + // What the owner would actually repay to close: debt minus the gas compensation. + netDebtEusdWei: bigint; + icr: number; +}; + +export type LiquityState = { + deployment: LiquityDeployment; + market: { + pool: Address; + eusdIndex: number; + usdcIndex: number; + stable: Address; + } | null; + // The collateral price the venue marks against. It is the run's fair price, which is also what the + // oracle adapter serves from the environment's PriceFeed -- so the ratios computed here are the + // ones the chain would compute in the same block. + priceUsd: number; + priceRaw: bigint; + tcr: number; + recoveryMode: boolean; + mcr: number; + ccr: number; + minNetDebtEusdWei: bigint; + gasCompensationEusdWei: bigint; + troveCount: number; + totalCollWei: bigint; + totalDebtEusdWei: bigint; + borrowingRateBps: number; + redemptionRateBps: number; + baseRateBps: number; + spTotalDepositsEusdWei: bigint; + // Ascending ICR: index 0 is the Trove a redemption reaches first. + troves: LiquityTroveRow[]; + // False when the list is longer than MAX_TROVE_SCAN, i.e. `troves` is not the whole ordering. + troveOrderKnown: boolean; + // Executable prices in USDC per eUSD, fee and impact included at probe size. + sellPriceUsdc: number; + buyPriceUsdc: number; + midPriceUsdc: number; + marketQuoted: boolean; + // (1 - mid) x 10000. Positive means eUSD is cheap, which is what redemption arb trades. + discountBps: number; + reserves?: { eusd: bigint; usdc: bigint }; +}; + +function toFloat(wei: bigint): number { + return Number(wei) / 1e18; +} + +/// A Liquity 1e18-scaled ratio as a plain number, with its "no debt" sentinel mapped to something +/// finite. Everything in this venue -- MCR, CCR, ICR, TCR -- uses that scale. +export function ratioFrom(raw: bigint): number { + if (raw > 10n ** 30n) return NO_DEBT_RATIO; + return Number(raw) / 1e18; +} + +/// A Liquity 1e18-scaled fee rate in bps (0.005e18 = 0.5% = 50bps). +export function rateBpsFrom(raw: bigint): number { + return Number((raw * 10_000n * 1000n) / WAD) / 1000; +} + +/// ICR = collateral value / debt, the same formula LiquityMath uses on chain. +export function icrOf( + collWei: bigint, + debtWei: bigint, + priceUsd: number, +): number { + if (debtWei <= 0n) return NO_DEBT_RATIO; + return (toFloat(collWei) * priceUsd) / toFloat(debtWei); +} + +/// The collateral price at which a Trove falls to MCR and becomes liquidatable. The number an agent +/// running a Trove has to watch, and the reason it is in the observation rather than left to be +/// re-derived from three other fields. +export function liquidationPriceUsd( + collWei: bigint, + debtWei: bigint, + mcr: number, +): number { + const coll = toFloat(collWei); + if (coll <= 0) return 0; + return (toFloat(debtWei) * mcr) / coll; +} + +/// Discount of the market against the $1 the protocol redeems at, in bps. Positive means eUSD trades +/// below par, so buying and redeeming converts the gap into collateral. +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, +): Promise { + const deployment = requireLiquity(); + const { publicClient } = ctx; + const priceRaw = BigInt(Math.round(fairPrice * 1e18)); + + const [ + tcrRaw, + recoveryMode, + mcrRaw, + ccrRaw, + minNetDebt, + gasCompensation, + troveCountRaw, + totalColl, + totalDebt, + borrowingRate, + redemptionRate, + baseRate, + spTotal, + ] = (await Promise.all([ + read(publicClient, deployment.troveManager, troveManagerAbi, "getTCR", [ + priceRaw, + ]), + read( + publicClient, + deployment.troveManager, + troveManagerAbi, + "checkRecoveryMode", + [priceRaw], + ), + read(publicClient, deployment.troveManager, troveManagerAbi, "MCR"), + read(publicClient, deployment.troveManager, troveManagerAbi, "CCR"), + read( + publicClient, + deployment.troveManager, + troveManagerAbi, + "MIN_NET_DEBT", + ), + read( + publicClient, + deployment.troveManager, + troveManagerAbi, + "LUSD_GAS_COMPENSATION", + ), + read( + publicClient, + deployment.troveManager, + troveManagerAbi, + "getTroveOwnersCount", + ), + read( + publicClient, + deployment.troveManager, + troveManagerAbi, + "getEntireSystemColl", + ), + read( + publicClient, + deployment.troveManager, + troveManagerAbi, + "getEntireSystemDebt", + ), + read( + publicClient, + deployment.troveManager, + troveManagerAbi, + "getBorrowingRateWithDecay", + ), + read( + publicClient, + deployment.troveManager, + troveManagerAbi, + "getRedemptionRateWithDecay", + ), + read(publicClient, deployment.troveManager, troveManagerAbi, "baseRate"), + read( + publicClient, + deployment.stabilityPool, + stabilityPoolAbi, + "getTotalLUSDDeposits", + ), + ])) as [ + bigint, + boolean, + bigint, + bigint, + bigint, + bigint, + bigint, + bigint, + bigint, + bigint, + bigint, + bigint, + bigint, + ]; + + const troveCount = Number(troveCountRaw); + const { troves, orderKnown } = await readTroveOrder( + publicClient, + deployment, + troveCount, + gasCompensation, + fairPrice, + ); + const market = await readMarket(publicClient); + + return { + deployment, + market: market.market, + priceUsd: fairPrice, + priceRaw, + tcr: ratioFrom(tcrRaw), + recoveryMode, + mcr: ratioFrom(mcrRaw), + ccr: ratioFrom(ccrRaw), + minNetDebtEusdWei: minNetDebt, + gasCompensationEusdWei: gasCompensation, + troveCount, + totalCollWei: totalColl, + totalDebtEusdWei: totalDebt, + borrowingRateBps: rateBpsFrom(borrowingRate), + redemptionRateBps: rateBpsFrom(redemptionRate), + baseRateBps: rateBpsFrom(baseRate), + spTotalDepositsEusdWei: spTotal, + troves, + troveOrderKnown: orderKnown, + ...market.prices, + }; +} + +/// Every Trove in the order a redemption walks them (riskiest first). +/// +/// Read from the owners array rather than by walking SortedTroves, because the walk is a chain of +/// dependent calls -- one round trip per Trove -- while the array is two batched rounds however long +/// the list is. The ordering is then recomputed here from the same ICRs the chain sorts on. +async function readTroveOrder( + publicClient: PublicClient, + deployment: LiquityDeployment, + troveCount: number, + gasCompensation: bigint, + priceUsd: number, +): Promise<{ troves: LiquityTroveRow[]; orderKnown: boolean }> { + if (troveCount === 0) return { troves: [], orderKnown: true }; + const orderKnown = troveCount <= MAX_TROVE_SCAN; + const scan = Math.min(troveCount, MAX_TROVE_SCAN); + let owners: Address[]; + try { + owners = (await Promise.all( + Array.from({ length: scan }, (_, i) => + read( + publicClient, + deployment.troveManager, + troveManagerAbi, + "getTroveFromTroveOwnersArray", + [BigInt(i)], + ), + ), + )) as Address[]; + } catch { + return { troves: [], orderKnown: false }; + } + let rows: LiquityTroveRow[]; + try { + const entries = (await Promise.all( + owners.map((owner) => + read( + publicClient, + deployment.troveManager, + troveManagerAbi, + "getEntireDebtAndColl", + [owner], + ), + ), + )) as Array; + rows = owners.map((owner, i) => { + const [debt, coll] = entries[i]; + return { + owner, + collWei: coll, + debtEusdWei: debt, + netDebtEusdWei: debt > gasCompensation ? debt - gasCompensation : 0n, + icr: icrOf(coll, debt, priceUsd), + }; + }); + } catch { + return { troves: [], orderKnown: false }; + } + rows.sort((a, b) => a.icr - b.icr); + return { troves: rows, orderKnown }; +} + +/// The eUSD/USDC market, probed from both sides. +/// +/// One-sided quotes under-report the executable mid whenever the pool is imbalanced, and an agent +/// trading against that phantom spread bleeds the fee every round trip -- the root cause of the WBTC +/// all-agent bleed. Here it would be worse than a bleed: the one thing this venue must report +/// correctly is how far eUSD is from par. +async function readMarket(publicClient: PublicClient): Promise<{ + market: LiquityState["market"]; + prices: Pick< + LiquityState, + | "sellPriceUsdc" + | "buyPriceUsdc" + | "midPriceUsdc" + | "marketQuoted" + | "discountBps" + | "reserves" + >; +}> { + const l = requireLiquity(); + if ( + !l.eusdUsdcPool || + l.eusdIndex === undefined || + l.usdcIndex === undefined + ) { + // A deploy without a curve factory has no market for the peg. Troves and the Stability Pool + // still work, so this is a missing leg rather than a broken venue. + return { + market: null, + prices: { + sellPriceUsdc: 0, + buyPriceUsdc: 0, + midPriceUsdc: 1, + marketQuoted: false, + discountBps: 0, + }, + }; + } + 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; + + let reserves: { eusd: bigint; usdc: bigint } | undefined; + try { + const [eusd, usdc] = (await Promise.all([ + read(publicClient, market.pool, curveStableSwapNgAbi, "balances", [ + BigInt(market.eusdIndex), + ]), + read(publicClient, market.pool, curveStableSwapNgAbi, "balances", [ + BigInt(market.usdcIndex), + ]), + ])) as [bigint, bigint]; + reserves = { eusd, usdc }; + } catch { + reserves = undefined; + } + + return { + market, + prices: { + sellPriceUsdc, + buyPriceUsdc, + // A pool that did not quote has no price, which is not a price of zero: par is the anchor the + // protocol itself enforces, so it is the least wrong thing to fall back to -- and marketQuoted + // says the number is a fallback rather than an observation. + midPriceUsdc: marketQuoted ? midPriceUsdc : 1, + marketQuoted, + discountBps: marketQuoted ? discountBpsFrom(midPriceUsdc) : 0, + ...(reserves ? { reserves } : {}), + }, + }; +} + +function read( + publicClient: PublicClient, + address: Address, + // biome-ignore lint/suspicious/noExplicitAny: one helper over several contract ABIs + abi: any, + functionName: string, + args?: readonly unknown[], +): Promise { + return publicClient.readContract({ + address, + abi, + functionName, + ...(args ? { args } : {}), + } as never); +} + +// --------------------------------------------------------------------------- +// parse / validate +// --------------------------------------------------------------------------- + +function requireDecimalString( + value: unknown, + name: string, +): asserts value is string { + if (typeof value !== "string" || !DECIMAL_INTEGER.test(value)) + throw new Error(`${name} must be a decimal integer string`); +} + +function optionalDecimalString( + value: unknown, + name: string, +): string | undefined { + if (value === undefined) return undefined; + requireDecimalString(value, name); + return value; +} + +function optionalBps(value: unknown, name: string): number | undefined { + if (value === undefined) return undefined; + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value <= 0 || + value > 10_000 + ) + throw new Error(`${name} must be an integer between 1 and 10000`); + return value; +} + +function withPriorityFee( + action: T, + obj: Record, +): T { + if (obj.maxPriorityFeePerGasWei !== undefined) { + requireDecimalString( + obj.maxPriorityFeePerGasWei, + "maxPriorityFeePerGasWei", + ); + action.maxPriorityFeePerGasWei = obj.maxPriorityFeePerGasWei; + } + return action; +} + +function parse(obj: Record): LeafAction | null { + switch (obj.type) { + case "liquityOpenTrove": { + requireDecimalString(obj.collateralWethWei, "collateralWethWei"); + requireDecimalString(obj.debtEusdWei, "debtEusdWei"); + const action: LiquityOpenTroveAction = { + type: "liquityOpenTrove", + collateralWethWei: obj.collateralWethWei, + debtEusdWei: obj.debtEusdWei, + }; + const maxFeeBps = optionalBps(obj.maxFeeBps, "maxFeeBps"); + if (maxFeeBps !== undefined) action.maxFeeBps = maxFeeBps; + return withPriorityFee(action, obj); + } + case "liquityAdjustTrove": { + const action: LiquityAdjustTroveAction = { type: "liquityAdjustTrove" }; + const add = optionalDecimalString( + obj.addCollateralWethWei, + "addCollateralWethWei", + ); + const withdraw = optionalDecimalString( + obj.withdrawCollateralWei, + "withdrawCollateralWei", + ); + const debtChange = optionalDecimalString( + obj.debtChangeEusdWei, + "debtChangeEusdWei", + ); + if (add !== undefined) action.addCollateralWethWei = add; + if (withdraw !== undefined) action.withdrawCollateralWei = withdraw; + if (debtChange !== undefined) action.debtChangeEusdWei = debtChange; + if (obj.isDebtIncrease !== undefined) { + if (typeof obj.isDebtIncrease !== "boolean") + throw new Error("isDebtIncrease must be a boolean"); + action.isDebtIncrease = obj.isDebtIncrease; + } + const maxFeeBps = optionalBps(obj.maxFeeBps, "maxFeeBps"); + if (maxFeeBps !== undefined) action.maxFeeBps = maxFeeBps; + return withPriorityFee(action, obj); + } + case "liquityCloseTrove": + return withPriorityFee( + { type: "liquityCloseTrove" }, + obj, + ); + case "liquityRedeem": { + requireDecimalString(obj.amountEusdWei, "amountEusdWei"); + const action: LiquityRedeemAction = { + type: "liquityRedeem", + amountEusdWei: obj.amountEusdWei, + }; + if (obj.maxIterations !== undefined) { + if ( + typeof obj.maxIterations !== "number" || + !Number.isInteger(obj.maxIterations) || + obj.maxIterations < 0 + ) + throw new Error("maxIterations must be a non-negative integer"); + action.maxIterations = obj.maxIterations; + } + const maxFeeBps = optionalBps(obj.maxFeeBps, "maxFeeBps"); + if (maxFeeBps !== undefined) action.maxFeeBps = maxFeeBps; + return withPriorityFee(action, obj); + } + case "liquityProvideToSP": { + requireDecimalString(obj.amountEusdWei, "amountEusdWei"); + return withPriorityFee( + { type: "liquityProvideToSP", amountEusdWei: obj.amountEusdWei }, + obj, + ); + } + case "liquityWithdrawFromSP": { + if (obj.amountEusdWei !== "max") + requireDecimalString(obj.amountEusdWei, "amountEusdWei"); + return withPriorityFee( + { + type: "liquityWithdrawFromSP", + amountEusdWei: obj.amountEusdWei as string, + }, + obj, + ); + } + case "liquityLiquidate": { + const action: LiquityLiquidateAction = { type: "liquityLiquidate" }; + if (obj.borrowers !== undefined) { + if ( + !Array.isArray(obj.borrowers) || + obj.borrowers.some((b) => typeof b !== "string") + ) + throw new Error("borrowers must be an array of addresses"); + action.borrowers = obj.borrowers as string[]; + } + if (obj.maxTroves !== undefined) { + if ( + typeof obj.maxTroves !== "number" || + !Number.isInteger(obj.maxTroves) || + obj.maxTroves <= 0 + ) + throw new Error("maxTroves must be a positive integer"); + action.maxTroves = obj.maxTroves; + } + return withPriorityFee(action, obj); + } + case "liquitySwapEusd": { + if (obj.tokenIn !== "USDC" && obj.tokenIn !== "EUSD") + throw new Error('liquitySwapEusd tokenIn must be "USDC" or "EUSD"'); + requireDecimalString(obj.amountIn, "amountIn"); + const action: LiquitySwapEusdAction = { + type: "liquitySwapEusd", + tokenIn: obj.tokenIn, + amountIn: obj.amountIn, + }; + 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 withPriorityFee(action, obj); + } + default: + return null; + } +} + +function validate( + action: LeafAction, + obs: AgentObservation, + balances: BalanceSnapshot, +): ValidationResult { + const liquity = obs.protocols.liquity; + // The eUSD balance is not part of BalanceSnapshot -- the token is deliberately outside the + // registry so nothing prices it at par -- so it comes from the observation, as the LST's does. + const eusdBalance = liquity ? BigInt(liquity.eusdBalanceWei) : 0n; + const wethBalance = balances.bases?.WETH ?? balances.wethWei; + + switch (action.type) { + case "liquityOpenTrove": { + if (!liquity) + return { ok: false, reason: "no liquity observation available" }; + const coll = BigInt(action.collateralWethWei); + const debt = BigInt(action.debtEusdWei); + if (coll <= 0n) + return { ok: false, reason: "collateralWethWei must be positive" }; + if (coll > wethBalance) + return { ok: false, reason: "collateralWethWei exceeds WETH balance" }; + if (debt < BigInt(liquity.minNetDebtEusdWei)) + return { + ok: false, + reason: `debtEusdWei is below MIN_NET_DEBT (${liquity.minNetDebtEusdWei})`, + }; + if (liquity.trove && liquity.trove.status === 1) + return { ok: false, reason: "this wallet already has an active Trove" }; + // Recovery Mode forbids opening below CCR, and the resulting ratio is knowable here, so say so + // now rather than paying gas to be told on chain. + const icr = + (Number(formatUnits(coll, 18)) * liquity.priceUsd) / + Number(formatUnits(debt, 18)); + const floor = liquity.recoveryMode ? liquity.ccr : liquity.mcr; + if (icr < floor) + return { + ok: false, + reason: `resulting ICR ${icr.toFixed(3)} is below the ${liquity.recoveryMode ? "CCR (Recovery Mode)" : "MCR"} of ${floor}`, + }; + return { ok: true }; + } + case "liquityAdjustTrove": { + if (!liquity?.trove || liquity.trove.status !== 1) + return { ok: false, reason: "no active Trove to adjust" }; + const add = BigInt(action.addCollateralWethWei ?? "0"); + const withdraw = BigInt(action.withdrawCollateralWei ?? "0"); + const debtChange = BigInt(action.debtChangeEusdWei ?? "0"); + if (add > 0n && withdraw > 0n) + return { + ok: false, + reason: "adjust cannot add and withdraw collateral at once", + }; + if (add === 0n && withdraw === 0n && debtChange === 0n) + return { ok: false, reason: "adjust must change something" }; + if (add > wethBalance) + return { + ok: false, + reason: "addCollateralWethWei exceeds WETH balance", + }; + if (debtChange > 0n && action.isDebtIncrease === undefined) + return { + ok: false, + reason: "isDebtIncrease is required with a debt change", + }; + if ( + debtChange > 0n && + action.isDebtIncrease === false && + debtChange > eusdBalance + ) + return { ok: false, reason: "debtChangeEusdWei exceeds eUSD balance" }; + if (withdraw > BigInt(liquity.trove.collWei)) + return { + ok: false, + reason: "withdrawCollateralWei exceeds the Trove's collateral", + }; + return { ok: true }; + } + case "liquityCloseTrove": { + if (!liquity?.trove || liquity.trove.status !== 1) + return { ok: false, reason: "no active Trove to close" }; + if (eusdBalance < BigInt(liquity.trove.netDebtEusdWei)) + return { + ok: false, + reason: `closing needs ${liquity.trove.netDebtEusdWei} eUSD to repay, wallet holds ${eusdBalance}`, + }; + return { ok: true }; + } + case "liquityRedeem": { + if (!liquity) + return { ok: false, reason: "no liquity observation available" }; + const amount = BigInt(action.amountEusdWei); + if (amount <= 0n) + return { ok: false, reason: "amountEusdWei must be positive" }; + if (amount > eusdBalance) + return { ok: false, reason: "amountEusdWei exceeds eUSD balance" }; + return { ok: true }; + } + case "liquityProvideToSP": { + const amount = BigInt(action.amountEusdWei); + if (amount <= 0n) + return { ok: false, reason: "amountEusdWei must be positive" }; + if (amount > eusdBalance) + return { ok: false, reason: "amountEusdWei exceeds eUSD balance" }; + return { ok: true }; + } + case "liquityWithdrawFromSP": { + if (!liquity) + return { ok: false, reason: "no liquity observation available" }; + if (action.amountEusdWei === "max") return { ok: true }; + const amount = BigInt(action.amountEusdWei); + // Zero is legal and means "claim the ETH gain", so it is only pointless when there is nothing + // deposited and nothing to claim. + if ( + amount === 0n && + BigInt(liquity.spDepositEusdWei) === 0n && + BigInt(liquity.spEthGainWei) === 0n + ) + return { ok: false, reason: "nothing deposited and no gain to claim" }; + if (amount > BigInt(liquity.spDepositEusdWei)) + return { + ok: false, + reason: "amountEusdWei exceeds the Stability Pool deposit", + }; + return { ok: true }; + } + case "liquityLiquidate": { + if (!action.borrowers?.length && !action.maxTroves) + return { + ok: false, + reason: "liquidate needs either borrowers or maxTroves", + }; + return { ok: true }; + } + case "liquitySwapEusd": { + const amount = BigInt(action.amountIn); + if (amount <= 0n) + return { ok: false, reason: "amountIn must be positive" }; + if (action.tokenIn === "EUSD") { + if (amount > eusdBalance) + return { ok: false, reason: "amountIn exceeds eUSD balance" }; + return { ok: true }; + } + if (amount > balances.usdcUnits) + return { ok: false, reason: "amountIn exceeds USDC balance" }; + const maxUsdcIn = BigInt(obs.limits.maxUsdcInUnits); + if (maxUsdcIn > 0n && amount > maxUsdcIn) + return { + ok: false, + reason: "amountIn exceeds the configured per-round limit", + }; + return { ok: true }; + } + default: + return { ok: false, reason: "not a liquity action" }; + } +} + +// --------------------------------------------------------------------------- +// Observation (issue #39 phase 3) +// --------------------------------------------------------------------------- + +async function observe( + ctx: SimContext, + state: LiquityState, + agent: Address, +): Promise { + const { publicClient } = ctx; + const d = state.deployment; + const [ + entire, + status, + eusdBalance, + spDeposit, + spEthGain, + spLqtyGain, + ethBalance, + ] = await Promise.all([ + read( + publicClient, + d.troveManager, + troveManagerAbi, + "getEntireDebtAndColl", + [agent], + ) as Promise, + read(publicClient, d.troveManager, troveManagerAbi, "getTroveStatus", [ + agent, + ]) as Promise, + read(publicClient, d.eusd, erc20Abi, "balanceOf", [ + agent, + ]) as Promise, + read( + publicClient, + d.stabilityPool, + stabilityPoolAbi, + "getCompoundedLUSDDeposit", + [agent], + ) as Promise, + read( + publicClient, + d.stabilityPool, + stabilityPoolAbi, + "getDepositorETHGain", + [agent], + ) as Promise, + read( + publicClient, + d.stabilityPool, + stabilityPoolAbi, + "getDepositorLQTYGain", + [agent], + ) as Promise, + publicClient.getBalance({ address: agent }), + ]); + + const [debt, coll] = entire; + const trove: LiquityTroveObservation | undefined = + Number(status) === 1 + ? buildTroveObservation(state, agent, coll, debt, Number(status)) + : undefined; + + const riskiest = state.troves[0]; + const spShareBps = + state.spTotalDepositsEusdWei > 0n + ? Number((spDeposit * 10_000n) / state.spTotalDepositsEusdWei) + : 0; + + return { + priceUsd: state.priceUsd, + tcr: state.tcr, + recoveryMode: state.recoveryMode, + mcr: state.mcr, + ccr: state.ccr, + troveCount: state.troveCount, + totalCollWei: state.totalCollWei.toString(), + totalDebtEusdWei: state.totalDebtEusdWei.toString(), + borrowingRateBps: state.borrowingRateBps, + redemptionRateBps: state.redemptionRateBps, + baseRateBps: state.baseRateBps, + minNetDebtEusdWei: state.minNetDebtEusdWei.toString(), + gasCompensationEusdWei: state.gasCompensationEusdWei.toString(), + eusdBalanceWei: eusdBalance.toString(), + marketPriceUsdc: state.midPriceUsdc, + ...(state.sellPriceUsdc > 0 + ? { marketSellPriceUsdc: state.sellPriceUsdc } + : {}), + ...(state.buyPriceUsdc > 0 + ? { marketBuyPriceUsdc: state.buyPriceUsdc } + : {}), + marketQuoted: state.marketQuoted, + discountBps: state.discountBps, + // The number that actually decides whether to redeem: the dislocation net of the fee the + // protocol charges for closing it. Reported rather than left to be re-derived, because getting + // the sign wrong here is the difference between the venue's α and a guaranteed loss. + redemptionEdgeBps: state.marketQuoted + ? state.discountBps - state.redemptionRateBps + : 0, + ...(state.reserves + ? { + poolReserves: { + eusd: state.reserves.eusd.toString(), + usdc: state.reserves.usdc.toString(), + }, + } + : {}), + ...(trove ? { trove } : {}), + ...(riskiest + ? { + riskiestTrove: { + owner: riskiest.owner, + icr: riskiest.icr, + netDebtEusdWei: riskiest.netDebtEusdWei.toString(), + }, + } + : {}), + spDepositEusdWei: spDeposit.toString(), + spEthGainWei: spEthGain.toString(), + spLqtyGainWei: spLqtyGain.toString(), + spTotalDepositsEusdWei: state.spTotalDepositsEusdWei.toString(), + spShareBps, + ethBalanceWei: ethBalance.toString(), + suggestedGasReserveWei: SUGGESTED_GAS_RESERVE_WEI.toString(), + }; +} + +/// Your Trove plus where it sits in the redemption queue. +/// +/// The position is the part that has no equivalent on any other venue: redemptions always start at +/// the riskiest Trove, so what matters is not only your own ratio but how much debt sits ahead of +/// you. `redeemedAheadEusdWei` is the redemption volume the system absorbs before it reaches you. +function buildTroveObservation( + state: LiquityState, + agent: Address, + collWei: bigint, + debtWei: bigint, + status: number, +): LiquityTroveObservation { + const gas = state.gasCompensationEusdWei; + const netDebt = debtWei > gas ? debtWei - gas : 0n; + const index = state.troves.findIndex( + (t) => t.owner.toLowerCase() === agent.toLowerCase(), + ); + const positionKnown = state.troveOrderKnown && index >= 0; + let ahead = 0n; + if (positionKnown) { + for (let i = 0; i < index; i++) ahead += state.troves[i].netDebtEusdWei; + } + return { + status, + collWei: collWei.toString(), + debtEusdWei: debtWei.toString(), + netDebtEusdWei: netDebt.toString(), + icr: icrOf(collWei, debtWei, state.priceUsd), + liquidationPriceUsd: liquidationPriceUsd(collWei, debtWei, state.mcr), + positionFromRiskiest: positionKnown ? index : -1, + redeemedAheadEusdWei: ahead.toString(), + positionKnown, + }; +} + +// --------------------------------------------------------------------------- +// Transactions +// --------------------------------------------------------------------------- + +function maxFeeWad(bps: number | undefined): bigint { + return (BigInt(bps ?? DEFAULT_MAX_FEE_BPS) * WAD) / 10_000n; +} + +function unwrapWeth(amount: bigint): BuiltTx { + return { + to: TOKENS.WETH.address, + data: encodeFunctionData({ + abi: wethAbi, + functionName: "withdraw", + args: [amount], + }), + }; +} + +/// Where a Trove with these numbers belongs in the sorted list. +/// +/// Liquity inserts by *nominal* ICR (collateral x 1e20 / debt, price-free), so the hint stays valid +/// for the block it is computed in. An unhinted insert walks the list on chain, which is the +/// difference between a routine transaction and one that runs out of gas. +async function insertHints( + publicClient: PublicClient, + sortedTroves: Address, + collWei: bigint, + debtWei: bigint, +): Promise<[Address, Address]> { + if (debtWei <= 0n) return [ZERO_ADDRESS, ZERO_ADDRESS]; + const nicr = (collWei * 10n ** 20n) / debtWei; + const [upper, lower] = (await read( + publicClient, + sortedTroves, + sortedTrovesAbi, + "findInsertPosition", + [nicr, ZERO_ADDRESS, ZERO_ADDRESS], + )) as [Address, Address]; + return [upper, lower]; +} + +async function buildTxs( + ctx: SimContext, + owner: Address, + action: LeafAction, + state: LiquityState | undefined, +): Promise { + const d = requireLiquity(); + const { publicClient } = ctx; + switch (action.type) { + case "liquityOpenTrove": { + const coll = BigInt(action.collateralWethWei); + const debt = BigInt(action.debtEusdWei); + // The Trove books the requested debt plus the borrowing fee plus the gas compensation, and it + // is that composite number the list sorts on -- hinting off the requested debt alone puts the + // insert in the wrong place and the transaction pays to walk from there. + const [fee, composite] = (await Promise.all([ + read( + publicClient, + d.troveManager, + troveManagerAbi, + "getBorrowingFeeWithDecay", + [debt], + ), + read( + publicClient, + d.borrowerOperations, + borrowerOperationsAbi, + "getCompositeDebt", + [debt], + ), + ])) as [bigint, bigint]; + const [upper, lower] = await insertHints( + publicClient, + d.sortedTroves, + coll, + composite + fee, + ); + return [ + unwrapWeth(coll), + { + to: d.borrowerOperations, + data: encodeFunctionData({ + abi: borrowerOperationsAbi, + functionName: "openTrove", + args: [maxFeeWad(action.maxFeeBps), debt, upper, lower], + }), + value: coll, + }, + ]; + } + case "liquityAdjustTrove": { + const add = BigInt(action.addCollateralWethWei ?? "0"); + const withdraw = BigInt(action.withdrawCollateralWei ?? "0"); + const debtChange = BigInt(action.debtChangeEusdWei ?? "0"); + const increase = action.isDebtIncrease === true; + const [debt, coll] = (await read( + publicClient, + d.troveManager, + troveManagerAbi, + "getEntireDebtAndColl", + [owner], + )) as readonly [bigint, bigint, bigint, bigint]; + const fee = + increase && debtChange > 0n + ? ((await read( + publicClient, + d.troveManager, + troveManagerAbi, + "getBorrowingFeeWithDecay", + [debtChange], + )) as bigint) + : 0n; + const nextColl = coll + add - withdraw; + const nextDebt = increase + ? debt + debtChange + fee + : debt > debtChange + ? debt - debtChange + : 0n; + const [upper, lower] = await insertHints( + publicClient, + d.sortedTroves, + nextColl, + nextDebt, + ); + const call: BuiltTx = { + to: d.borrowerOperations, + data: encodeFunctionData({ + abi: borrowerOperationsAbi, + functionName: "adjustTrove", + args: [ + maxFeeWad(action.maxFeeBps), + withdraw, + debtChange, + increase, + upper, + lower, + ], + }), + ...(add > 0n ? { value: add } : {}), + }; + return add > 0n ? [unwrapWeth(add), call] : [call]; + } + case "liquityCloseTrove": + return [ + { + to: d.borrowerOperations, + data: encodeFunctionData({ + abi: borrowerOperationsAbi, + functionName: "closeTrove", + }), + }, + ]; + case "liquityRedeem": { + // Routed through the helper rather than built here, because the hints cannot be computed off + // chain: Liquity checks a partial redemption against a nominal ICR derived from the price the + // *transaction* fetches, and this environment writes a new price every block ahead of every + // agent. Every redemption in the venue's first live run reverted with "Unable to redeem any + // amount" for exactly that reason. The helper computes them after fetchPrice has cached the + // price the redemption will use, so the two cannot disagree. + const helper = requireRedemptionHelper(); + return [ + { + to: helper, + data: encodeFunctionData({ + abi: liquityRedemptionHelperAbi, + functionName: "redeem", + args: [ + BigInt(action.amountEusdWei), + maxFeeWad(action.maxFeeBps), + BigInt(action.maxIterations ?? 0), + ], + }), + }, + ]; + } + case "liquityProvideToSP": + return [ + { + to: d.stabilityPool, + data: encodeFunctionData({ + abi: stabilityPoolAbi, + functionName: "provideToSP", + // No front-end tag: this deployment has no front ends, so the whole LQTY share (which + // nothing values here anyway) stays with the depositor. + args: [BigInt(action.amountEusdWei), ZERO_ADDRESS], + }), + }, + ]; + case "liquityWithdrawFromSP": { + const amount = + action.amountEusdWei === "max" + ? maxUint256 + : BigInt(action.amountEusdWei); + return [ + { + to: d.stabilityPool, + data: encodeFunctionData({ + abi: stabilityPoolAbi, + functionName: "withdrawFromSP", + // The pool clamps to the compounded deposit, so "max" is the contract's own idiom for + // taking everything -- no read needed, and no race with a liquidation in between. + args: [amount], + }), + }, + ]; + } + case "liquityLiquidate": { + if (action.borrowers?.length) { + return [ + { + to: d.troveManager, + data: encodeFunctionData({ + abi: troveManagerAbi, + functionName: "batchLiquidateTroves", + args: [action.borrowers as Address[]], + }), + }, + ]; + } + return [ + { + to: d.troveManager, + data: encodeFunctionData({ + abi: troveManagerAbi, + functionName: "liquidateTroves", + args: [BigInt(action.maxTroves ?? 1)], + }), + }, + ]; + } + case "liquitySwapEusd": { + const market = requireEusdMarket(); + const amountIn = BigInt(action.amountIn); + const [i, j] = + action.tokenIn === "EUSD" + ? [market.eusdIndex, market.usdcIndex] + : [market.usdcIndex, market.eusdIndex]; + const quoted = (await read( + publicClient, + market.pool, + curveStableSwapNgAbi, + "get_dy", + [BigInt(i), BigInt(j), amountIn], + )) as bigint; + const slippageBps = action.slippageBps ?? DEFAULT_SLIPPAGE_BPS; + const minDy = (quoted * BigInt(10_000 - slippageBps)) / 10_000n; + return [ + { + to: market.pool, + data: encodeFunctionData({ + abi: curveStableSwapNgAbi, + functionName: "exchange", + args: [BigInt(i), BigInt(j), amountIn, minDy], + }), + }, + ]; + } + default: + throw new Error("liquity buildTxs: unexpected action"); + } +} + +// --------------------------------------------------------------------------- +// Valuation (issue #39 phase 4) +// --------------------------------------------------------------------------- + +// What an agent holds on this venue, before it is priced. +type LiquityHoldings = { + collWei: bigint; + debtEusdWei: bigint; + netDebtEusdWei: bigint; + spDepositEusdWei: bigint; + spEthGainWei: bigint; + eusdBalanceWei: bigint; +}; + +/// Price a Liquity position, given what eUSD is worth. +/// +/// Two marks, because they genuinely differ here and the difference is the venue's point: +/// mark every eUSD leg at the market's executable *mid*, i.e. what the position is worth +/// realizable the long leg at what selling that size would fetch, and the debt at what buying it +/// back would cost -- the impact of actually unwinding, which a mid cannot show +/// +/// A Trove's contribution never goes below zero. Below 100% ICR the owner can stop repaying and lose +/// only the collateral, which is a real property of a CDP rather than an accounting convenience; a +/// negative mark would charge them for a liability they can walk away from. +export function liquityPositionValue(input: { + holdings: LiquityHoldings; + fairPriceUsd: number; + eusdPriceUsdc: number; + // Own-size quotes, when the pool answered. Falling back to the mid understates nothing and + // overstates nothing systematically -- it simply omits the impact. + longExitUsdc?: number; + debtBuybackUsdc?: number; +}): { valueUsdc: number; liquidatableValueUsdc: number } { + const { holdings: h, fairPriceUsd, eusdPriceUsdc } = input; + const longEusd = toFloat(h.eusdBalanceWei + h.spDepositEusdWei); + const collUsd = toFloat(h.collWei) * fairPriceUsd; + const gainUsd = toFloat(h.spEthGainWei) * fairPriceUsd; + const netDebtEusd = toFloat(h.netDebtEusdWei); + + const troveMark = Math.max(0, collUsd - netDebtEusd * eusdPriceUsdc); + const troveExit = Math.max( + 0, + collUsd - (input.debtBuybackUsdc ?? netDebtEusd * eusdPriceUsdc), + ); + return { + valueUsdc: troveMark + gainUsd + longEusd * eusdPriceUsdc, + liquidatableValueUsdc: + troveExit + gainUsd + (input.longExitUsdc ?? longEusd * eusdPriceUsdc), + }; +} + +function usdcFloat(units: bigint): number { + return Number(formatUnits(units, USDC_DECIMALS)); +} + +/// Historical valuation (issue #41's staged reads). +/// +/// Two stages, because the realizable mark depends on sizes the first stage returns: +/// 0. the market's two-sided probe, the gas compensation, and every agent's Trove / Stability Pool +/// / eUSD position +/// 1. for exactly the agents that hold eUSD or owe it: what their own size would sell for, and +/// what buying their debt back would cost +/// +/// Takes the deployment explicitly rather than reading the module constant, so the marking rules can +/// be exercised without a deployed venue. +export async function* liquityValuationRun( + deployment: LiquityDeployment, + ctx: ValuationContext, +): ValuationRun { + const hasMarket = + Boolean(deployment.eusdUsdcPool) && + deployment.eusdIndex !== undefined && + deployment.usdcIndex !== undefined; + const pool = deployment.eusdUsdcPool as Address; + const eusdIndex = BigInt(deployment.eusdIndex ?? 0); + const usdcIndex = BigInt(deployment.usdcIndex ?? 1); + + const stage0: ValuationRead[] = [ + { + address: deployment.troveManager, + 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, + abi: troveManagerAbi, + functionName: "getEntireDebtAndColl", + args: [a.address], + }, + { + address: deployment.stabilityPool, + abi: stabilityPoolAbi, + functionName: "getCompoundedLUSDDeposit", + args: [a.address], + }, + { + address: deployment.stabilityPool, + abi: stabilityPoolAbi, + 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 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" + ) + return undefined; + const [debt, coll] = entire; + return { + collWei: coll, + debtEusdWei: debt, + 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; + + // 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.netDebtEusdWei > 0n) debtTargets.push(i); + }); + let quotes: unknown[] = []; + if (hasMarket && (longTargets.length > 0 || debtTargets.length > 0)) { + quotes = yield [ + ...longTargets.map((i): ValuationRead => ({ + address: pool, + abi: curveStableSwapNgAbi, + functionName: "get_dy", + args: [ + eusdIndex, + usdcIndex, + holdings[i]!.eusdBalanceWei + holdings[i]!.spDepositEusdWei, + ], + })), + ...debtTargets.map((i): ValuationRead => ({ + address: pool, + abi: curveStableSwapNgAbi, + // What it costs to buy the debt back, which is not get_dy of anything: the size is fixed on + // the *output* side. Marking a liability off the wrong side of the book flatters it exactly + // when eUSD is dear, which is when a Trove is most expensive to close. + functionName: "get_dx", + args: [usdcIndex, eusdIndex, holdings[i]!.netDebtEusdWei], + })), + ]; + } + const longExitByIndex = new Map(); + longTargets.forEach((agentIndex, k) => { + const q = quotes[k]; + if (typeof q === "bigint") longExitByIndex.set(agentIndex, usdcFloat(q)); + }); + const debtCostByIndex = new Map(); + debtTargets.forEach((agentIndex, k) => { + const q = quotes[longTargets.length + k]; + if (typeof q === "bigint") debtCostByIndex.set(agentIndex, usdcFloat(q)); + }); + + const fairWeth = ctx.fairByBase().WETH ?? 0; + const out: Record = {}; + ctx.agents.forEach((agent, i) => { + const h = holdings[i]; + if (!h) { + // The read that would have revealed the position failed. "Zero" here is indistinguishable + // from having closed out, so it is reported instead (issue #44). + out[agent.id] = { + valueUsdc: 0, + liquidatableValueUsdc: 0, + unpriced: [ + { + source: "liquity-position", + amountRaw: "", + reason: "read-failed", + read: "TroveManager.getEntireDebtAndColl", + }, + ], + }; + return; + } + const unpriced: UnpricedHoldingDetail[] = []; + const exposure = h.eusdBalanceWei + h.spDepositEusdWei + h.netDebtEusdWei; + if (probeMid === undefined && 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. + unpriced.push({ + token: deployment.eusd, + amountRaw: exposure.toString(), + source: "liquity-eusd-market", + reason: "read-failed", + read: "CurveStableSwapNG.get_dy", + }); + } + const value = liquityPositionValue({ + holdings: h, + fairPriceUsd: fairWeth, + eusdPriceUsdc: probeMid ?? 1, + longExitUsdc: longExitByIndex.get(i), + debtBuybackUsdc: debtCostByIndex.get(i), + }); + out[agent.id] = { ...value, unpriced }; + }); + return out; +} + +// --------------------------------------------------------------------------- +// Adapter +// --------------------------------------------------------------------------- + +export const liquityAdapter: ProtocolAdapter = { + id: "liquity", + parse, + // Every call here is an ordinary transaction, so a Liquity leg can ride in a bundle with an AMM + // leg -- buying a depegged eUSD and redeeming it in one shot is the venue's headline trade. + bundleable: () => true, + validate, + + async readState(ctx, fairPrice): Promise { + return getLiquityState(ctx, fairPrice); + }, + + async observe(ctx, state, agent): Promise { + return observe(ctx, state as LiquityState, agent); + }, + + async buildTxs(ctx, owner, action, state): Promise { + return buildTxs(ctx, owner, action, state as LiquityState | undefined); + }, + + async valueUsdc(ctx, agent, state, fairPrice): Promise { + if (!LIQUITY) return 0; + const d = LIQUITY; + const s = state as LiquityState | undefined; + const [entire, spDeposit, spGain, eusdBalance, gasCompensation] = + (await Promise.all([ + read( + ctx.publicClient, + d.troveManager, + troveManagerAbi, + "getEntireDebtAndColl", + [agent], + ), + read( + ctx.publicClient, + d.stabilityPool, + stabilityPoolAbi, + "getCompoundedLUSDDeposit", + [agent], + ), + read( + ctx.publicClient, + d.stabilityPool, + stabilityPoolAbi, + "getDepositorETHGain", + [agent], + ), + read(ctx.publicClient, d.eusd, erc20Abi, "balanceOf", [agent]), + read( + ctx.publicClient, + d.troveManager, + troveManagerAbi, + "LUSD_GAS_COMPENSATION", + ), + ])) as [ + readonly [bigint, bigint, bigint, bigint], + bigint, + bigint, + bigint, + bigint, + ]; + const [debt, coll] = entire; + const eusdPriceUsdc = s?.marketQuoted ? s.midPriceUsdc : 1; + return liquityPositionValue({ + holdings: { + collWei: coll, + debtEusdWei: debt, + netDebtEusdWei: debt > gasCompensation ? debt - gasCompensation : 0n, + spDepositEusdWei: spDeposit, + spEthGainWei: spGain, + eusdBalanceWei: eusdBalance, + }, + fairPriceUsd: fairPrice, + eusdPriceUsdc, + }).valueUsdc; + }, + + valueAtBlock(ctx) { + if (!LIQUITY) { + const empty: Record = {}; + for (const a of ctx.agents) + empty[a.id] = { valueUsdc: 0, liquidatableValueUsdc: 0, unpriced: [] }; + return (async function* () { + return empty; + })(); + } + return liquityValuationRun(LIQUITY, ctx); + }, + + 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. + return LIQUITY ? [LIQUITY.eusd] : []; + }, + + async setupWallet(): Promise { + if (!LIQUITY?.eusdUsdcPool) return []; + // Liquity itself never pulls tokens through an allowance -- the stablecoin has privileged + // transfer paths for the pools -- so the only approvals needed are for its market and for the + // redemption helper, which does take the eUSD it redeems. + return [ + approveTx(LIQUITY.eusd, LIQUITY.eusdUsdcPool), + approveTx(TOKENS.USDC.address, LIQUITY.eusdUsdcPool), + ...(LIQUITY.redemptionHelper + ? [approveTx(LIQUITY.eusd, LIQUITY.redemptionHelper)] + : []), + ]; + }, +}; + +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/registry.ts b/sdk/src/protocols/registry.ts index 3e26126..b106a60 100644 --- a/sdk/src/protocols/registry.ts +++ b/sdk/src/protocols/registry.ts @@ -8,6 +8,7 @@ import { curveAdapter } from "./curve.js"; import { aaveAdapter } from "./aave.js"; import { gmxAdapter } from "./gmx.js"; import { lstAdapter } from "./lst.js"; +import { liquityAdapter } from "./liquity.js"; import { activeBaseSymbols, tokenInfo } from "../markets.js"; import { setEnabledProtocolIds } from "./enabled.js"; @@ -19,6 +20,7 @@ const ALL_ADAPTERS: ProtocolAdapter[] = [ aaveAdapter, gmxAdapter, lstAdapter, + liquityAdapter, ]; const ALL_BY_ID = new Map( @@ -27,10 +29,12 @@ const ALL_BY_ID = new Map( export const ALL_PROTOCOL_IDS: ProtocolId[] = ALL_ADAPTERS.map((a) => a.id); -// The venues a run gets when it does not say. lst is left out because it exists only under local -// deploy (issue #38) -- defaulting it on would break every fork run at the first read. +// The venues a run gets when it does not say. lst and liquity are left out because they exist only +// under local deploy (issues #38 / #39) -- defaulting either on would break every fork run at the +// first read. +const LOCAL_ONLY_PROTOCOL_IDS: ProtocolId[] = ["lst", "liquity"]; const DEFAULT_PROTOCOL_IDS: ProtocolId[] = ALL_ADAPTERS.map((a) => a.id).filter( - (id) => id !== "lst", + (id) => !LOCAL_ONLY_PROTOCOL_IDS.includes(id), ); // Set by the coordinator at startup. When unset, the default set above is treated as enabled. diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 05e2004..e40507d 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -13,7 +13,7 @@ export type TokenSymbol = string; export type TokenKind = "base" | "stable" | "lst"; export type ProtocolId = - "uniswap" | "balancer" | "curve" | "gmx" | "aave" | "lst"; + "uniswap" | "balancer" | "curve" | "gmx" | "aave" | "lst" | "liquity"; // --------------------------------------------------------------------------- // Market leg (venue-specific metadata. ADR 0013). One per protocol × base. @@ -185,6 +185,87 @@ export type LstClaimWithdrawAction = { maxPriorityFeePerGasWei?: string; }; +// Liquity venue (issue #39): a CDP stablecoin, eUSD. +// +// Collateral is native ETH, because that is what the forked core takes (`msg.value`). Every +// collateral amount here is still denominated in WETH wei and the adapter unwraps first, so an agent +// never has to think about which of the two it holds -- except for gas, which native ETH also pays: +// sinking the whole balance into a Trove strands the agent with no way to send the next transaction. +export type LiquityOpenTroveAction = { + type: "liquityOpenTrove"; + // WETH to unwrap and post as collateral. + collateralWethWei: string; + // eUSD to draw. The Trove's booked debt is this plus the borrowing fee plus the 200 eUSD gas + // compensation, and the sum must clear MIN_NET_DEBT (1,800). + debtEusdWei: string; + // Slippage bound on the borrowing fee, which moves with baseRate. Default 500 (5%). + maxFeeBps?: number; + maxPriorityFeePerGasWei?: string; +}; + +export type LiquityAdjustTroveAction = { + type: "liquityAdjustTrove"; + // Exactly one side of each pair. Adding collateral unwraps WETH; withdrawing returns native ETH. + addCollateralWethWei?: string; + withdrawCollateralWei?: string; + debtChangeEusdWei?: string; + // Whether debtChangeEusdWei is drawn (true) or repaid (false). Required when there is a change. + isDebtIncrease?: boolean; + maxFeeBps?: number; + maxPriorityFeePerGasWei?: string; +}; + +export type LiquityCloseTroveAction = { + type: "liquityCloseTrove"; + maxPriorityFeePerGasWei?: string; +}; + +// Buy eUSD below par and exchange it for collateral at the oracle price: the venue's clearest α. +// The adapter computes the HintHelpers hints and truncates the amount to what the sorted list can +// actually absorb -- an unhinted redemption walks the list on-chain and is prohibitively expensive. +export type LiquityRedeemAction = { + type: "liquityRedeem"; + amountEusdWei: string; + // Cap on how many Troves the redemption walks. 0 (default) means no cap. + maxIterations?: number; + // Slippage bound on the redemption fee, which rises with every redemption in the run. Default 500. + maxFeeBps?: number; + maxPriorityFeePerGasWei?: string; +}; + +export type LiquityProvideToSpAction = { + type: "liquityProvideToSP"; + amountEusdWei: string; + maxPriorityFeePerGasWei?: string; +}; + +export type LiquityWithdrawFromSpAction = { + type: "liquityWithdrawFromSP"; + // Decimal wei, or "max". "0" is legal and claims the accrued ETH gain without touching the deposit. + amountEusdWei: string; + maxPriorityFeePerGasWei?: string; +}; + +export type LiquityLiquidateAction = { + type: "liquityLiquidate"; + // Specific Troves to liquidate. Omit to sweep the riskiest `maxTroves` instead. + borrowers?: string[]; + maxTroves?: number; + maxPriorityFeePerGasWei?: string; +}; + +// The eUSD/USDC market. Not one of the seven actions issue #39 lists, but the venue's own α -- +// buying a depegged eUSD to redeem it -- is unreachable without it, and the alternative is every +// agent hand-rolling a rawTx against the pool. +export type LiquitySwapEusdAction = { + type: "liquitySwapEusd"; + // "USDC" buys eUSD from the pool; "EUSD" sells into it. + tokenIn: "USDC" | "EUSD"; + amountIn: string; // units of tokenIn (USDC is 6-decimal, eUSD is 18) + slippageBps?: number; + maxPriorityFeePerGasWei?: string; +}; + // Bundleable leaves (excluding GMX) export type BundleActionItem = | SwapAction @@ -200,7 +281,15 @@ export type BundleActionItem = | LstDepositAction | LstSwapAction | LstRequestWithdrawAction - | LstClaimWithdrawAction; + | LstClaimWithdrawAction + | LiquityOpenTroveAction + | LiquityAdjustTroveAction + | LiquityCloseTroveAction + | LiquityRedeemAction + | LiquityProvideToSpAction + | LiquityWithdrawFromSpAction + | LiquityLiquidateAction + | LiquitySwapEusdAction; // All leaf actions (including GMX. The unit of intent / buildTxs) export type LeafAction = @@ -417,6 +506,105 @@ export type LstObservation = { aaveCollateral?: boolean; }; +// Your Trove, and where it sits in the queue a redemption walks (issue #39). +export type LiquityTroveObservation = { + // Liquity's own status enum: 1 = active, 3 = closed by liquidation, 4 = closed by redemption. + status: number; + collWei: string; + // Everything the system books against you, including pending redistribution and the 200 eUSD gas + // compensation. This is the number ICR is computed from. + debtEusdWei: string; + // What you would actually have to repay to close: debt minus the gas compensation the GasPool + // holds on your behalf. Valuing a Trove against the gross debt understates it by 200 eUSD. + netDebtEusdWei: string; + // Individual collateral ratio, as a plain ratio (1.1 = 110% = MCR). + icr: number; + // The collateral price at which ICR falls to MCR -- i.e. how far the market has to move before + // this Trove is liquidatable. Zero when there is no debt. + liquidationPriceUsd: number; + // Redemptions start at the riskiest Trove and walk up. 0 means yours is next. + positionFromRiskiest: number; + // Net debt of every Trove ahead of you in that walk: the redemption volume the system can absorb + // before it reaches you. This, not ICR alone, is what "am I about to be redeemed against" means. + redeemedAheadEusdWei: string; + // Whether the walk above could be resolved. False when the list is larger than the observation + // scans, in which case positionFromRiskiest and redeemedAheadEusdWei are not meaningful. + positionKnown: boolean; +}; + +// Liquity V1 as the CDP stablecoin venue (issue #39). +// +// Four things move at once here and the observation reports them separately, because trading the +// venue means playing them against each other: +// - the system's TCR and Recovery Mode, which move *everyone's* liquidation risk at the same time +// - your own ICR and your position in the sorted list, which is what being redeemed against means +// - the two fee curves, which rise with use and decay on a ~12h half-life (so within a run they +// effectively only rise: the first large redemption prices the ones after it) +// - the market price of eUSD against the $1 the protocol will always redeem it for +export type LiquityObservation = { + // The collateral price Liquity itself marks against, from the environment's PriceFeed via the + // oracle adapter. Equal to the run's fair price, one block stale like every other oracle here. + priceUsd: number; + // System total collateral ratio, as a plain ratio. Below CCR the system enters Recovery Mode. + tcr: number; + // In Recovery Mode the liquidation threshold stops being MCR: a Trove is liquidatable once its + // ICR is under the *current TCR* (which is itself under CCR while this lasts), and only if the + // Stability Pool can absorb its whole debt. The seizure is capped at 110% of the debt, so a + // borrower over that keeps the rest as a claimable surplus. Borrowing is + // restricted. It is the venue's reflexive failure mode and it applies to everyone at once. + recoveryMode: boolean; + mcr: number; // 1.1 + ccr: number; // 1.5 + troveCount: number; + totalCollWei: string; + totalDebtEusdWei: string; + // Fee on newly drawn debt, in bps (floor 50, rises with baseRate). + borrowingRateBps: number; + // Fee taken out of the ETH a redemption pays, in bps (floor 50). Subtract it from the discount + // below to see whether redeeming is actually profitable. + redemptionRateBps: number; + baseRateBps: number; + // Floor on a Trove's net debt (1,800 eUSD) and the gas compensation added on top (200 eUSD). + minNetDebtEusdWei: string; + gasCompensationEusdWei: string; + // --- the eUSD market, against the $1 the protocol redeems at --- + eusdBalanceWei: string; + // Executable mid at probe size, in USDC per eUSD. 1.0 is par. + marketPriceUsdc: number; + marketSellPriceUsdc?: number; + marketBuyPriceUsdc?: number; + // False means the pool refused to quote (no market, or no liquidity at probe size). discountBps + // is then 0 rather than a fictitious 10000, so check this before acting on it. + marketQuoted: boolean; + // (1 - marketPrice) x 10000. Positive means eUSD trades below par: buying it and redeeming + // against the riskiest Trove converts the discount into collateral. + discountBps: number; + // discountBps minus redemptionRateBps: the edge a redemption actually captures before impact and + // gas. Negative means the fee eats the dislocation and the right move is to wait. + redemptionEdgeBps: number; + poolReserves?: { eusd: string; usdc: string }; + // --- your Trove --- + trove?: LiquityTroveObservation; + // The Trove a redemption would hit first, and its ICR: the one worth redeeming against, and the + // one worth liquidating if the price keeps falling. + riskiestTrove?: { owner: string; icr: number; netDebtEusdWei: string }; + // --- your Stability Pool position --- + spDepositEusdWei: string; + // ETH already earned from absorbed liquidations, claimable by withdrawing (any amount, including 0). + spEthGainWei: string; + spLqtyGainWei: string; + spTotalDepositsEusdWei: string; + // Your share of the pool in bps, which is your share of the next liquidation's collateral. + spShareBps: number; + // --- gas headroom --- + // Collateral is native ETH, so a Trove is funded out of the same balance that pays for gas. + ethBalanceWei: string; + // What to keep back for the rest of the run. Posting past this strands the agent: it can no longer + // send the transaction that would close the position. Self-stranding is a legitimate loss, not a + // bug, so the number is surfaced rather than enforced. + suggestedGasReserveWei: string; +}; + export type ProtocolObservations = { uniswap?: UniswapObservation; balancer?: AmmObservation; @@ -424,6 +612,7 @@ export type ProtocolObservations = { gmx?: GmxObservation; aave?: AaveObservation; lst?: LstObservation; + liquity?: LiquityObservation; }; export type AgentObservation = { diff --git a/test/events.test.ts b/test/events.test.ts index e521724..4ab5aac 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -167,6 +167,73 @@ test("liquidityPull targets its own base", () => { assert.ok(Math.abs(s.depthMultiplierAt(12).WBTC - 0.5) < 1e-9); }); +// ---- eusdDepeg (issue #39) ---- + +const FIXED_DEPEG: StressEventConfig = { + type: "eusdDepeg", + magnitudeRange: [0.4, 0.4], + windowFrac: [0.5, 0.5], + rampBlocks: 2, + holdBlocks: 2, + decayBlocks: 2, +}; + +test("eusdDepeg drives how much eUSD has been sold, and leaves the price overlay alone", () => { + const s = new EventSchedule([FIXED_DEPEG], 1, 20); + assert.equal(s.hasEusdDepeg(), true); + + // Outside the window nothing is sold, which the coordinator reads as "buy it all back". + assert.equal(s.eusdDepegFractionAt(9), 0); + assert.equal(s.eusdDepegFractionAt(16), 0); + // ramp (t=0 -> e=0.5), hold (e=1), decay (t=4 -> e=0.5) + assert.ok(Math.abs(s.eusdDepegFractionAt(10) - 0.2) < 1e-9); + assert.ok(Math.abs(s.eusdDepegFractionAt(12) - 0.4) < 1e-9); + assert.ok(Math.abs(s.eusdDepegFractionAt(14) - 0.2) < 1e-9); + + // The collateral price is untouched: this event moves a stablecoin's market, not ETH. + assert.equal(s.at(12).wethMult, 1); +}); + +test("a run with no eusdDepeg never asks for one", () => { + const s = new EventSchedule([FIXED_CRASH], 1, 20); + assert.equal(s.hasEusdDepeg(), false); + assert.equal(s.eusdDepegFractionAt(12), 0); +}); + +test("overlapping depegs add up rather than compounding", () => { + // Two actors each selling 40% of the pool have sold 80% of it, not 64%. + const s = new EventSchedule([FIXED_DEPEG, FIXED_DEPEG], 1, 20); + assert.ok(Math.abs(s.eusdDepegFractionAt(12) - 0.8) < 1e-9); +}); + +test("a depeg can be aligned with a crash, which is a different regime from either alone", () => { + const s = new EventSchedule( + [FIXED_CRASH, { ...FIXED_DEPEG, alignWith: "crash" }], + 7, + 40, + ); + const crash = s.events[0]; + assert.equal(s.events[1].startBlock, crash.startBlock); +}); + +test("selling the pool's entire eUSD side is refused: it is an outage, not a discount", () => { + assert.throws( + () => + parseStressEvents( + '[{"type":"eusdDepeg","magnitudeRange":[0.5,1.0],"windowFrac":[0.3,0.7],"rampBlocks":1,"holdBlocks":1,"decayBlocks":1}]', + ), + /magnitudeRange max must be < 1/, + ); + // And it needs a window like every other state event. + assert.throws( + () => + parseStressEvents( + '[{"type":"eusdDepeg","magnitudeRange":[0.3,0.5],"windowFrac":[0.3,0.7],"rampBlocks":0,"holdBlocks":0,"decayBlocks":0}]', + ), + /positive total window/, + ); +}); + test("alignWith puts the pull on the crash's window rather than its own draw", () => { // Same range, independent draws: in a 360-block run the two windows are nowhere near each other. const wide: [number, number] = [0.25, 0.7]; diff --git a/test/improve.test.ts b/test/improve.test.ts index 6345856..afefd0f 100644 --- a/test/improve.test.ts +++ b/test/improve.test.ts @@ -15,19 +15,21 @@ import { compileExecutor, DEFAULT_REVISE_EVERY_BLOCKS, effectiveReviseInterval, + improvePolicyState, loadImproveAgent, EXECUTOR_TIMEOUT_MS, MAX_REVISIONS_PER_RUN, parseRevision, } from "../example/agents/runtime/improve.js"; -function agentDir(improveMd: string): string { +function agentDir(promptMd: string): string { const dir = mkdtempSync(join(tmpdir(), "eris-improve-")); - writeFileSync(join(dir, "improve.md"), improveMd); + writeFileSync(join(dir, "prompt.md"), promptMd); return dir; } const FRONTMATTER = `--- +kind: improve name: test-agent description: an agent used by the tests --- @@ -48,9 +50,38 @@ test("loadImproveAgent: the participant can declare the cadence", () => { assert.equal(agent.reviseEveryBlocks, 30); }); +// The file name is reused from the retired per-decision prompt (ADR 0018 Amendment 1), and both +// formats carry the same name/description frontmatter -- so the marker is the only thing that can +// tell them apart. Getting this wrong means handing the reviser trading instructions as its brief. +test("loadImproveAgent: a prompt.md without the marker is refused, not reinterpreted", () => { + const legacy = `--- +name: arb-bot +description: Gap-driven swaps with profit-proportional priority-fee bidding +--- + +# Mission + +You are a gap-driven arbitrage bot. Swap when the gap exceeds 5bps.`; + assert.throws(() => loadImproveAgent(agentDir(legacy)), /kind: improve/); + // And the message has to say what the file is, or nobody can act on it. + assert.throws(() => loadImproveAgent(agentDir(legacy)), /prompt mode/i); +}); + +test("improvePolicyState: the old file name is a refusal, not silence", () => { + const dir = mkdtempSync(join(tmpdir(), "eris-improve-")); + assert.equal(improvePolicyState(dir), "absent"); + writeFileSync(join(dir, "improve.md"), FRONTMATTER); + // A directory that still uses the old name meant to opt in. Reading it as "absent" would run the + // strategy unrevised with nothing anywhere saying the LLM was never involved. + assert.equal(improvePolicyState(dir), "renamed"); + writeFileSync(join(dir, "prompt.md"), FRONTMATTER); + assert.equal(improvePolicyState(dir), "present"); +}); + test("loadImproveAgent: a missing name or bad cadence is an explicit error", () => { assert.throws( - () => loadImproveAgent(agentDir("---\ndescription: x\n---\nbody")), + () => + loadImproveAgent(agentDir("---\nkind: improve\ndescription: x\n---\nbody")), /"name" is required/, ); assert.throws( @@ -204,7 +235,7 @@ test("buildRevisionSystem: the model is told it is not trading, and may decline" const agent = loadImproveAgent(agentDir(FRONTMATTER)); const system = buildRevisionSystem(agent, "return null;"); assert.match(system, /You are NOT trading/); - // The participant's own instructions have to reach the model, or improve.md is decorative. + // The participant's own instructions have to reach the model, or prompt.md is decorative. assert.match(system, /Only rewrite the strategy when it is losing money/); // Declining must read as a legitimate answer, since "do not touch a winner" is the fix for the // failure mode the previous implementation had. diff --git a/test/liquity.test.ts b/test/liquity.test.ts new file mode 100644 index 0000000..e9c2a37 --- /dev/null +++ b/test/liquity.test.ts @@ -0,0 +1,585 @@ +// The CDP stablecoin venue's pricing and action surface (issue #39). +// +// Most of what could go wrong here is arithmetic that looks right. The three that would be silent: +// marking a depegged eUSD at par (which hands every holder value they cannot realize), charging a +// borrower for the 200 eUSD of gas compensation they never repay, and reporting zero for a position +// whose read failed. Each has a test below. +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Address } from "viem"; +import type { LiquityDeployment } from "@eris/sdk/constants.js"; +import { + discountBpsFrom, + icrOf, + liquidationPriceUsd, + liquityAdapter, + liquityPositionValue, + liquityValuationRun, + rateBpsFrom, + ratioFrom, +} from "@eris/sdk/protocols/liquity.js"; +import type { ValuationContext } from "@eris/sdk/protocols/types.js"; +import type { + AgentObservation, + BalanceSnapshot, + LiquityObservation, +} from "@eris/sdk/types.js"; + +const WAD = 10n ** 18n; +const USDC = 10n ** 6n; +const FAIR = 3000; + +const DEPLOYMENT: LiquityDeployment = { + troveManager: "0x00000000000000000000000000000000000c0001" as Address, + borrowerOperations: "0x00000000000000000000000000000000000c0002" as Address, + stabilityPool: "0x00000000000000000000000000000000000c0003" as Address, + sortedTroves: "0x00000000000000000000000000000000000c0004" as Address, + activePool: "0x00000000000000000000000000000000000c0005" as Address, + defaultPool: "0x00000000000000000000000000000000000c0006" as Address, + collSurplusPool: "0x00000000000000000000000000000000000c0007" as Address, + gasPool: "0x00000000000000000000000000000000000c0008" as Address, + hintHelpers: "0x00000000000000000000000000000000000c0009" as Address, + priceFeed: "0x00000000000000000000000000000000000c000a" as Address, + eusd: "0x00000000000000000000000000000000000eu5d" as Address, + lqtyToken: "0x00000000000000000000000000000000000c000c" as Address, + lqtyStaking: "0x00000000000000000000000000000000000c000d" as Address, + communityIssuance: "0x00000000000000000000000000000000000c000e" as Address, + eusdUsdcPool: "0x00000000000000000000000000000000000p0001" as Address, + eusdIndex: 0, + usdcIndex: 1, + stable: "0x00000000000000000000000000000000000u5dc" as Address, +}; + +const AGENT = { + id: "a1", + address: "0x00000000000000000000000000000000000a0001" as Address, +}; + +const GAS_COMPENSATION = 200n * WAD; + +// --------------------------------------------------------------------------- +// Liquity's fixed-point conventions +// --------------------------------------------------------------------------- + +test("ratios and fee rates come back in the units the observation promises", () => { + assert.equal(ratioFrom(1_100_000_000_000_000_000n), 1.1); // MCR + assert.equal(ratioFrom(1_500_000_000_000_000_000n), 1.5); // CCR + // A Trove with no debt reports uint256 max. Zero would read as "totally undercollateralized", + // which is the opposite of the truth, and JSON has no infinity. + assert.ok(ratioFrom(2n ** 255n) > 1000); + // 0.5% borrowing fee floor. + assert.equal(rateBpsFrom(5_000_000_000_000_000n), 50); +}); + +test("ICR and the liquidation price are two views of the same number", () => { + const coll = 2n * WAD; + const debt = 4000n * WAD; + assert.equal(icrOf(coll, debt, FAIR), 1.5); + // At MCR 1.1 the position is liquidatable once ETH is worth 4000 * 1.1 / 2. + assert.equal(liquidationPriceUsd(coll, debt, 1.1), 2200); +}); + +test("a discount is measured against par, which is what the protocol redeems at", () => { + assert.equal(Math.round(discountBpsFrom(0.99)), 100); + assert.equal(Math.round(discountBpsFrom(1.0)), 0); + // A premium is a negative discount rather than a separate concept. + assert.equal(Math.round(discountBpsFrom(1.005)), -50); +}); + +// --------------------------------------------------------------------------- +// Position value +// --------------------------------------------------------------------------- + +test("the gas compensation is not the borrower's liability", () => { + // 2 ETH against 4,200 eUSD of booked debt, 200 of which is the GasPool's. + const value = liquityPositionValue({ + holdings: { + collWei: 2n * WAD, + debtEusdWei: 4200n * WAD, + netDebtEusdWei: 4000n * WAD, + spDepositEusdWei: 0n, + spEthGainWei: 0n, + eusdBalanceWei: 0n, + }, + fairPriceUsd: FAIR, + eusdPriceUsdc: 1, + }); + // 6000 of collateral against 4000 repayable, not 4200. + assert.equal(value.valueUsdc, 2000); +}); + +test("eUSD is marked at the market, not at the dollar it is named after", () => { + const holdings = { + collWei: 0n, + debtEusdWei: 0n, + netDebtEusdWei: 0n, + spDepositEusdWei: 1000n * WAD, + spEthGainWei: 0n, + eusdBalanceWei: 9000n * WAD, + }; + const atPar = liquityPositionValue({ + holdings, + fairPriceUsd: FAIR, + eusdPriceUsdc: 1, + }); + const depegged = liquityPositionValue({ + holdings, + fairPriceUsd: FAIR, + 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); +}); + +test("a depegged debt is cheaper to close, and the realizable mark says so", () => { + const value = liquityPositionValue({ + holdings: { + collWei: 2n * WAD, + debtEusdWei: 4200n * WAD, + netDebtEusdWei: 4000n * WAD, + spDepositEusdWei: 0n, + spEthGainWei: 0n, + eusdBalanceWei: 0n, + }, + fairPriceUsd: FAIR, + eusdPriceUsdc: 0.98, + // Buying 4,000 eUSD back actually costs 3,950 once impact is included -- more than the mid + // implies (3,920), which is the whole reason the liability is quoted on the output side. + debtBuybackUsdc: 3950, + }); + assert.equal(value.valueUsdc, 6000 - 4000 * 0.98); + assert.equal(value.liquidatableValueUsdc, 6000 - 3950); + // The realizable mark is the *lower* one here: the mid flattered the position. + assert.ok(value.liquidatableValueUsdc < value.valueUsdc); +}); + +test("selling a large eUSD balance realizes less than the mid says", () => { + const value = liquityPositionValue({ + holdings: { + collWei: 0n, + debtEusdWei: 0n, + netDebtEusdWei: 0n, + spDepositEusdWei: 0n, + spEthGainWei: 0n, + eusdBalanceWei: 20_000n * WAD, + }, + fairPriceUsd: FAIR, + eusdPriceUsdc: 0.99, + longExitUsdc: 19_500, + }); + assert.equal(value.valueUsdc, 19_800); + assert.equal(value.liquidatableValueUsdc, 19_500); +}); + +test("a Trove past 100% is worth zero, not a negative number", () => { + // The borrower can stop repaying and lose only the collateral. Charging them the shortfall would + // be charging for a liability they can walk away from. + const value = liquityPositionValue({ + holdings: { + collWei: WAD, + debtEusdWei: 4200n * WAD, + netDebtEusdWei: 4000n * WAD, + spDepositEusdWei: 0n, + spEthGainWei: 0n, + eusdBalanceWei: 0n, + }, + fairPriceUsd: 2000, + eusdPriceUsdc: 1, + }); + assert.equal(value.valueUsdc, 0); + assert.equal(value.liquidatableValueUsdc, 0); +}); + +test("Stability Pool ETH gains are collateral the depositor already owns", () => { + const value = liquityPositionValue({ + holdings: { + collWei: 0n, + debtEusdWei: 0n, + netDebtEusdWei: 0n, + spDepositEusdWei: 5000n * WAD, + spEthGainWei: WAD / 2n, + eusdBalanceWei: 0n, + }, + fairPriceUsd: FAIR, + eusdPriceUsdc: 1, + }); + assert.equal(value.valueUsdc, 5000 + 1500); +}); + +// --------------------------------------------------------------------------- +// Historical valuation (issue #41's staged reads) +// --------------------------------------------------------------------------- + +function context(overrides: Partial = {}): ValuationContext { + return { + publicClient: undefined as never, + blockNumber: 100, + horizonBlock: 100, + agents: [AGENT], + activeStables: [], + fairByBase: () => ({ WETH: FAIR }), + ...overrides, + }; +} + +// Drive the generator with canned stage results, recording what each stage asked for. +async function drive( + ctx: ValuationContext, + stages: Array<(reads: unknown[]) => unknown[]>, + deployment: LiquityDeployment = DEPLOYMENT, +) { + const run = liquityValuationRun(deployment, ctx); + const asked: unknown[][] = []; + let step = await run.next(); + let i = 0; + while (!step.done) { + asked.push(step.value); + const reply = stages[i]?.(step.value) ?? step.value.map(() => undefined); + i += 1; + step = await run.next(reply); + } + return { asked, values: step.value }; +} + +// getEntireDebtAndColl returns (debt, coll, pendingDebt, pendingColl). +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, + ], + // 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); + const v = values[AGENT.id]; + // Trove 6000 - 4000 x 0.99, plus 1,000 eUSD at 0.99. + assert.equal(Math.round(v.valueUsdc), Math.round(6000 - 3960 + 990)); + assert.equal( + Math.round(v.liquidatableValueUsdc), + Math.round(6000 - 3960 + 990), + ); + assert.equal(v.unpriced.length, 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, + ], + ]); + const v = values[AGENT.id]; + assert.equal(v.valueUsdc, 0); + assert.equal(v.unpriced.length, 1); + assert.equal(v.unpriced[0].reason, "read-failed"); + assert.match(v.unpriced[0].read ?? "", /getEntireDebtAndColl/); +}); + +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 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].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], + ]); + assert.equal(asked.length, 1); + assert.equal(values[AGENT.id].valueUsdc, 0); +}); + +test("a deployment without a market skips the probe entirely", async () => { + const noMarket: LiquityDeployment = { + ...DEPLOYMENT, + eusdUsdcPool: undefined, + eusdIndex: undefined, + usdcIndex: undefined, + }; + const { asked, values } = await drive( + context(), + [() => [GAS_COMPENSATION, entire(0n, 0n), 0n, 0n, 2000n * WAD]], + noMarket, + ); + assert.equal(asked[0].length, 5); + assert.equal(values[AGENT.id].valueUsdc, 2000); +}); + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +function observation( + liquity: Partial = {}, +): AgentObservation { + const base: LiquityObservation = { + priceUsd: FAIR, + tcr: 3, + recoveryMode: false, + mcr: 1.1, + ccr: 1.5, + troveCount: 1, + totalCollWei: (250n * WAD).toString(), + totalDebtEusdWei: (250_000n * WAD).toString(), + borrowingRateBps: 50, + redemptionRateBps: 50, + baseRateBps: 0, + minNetDebtEusdWei: (1800n * WAD).toString(), + gasCompensationEusdWei: GAS_COMPENSATION.toString(), + eusdBalanceWei: "0", + marketPriceUsdc: 1, + marketQuoted: true, + discountBps: 0, + redemptionEdgeBps: -50, + spDepositEusdWei: "0", + spEthGainWei: "0", + spLqtyGainWei: "0", + spTotalDepositsEusdWei: (50_000n * WAD).toString(), + spShareBps: 0, + ethBalanceWei: WAD.toString(), + suggestedGasReserveWei: (WAD / 20n).toString(), + ...liquity, + }; + return { + kind: "observation", + runId: "r", + round: 1, + blockNumber: "1", + agentAddress: AGENT.address, + fairPriceUsdcPerWeth: FAIR, + oraclePrices: { wethUsd: FAIR, usdcUsd: 1 }, + enabledProtocols: ["uniswap", "liquity"], + balances: { + ethWei: WAD.toString(), + wethWei: (10n * WAD).toString(), + usdcUnits: (25_000n * USDC).toString(), + }, + inventory: { valueUsdc: 0, weth: 10, usdc: 25_000, eth: 1 }, + history: [], + limits: { + maxWethInWei: WAD.toString(), + maxUsdcInUnits: (5000n * USDC).toString(), + defaultPriorityFeePerGasWei: "1000000000", + maxPriorityFeePerGasWei: "5000000000", + defaultSlippageBps: 50, + maxBundleActions: 5, + maxLpWethWei: "0", + maxLpUsdcUnits: "0", + maxOpenPositions: 1, + maxGmxSizeUsd: "0", + maxAaveSupplyWethWei: "0", + maxAaveBorrowUsdcUnits: "0", + }, + protocols: { liquity: base }, + }; +} + +const BALANCES: BalanceSnapshot = { + ethWei: WAD, + wethWei: 10n * WAD, + usdcUnits: 25_000n * USDC, + bases: { WETH: 10n * WAD }, +}; + +test("every action the venue exposes round-trips through parse", () => { + const actions = [ + { + type: "liquityOpenTrove", + collateralWethWei: (2n * WAD).toString(), + debtEusdWei: (4000n * WAD).toString(), + }, + { + type: "liquityAdjustTrove", + debtChangeEusdWei: "1", + isDebtIncrease: true, + }, + { type: "liquityCloseTrove" }, + { type: "liquityRedeem", amountEusdWei: "1" }, + { type: "liquityProvideToSP", amountEusdWei: "1" }, + { type: "liquityWithdrawFromSP", amountEusdWei: "max" }, + { type: "liquityLiquidate", maxTroves: 2 }, + { type: "liquitySwapEusd", tokenIn: "USDC", amountIn: "1" }, + ]; + for (const a of actions) { + const parsed = liquityAdapter.parse({ ...a }); + assert.equal(parsed?.type, a.type, `parse dropped ${a.type}`); + } + // Something from another venue is not this adapter's. + assert.equal(liquityAdapter.parse({ type: "swap" }), null); +}); + +test("a Trove below MIN_NET_DEBT is refused before it costs gas", () => { + const result = liquityAdapter.validate( + { + type: "liquityOpenTrove", + collateralWethWei: (2n * WAD).toString(), + debtEusdWei: (1000n * WAD).toString(), + }, + observation(), + BALANCES, + ); + assert.equal(result.ok, false); + assert.match(result.ok === false ? result.reason : "", /MIN_NET_DEBT/); +}); + +test("a Trove that would open under MCR is refused, and under CCR in Recovery Mode", () => { + // 1 ETH (3,000) against 2,900 eUSD is 103% -- under MCR. + const thin = { + type: "liquityOpenTrove" as const, + collateralWethWei: WAD.toString(), + debtEusdWei: (2900n * WAD).toString(), + }; + assert.equal( + liquityAdapter.validate(thin, observation(), BALANCES).ok, + false, + ); + // 2 ETH (6,000) against 4,200 is 143%: comfortably above MCR, but under the CCR that Recovery + // Mode raises the bar to -- which is the whole point of Recovery Mode being systemic. + const ok = { + type: "liquityOpenTrove" as const, + collateralWethWei: (2n * WAD).toString(), + debtEusdWei: (4200n * WAD).toString(), + }; + assert.equal(liquityAdapter.validate(ok, observation(), BALANCES).ok, true); + assert.equal( + liquityAdapter.validate(ok, observation({ recoveryMode: true }), BALANCES) + .ok, + false, + ); +}); + +test("closing needs the eUSD to repay with, which the wallet may not have", () => { + const trove = { + status: 1, + collWei: (2n * WAD).toString(), + debtEusdWei: (4200n * WAD).toString(), + netDebtEusdWei: (4000n * WAD).toString(), + icr: 1.5, + liquidationPriceUsd: 2200, + positionFromRiskiest: 0, + redeemedAheadEusdWei: "0", + positionKnown: true, + }; + const broke = liquityAdapter.validate( + { type: "liquityCloseTrove" }, + observation({ trove }), + BALANCES, + ); + assert.equal(broke.ok, false); + const funded = liquityAdapter.validate( + { type: "liquityCloseTrove" }, + observation({ trove, eusdBalanceWei: (4000n * WAD).toString() }), + BALANCES, + ); + assert.equal(funded.ok, true); +}); + +test("redeeming and depositing are bounded by the eUSD actually held", () => { + const obs = observation({ eusdBalanceWei: (1000n * WAD).toString() }); + assert.equal( + liquityAdapter.validate( + { type: "liquityRedeem", amountEusdWei: (2000n * WAD).toString() }, + obs, + BALANCES, + ).ok, + false, + ); + assert.equal( + liquityAdapter.validate( + { type: "liquityRedeem", amountEusdWei: (900n * WAD).toString() }, + obs, + BALANCES, + ).ok, + true, + ); + assert.equal( + liquityAdapter.validate( + { type: "liquityProvideToSP", amountEusdWei: (2000n * WAD).toString() }, + obs, + BALANCES, + ).ok, + false, + ); +}); + +test("withdrawing zero from the Stability Pool claims the gain, and is only refused when there is none", () => { + const empty = liquityAdapter.validate( + { type: "liquityWithdrawFromSP", amountEusdWei: "0" }, + observation(), + BALANCES, + ); + assert.equal(empty.ok, false); + const withGain = liquityAdapter.validate( + { type: "liquityWithdrawFromSP", amountEusdWei: "0" }, + observation({ spEthGainWei: (WAD / 10n).toString() }), + BALANCES, + ); + assert.equal(withGain.ok, true); +}); + +test("buying eUSD is bounded by the USDC balance and the per-round limit", () => { + const tooBig = liquityAdapter.validate( + { + type: "liquitySwapEusd", + tokenIn: "USDC", + amountIn: (10_000n * USDC).toString(), + }, + observation(), + BALANCES, + ); + // 10,000 is affordable but over the 5,000 per-round cap. + assert.equal(tooBig.ok, false); + const fine = liquityAdapter.validate( + { + type: "liquitySwapEusd", + tokenIn: "USDC", + amountIn: (4000n * USDC).toString(), + }, + observation(), + BALANCES, + ); + assert.equal(fine.ok, true); +}); + +test("the venue accounts for eUSD and deliberately leaves LQTY visible", async () => { + // LIQUITY is null outside a local deploy, so this asserts the shape rather than the addresses: + // whatever it returns, LQTY must not be in it (issue #41's convention is that a token a venue + // issues but does not value stays reportable as unaccounted). + const accounted = await liquityAdapter.accountedTokens?.(undefined as never); + assert.ok(Array.isArray(accounted)); + assert.equal( + accounted?.some((t) => t.toLowerCase() === DEPLOYMENT.lqtyToken), + false, + ); +}); diff --git a/test/liquityAgent.test.ts b/test/liquityAgent.test.ts new file mode 100644 index 0000000..93af140 --- /dev/null +++ b/test/liquityAgent.test.ts @@ -0,0 +1,273 @@ +// redemption-arb's decision surface (issue #39). +// +// The venue is only exercised if the reference agent actually uses it, and the way this particular +// agent rots is by looking profitable while losing money: the discount it trades has to clear the +// redemption fee *and* the cost of turning the redeemed ETH back into USDC. These pin that, plus the +// two orderings that matter -- recycling proceeds before opening a new position, and choosing +// between the protocol and the pool when the peg has recovered. +import test from "node:test"; +import assert from "node:assert/strict"; +import { decideRedemption } from "../example/agents/redemption-arb/agent.js"; +import type { LiquityObservation } from "@eris/sdk/types.js"; + +const WAD = 10n ** 18n; +const USDC = 10n ** 6n; + +function liquity( + overrides: Partial = {}, +): LiquityObservation { + const discountBps = overrides.discountBps ?? 0; + const redemptionRateBps = overrides.redemptionRateBps ?? 50; + return { + priceUsd: 3000, + tcr: 3, + recoveryMode: false, + mcr: 1.1, + ccr: 1.5, + troveCount: 1, + totalCollWei: (250n * WAD).toString(), + totalDebtEusdWei: (250_000n * WAD).toString(), + borrowingRateBps: 50, + redemptionRateBps, + baseRateBps: 0, + minNetDebtEusdWei: (1800n * WAD).toString(), + gasCompensationEusdWei: (200n * WAD).toString(), + eusdBalanceWei: "0", + marketPriceUsdc: 1 - discountBps / 10_000, + marketQuoted: true, + discountBps, + redemptionEdgeBps: discountBps - redemptionRateBps, + poolReserves: { + eusd: (100_000n * WAD).toString(), + usdc: (100_000n * USDC).toString(), + }, + spDepositEusdWei: "0", + spEthGainWei: "0", + spLqtyGainWei: "0", + spTotalDepositsEusdWei: (50_000n * WAD).toString(), + spShareBps: 0, + ethBalanceWei: WAD.toString(), + suggestedGasReserveWei: (WAD / 20n).toString(), + ...overrides, + }; +} + +function input( + overrides: Partial[0]> = {}, +) { + return { + liquity: liquity(), + usdcUnits: 25_000n * USDC, + wethWei: 0n, + ethWei: WAD, + ethBaselineWei: WAD, + wethBaselineWei: 0n, + maxUsdcPerRound: 5000n * USDC, + maxWethPerRound: WAD, + canSellEth: true, + ...overrides, + }; +} + +test("at par there is nothing to trade and the agent says so", () => { + const d = decideRedemption(input()); + assert.equal(d.kind, "hold"); + assert.match(d.kind === "hold" ? d.reason : "", /off par/); +}); + +test("a discount that does not clear the fee plus the exit is left alone", () => { + // 60bps off par: the 50bps redemption fee and the 35bps exit already exceed it. + const d = decideRedemption(input({ liquity: liquity({ discountBps: 60 }) })); + assert.equal(d.kind, "hold"); +}); + +test("a discount wide enough to clear every cost is bought", () => { + const d = decideRedemption(input({ liquity: liquity({ discountBps: 150 }) })); + assert.equal(d.kind, "buy"); + if (d.kind !== "buy") return; + // Bounded by the per-round limit rather than by the 30% of balance it would otherwise take. + assert.equal(d.usdcIn, 5000n * USDC); + assert.ok(d.edgeBps > 0); +}); + +test("the purchase leaves enough discount behind to still be worth redeeming", () => { + // The shape of a real broken peg: the pool is eUSD-heavy by 40k and shows 120bps. Redeeming needs + // 85bps, so only the 15bps of headroom above that (plus safety) may be spent on impact -- which + // caps the buy far below the per-round limit. + // + // This is the bug the first live run of this agent had: it bought the whole 5,000, its own fill + // took the discount to 78bps, and the redemption it exists to perform never fired. + const depegged = liquity({ + discountBps: 120, + poolReserves: { + eusd: (140_000n * WAD).toString(), + usdc: (60_000n * USDC).toString(), + }, + }); + const d = decideRedemption(input({ liquity: depegged })); + assert.equal(d.kind, "buy"); + if (d.kind !== "buy") return; + assert.ok( + d.usdcIn < 3000n * USDC, + `expected the impact cap to bind, got ${d.usdcIn}`, + ); + // And the trade it does take is big enough to be worth the gas. + assert.ok(d.usdcIn >= 200n * USDC); +}); + +test("a discount only just above the redemption threshold is not worth taking at all", () => { + // 90bps against an 85bps threshold: any fill closes the gap, so there is nothing to buy. + const d = decideRedemption( + input({ + liquity: liquity({ + discountBps: 106, + poolReserves: { + eusd: (138_000n * WAD).toString(), + usdc: (62_000n * USDC).toString(), + }, + }), + }), + ); + assert.equal(d.kind, "hold"); +}); + +test("the purchase never exceeds a share of the pool it is trading against", () => { + // A thin pool: 10% of 2,000 eUSD is 200, which is the dust floor, so nothing is worth doing. + const thin = decideRedemption( + input({ + liquity: liquity({ + discountBps: 150, + poolReserves: { + eusd: (1000n * WAD).toString(), + usdc: (1000n * USDC).toString(), + }, + }), + }), + ); + assert.equal(thin.kind, "hold"); + // A pool ten times deeper lets the same balance through, still capped by its depth. + const deeper = decideRedemption( + input({ + liquity: liquity({ + discountBps: 150, + poolReserves: { + eusd: (30_000n * WAD).toString(), + usdc: (30_000n * USDC).toString(), + }, + }), + }), + ); + assert.equal(deeper.kind, "buy"); + if (deeper.kind === "buy") assert.equal(deeper.usdcIn, 3000n * USDC); +}); + +test("eUSD in hand is redeemed while the edge survives the exit cost", () => { + const d = decideRedemption( + input({ + liquity: liquity({ + discountBps: 150, + eusdBalanceWei: (5000n * WAD).toString(), + }), + }), + ); + assert.equal(d.kind, "redeem"); +}); + +test("a narrowed discount is held rather than sold back at a loss", () => { + // 60bps: redeeming nets 60 - 50 - 35 < 0, but the pool is still below par, so selling would + // realize the loss for no reason. + const d = decideRedemption( + input({ + liquity: liquity({ + discountBps: 60, + eusdBalanceWei: (5000n * WAD).toString(), + }), + }), + ); + assert.equal(d.kind, "hold"); + assert.match(d.kind === "hold" ? d.reason : "", /holding/); +}); + +test("above par the pool pays more than the protocol, so the inventory is sold", () => { + const d = decideRedemption( + input({ + liquity: liquity({ + discountBps: -20, + eusdBalanceWei: (5000n * WAD).toString(), + }), + }), + ); + assert.equal(d.kind, "sell"); +}); + +test("the redemption's proceeds are recycled before anything else is opened", () => { + // A wide discount *and* WETH in hand: the WETH goes first, otherwise the agent is running a + // price bet it never chose while trading a second one. + const d = decideRedemption( + input({ + liquity: liquity({ discountBps: 200 }), + wethWei: WAD, + }), + ); + assert.equal(d.kind, "unwind"); +}); + +test("the WETH a run funded is left alone; only redemption proceeds are sold", () => { + // The template config hands out 20 WETH for the LST venue. Selling it would pay the AMM's fee on + // inventory the agent never chose, and (measured on that config) an uncapped sale is rejected by + // the per-round limit every block, starving every other decision. + const funded = decideRedemption( + input({ wethWei: 20n * WAD, wethBaselineWei: 20n * WAD }), + ); + assert.notEqual(funded.kind, "unwind"); + // Proceeds on top of it are sold, in per-round-sized pieces. + const withProceeds = decideRedemption( + input({ wethWei: 23n * WAD, wethBaselineWei: 20n * WAD }), + ); + assert.equal(withProceeds.kind, "unwind"); + if (withProceeds.kind === "unwind") + assert.equal(withProceeds.wethWei, WAD); // the per-round cap, not the whole 3 +}); + +test("only the ETH above the starting endowment is tradable", () => { + // Redemption paid out 0.5 ETH on top of the 1 ETH gas endowment. + const gained = decideRedemption( + input({ ethWei: WAD + WAD / 2n, ethBaselineWei: WAD }), + ); + assert.equal(gained.kind, "wrap"); + if (gained.kind === "wrap") assert.equal(gained.ethWei, WAD / 2n); + // Without a redemption there is nothing to wrap: selling into the endowment is how an agent + // strands itself with a position it can no longer close. + const untouched = decideRedemption( + input({ ethWei: WAD, ethBaselineWei: WAD }), + ); + assert.notEqual(untouched.kind, "wrap"); +}); + +test("with no spot market the proceeds stay in ETH rather than reverting every block", () => { + const d = decideRedemption( + input({ wethWei: WAD, ethWei: 2n * WAD, canSellEth: false }), + ); + assert.notEqual(d.kind, "unwind"); + assert.notEqual(d.kind, "wrap"); +}); + +test("a pool that will not quote is not a 100% discount", () => { + const d = decideRedemption( + input({ + liquity: liquity({ marketQuoted: false, discountBps: 0 }), + }), + ); + assert.equal(d.kind, "hold"); + assert.match(d.kind === "hold" ? d.reason : "", /did not quote/); +}); + +test("a rising fee curve closes the trade that was open a moment ago", () => { + // The same 150bps discount, after somebody else's redemption pushed the fee to 120bps. + const d = decideRedemption( + input({ + liquity: liquity({ discountBps: 150, redemptionRateBps: 120 }), + }), + ); + assert.equal(d.kind, "hold"); +}); diff --git a/test/liquityBorrower.test.ts b/test/liquityBorrower.test.ts new file mode 100644 index 0000000..2225940 --- /dev/null +++ b/test/liquityBorrower.test.ts @@ -0,0 +1,336 @@ +// The borrower and underwriter sides of the CDP venue (issue #39). +// +// These two agents exist to exercise the half of the venue that only appears when the price falls, +// so the tests concentrate on the decisions that are wrong in a way a passing run would hide: a +// defence taken too late, a defence that is not available, and a liquidation called at a ratio that +// will have recovered by the time the call lands. +import test from "node:test"; +import assert from "node:assert/strict"; +import { + decideTrove, + topUpForIcr, +} from "../example/agents/trove-manager/agent.js"; +import { decideUnderwriting } from "../example/agents/sp-underwriter/agent.js"; +import type { + LiquityObservation, + LiquityTroveObservation, +} from "@eris/sdk/types.js"; + +const WAD = 10n ** 18n; +const USDC = 10n ** 6n; +const PRICE = 3000; + +function trove( + overrides: Partial = {}, + priceUsd = PRICE, +): LiquityTroveObservation { + // 4 ETH against 6,200 eUSD of booked debt: ICR 1.94 at $3,000. + const collWei = overrides.collWei ?? (4n * WAD).toString(); + const debtEusdWei = overrides.debtEusdWei ?? (6200n * WAD).toString(); + const icr = + overrides.icr ?? + ((Number(collWei) / 1e18) * priceUsd) / (Number(debtEusdWei) / 1e18); + return { + status: 1, + collWei, + debtEusdWei, + netDebtEusdWei: (BigInt(debtEusdWei) - 200n * WAD).toString(), + icr, + liquidationPriceUsd: + ((Number(debtEusdWei) / 1e18) * 1.1) / (Number(collWei) / 1e18), + positionFromRiskiest: 2, + redeemedAheadEusdWei: (100_000n * WAD).toString(), + positionKnown: true, + ...overrides, + }; +} + +function liquity( + overrides: Partial = {}, +): LiquityObservation { + return { + priceUsd: PRICE, + tcr: 2.7, + recoveryMode: false, + mcr: 1.1, + ccr: 1.5, + troveCount: 3, + totalCollWei: (280n * WAD).toString(), + totalDebtEusdWei: (300_000n * WAD).toString(), + borrowingRateBps: 50, + redemptionRateBps: 50, + baseRateBps: 0, + minNetDebtEusdWei: (1800n * WAD).toString(), + gasCompensationEusdWei: (200n * WAD).toString(), + eusdBalanceWei: "0", + marketPriceUsdc: 1, + marketQuoted: true, + discountBps: 0, + redemptionEdgeBps: -50, + poolReserves: { + eusd: (100_000n * WAD).toString(), + usdc: (100_000n * USDC).toString(), + }, + spDepositEusdWei: "0", + spEthGainWei: "0", + spLqtyGainWei: "0", + spTotalDepositsEusdWei: (75_000n * WAD).toString(), + spShareBps: 0, + ethBalanceWei: WAD.toString(), + suggestedGasReserveWei: (WAD / 20n).toString(), + ...overrides, + }; +} + +function troveInput( + overrides: Partial[0]> = {}, +) { + return { + liquity: liquity(), + wethWei: 20n * WAD, + usdcUnits: 25_000n * USDC, + blocksRemaining: 100, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// trove-manager +// --------------------------------------------------------------------------- + +test("topUpForIcr: the collateral that lifts a ratio to a target", () => { + // 4 ETH against 6,000 eUSD is 200% at $3,000. Reaching 250% needs 5 ETH, so 1 more. + const need = topUpForIcr({ + collWei: 4n * WAD, + debtEusdWei: 6000n * WAD, + priceUsd: PRICE, + targetIcr: 2.5, + }); + assert.ok(need >= WAD && need <= WAD + WAD / 100n, `${need}`); + // Already above it: nothing to add. + assert.equal( + topUpForIcr({ + collWei: 6n * WAD, + debtEusdWei: 6000n * WAD, + priceUsd: PRICE, + targetIcr: 2.5, + }), + 0n, + ); +}); + +test("opening draws the debt the target ratio allows, not what the collateral could carry", () => { + const d = decideTrove(troveInput()); + assert.equal(d.kind, "open"); + if (d.kind !== "open") return; + // 60% of 20 WETH = 12 ETH = $36,000 at 200% -> 18,000 eUSD. + assert.equal(d.collateralWei, 12n * WAD); + const debt = Number(d.debtEusdWei) / 1e18; + assert.ok(Math.abs(debt - 18_000) < 1, `${debt}`); +}); + +test("a Trove is not opened into Recovery Mode, or below the protocol's minimum", () => { + assert.equal( + decideTrove(troveInput({ liquity: liquity({ recoveryMode: true }) })).kind, + "hold", + ); + // 0.6 ETH of collateral at 200% supports 900 eUSD, under the 1,800 floor. + const tiny = decideTrove(troveInput({ wethWei: WAD })); + assert.equal(tiny.kind, "hold"); + assert.match(tiny.kind === "hold" ? tiny.reason : "", /minimum/); +}); + +test("a Trove is not opened in the last blocks of a run", () => { + const d = decideTrove(troveInput({ blocksRemaining: 5 })); + assert.equal(d.kind, "hold"); + assert.match(d.kind === "hold" ? d.reason : "", /borrowing fee/); +}); + +test("collateral goes in when the ratio drops under the floor", () => { + // 4 ETH against 8,000 eUSD at $3,000 is 150%; a fall to $2,400 makes it 120%. + const failing = liquity({ + priceUsd: 2400, + trove: trove( + { + collWei: (4n * WAD).toString(), + debtEusdWei: (8000n * WAD).toString(), + }, + 2400, + ), + }); + const d = decideTrove(troveInput({ liquity: failing })); + assert.equal(d.kind, "topUp"); + if (d.kind === "topUp") assert.ok(d.collateralWei > 0n); +}); + +test("Recovery Mode raises the bar to CCR even for a Trove that was comfortable", () => { + // 160% ICR: fine normally, inside the band that Recovery Mode makes liquidatable. + const rm = liquity({ + recoveryMode: true, + trove: trove({ + collWei: (4n * WAD).toString(), + debtEusdWei: (7500n * WAD).toString(), + }), + }); + const d = decideTrove(troveInput({ liquity: rm })); + assert.equal(d.kind, "topUp"); + assert.match(d.kind === "topUp" ? d.reason : "", /Recovery Mode/); +}); + +test("with no collateral left, debt is the other side of the same ratio", () => { + const failing = liquity({ + priceUsd: 2400, + eusdBalanceWei: (5000n * WAD).toString(), + trove: trove( + { + collWei: (4n * WAD).toString(), + debtEusdWei: (8000n * WAD).toString(), + }, + 2400, + ), + }); + const d = decideTrove(troveInput({ liquity: failing, wethWei: 0n })); + assert.equal(d.kind, "repay"); +}); + +test("sitting at the front of the redemption queue is defended, not accepted", () => { + // Nothing meaningful ahead: the next redemption reaches this Trove. + const exposed = liquity({ + trove: trove({ + positionFromRiskiest: 0, + redeemedAheadEusdWei: "0", + }), + }); + const d = decideTrove(troveInput({ liquity: exposed })); + assert.equal(d.kind, "topUp"); + assert.match(d.kind === "topUp" ? d.reason : "", /redemption queue/); + // With a wall of debt ahead there is nothing to defend against. + const shielded = decideTrove( + troveInput({ liquity: liquity({ trove: trove() }) }), + ); + assert.equal(shielded.kind, "hold"); +}); + +test("closing buys back the borrowing fee first, because that is what closing costs", () => { + // Drew 6,000 and kept all of it; the Trove books 6,200 including the fee and the gas + // compensation, so 6,000 owed against 6,000 held is still short. + const t = trove({ + collWei: (4n * WAD).toString(), + debtEusdWei: (6200n * WAD).toString(), + }); + const short = liquity({ trove: t, eusdBalanceWei: (5970n * WAD).toString() }); + const d = decideTrove(troveInput({ liquity: short, blocksRemaining: 5 })); + assert.equal(d.kind, "buyToClose"); + if (d.kind === "buyToClose") { + assert.ok(d.usdcIn > 0n && d.usdcIn < 100n * USDC, `${d.usdcIn}`); + } + // Once the shortfall is covered, it closes. + const funded = liquity({ + trove: t, + eusdBalanceWei: (6100n * WAD).toString(), + }); + assert.equal( + decideTrove(troveInput({ liquity: funded, blocksRemaining: 5 })).kind, + "close", + ); +}); + +// --------------------------------------------------------------------------- +// sp-underwriter +// --------------------------------------------------------------------------- + +function spInput( + overrides: Partial[0]> = {}, +) { + return { + liquity: liquity(), + usdcUnits: 25_000n * USDC, + wethWei: 0n, + ethWei: WAD, + ethBaselineWei: WAD, + wethBaselineWei: 0n, + maxUsdcPerRound: 5000n * USDC, + maxWethPerRound: 5n * WAD, + canSellEth: true, + ...overrides, + }; +} + +test("a Trove under MCR is liquidated, and one at the line is not", () => { + const under = liquity({ + riskiestTrove: { + owner: "0x00000000000000000000000000000000000a0002", + icr: 1.04, + netDebtEusdWei: (8000n * WAD).toString(), + }, + }); + const d = decideUnderwriting(spInput({ liquity: under })); + assert.equal(d.kind, "liquidate"); + + // 1.099 is under MCR on paper, but the oracle every agent reads is a block old: a call sent on + // that ratio can easily execute after the price has recovered, and reverts. + const edge = liquity({ + riskiestTrove: { + owner: "0x00000000000000000000000000000000000a0002", + icr: 1.099, + netDebtEusdWei: (8000n * WAD).toString(), + }, + }); + assert.notEqual( + decideUnderwriting(spInput({ liquity: edge })).kind, + "liquidate", + ); +}); + +test("underwriting is built from eUSD bought at or below par, never at a premium", () => { + const d = decideUnderwriting(spInput()); + assert.equal(d.kind, "buy"); + if (d.kind === "buy") assert.equal(d.usdcIn, 5000n * USDC); // the per-round cap binds + + const premium = decideUnderwriting( + spInput({ liquity: liquity({ discountBps: -30 }) }), + ); + assert.equal(premium.kind, "hold"); + assert.match(premium.kind === "hold" ? premium.reason : "", /premium/); +}); + +test("eUSD in hand goes into the pool rather than sitting in the wallet", () => { + const d = decideUnderwriting( + spInput({ liquity: liquity({ eusdBalanceWei: (5000n * WAD).toString() }) }), + ); + assert.equal(d.kind, "deposit"); +}); + +test("the collateral a liquidation paid is claimed and sold, in that order", () => { + // A gain worth claiming, and nothing else to do. + const gained = liquity({ spEthGainWei: (WAD / 2n).toString() }); // 0.5 ETH = $1,500 + assert.equal(decideUnderwriting(spInput({ liquity: gained })).kind, "claim"); + // Once claimed it is native ETH above the endowment: wrap, then sell. + const claimed = decideUnderwriting( + spInput({ liquity: gained, ethWei: WAD + WAD / 2n }), + ); + assert.equal(claimed.kind, "wrap"); + const wrapped = decideUnderwriting( + spInput({ liquity: gained, wethWei: WAD / 2n }), + ); + assert.equal(wrapped.kind, "unwind"); + // A dust gain is not worth the gas and the AMM fee. + const dust = liquity({ spEthGainWei: (WAD / 1000n).toString() }); // $3 + assert.notEqual(decideUnderwriting(spInput({ liquity: dust })).kind, "claim"); +}); + +test("a liquidation outranks everything else, because it is the only branch with a deadline", () => { + const both = liquity({ + spEthGainWei: (WAD / 2n).toString(), + eusdBalanceWei: (5000n * WAD).toString(), + riskiestTrove: { + owner: "0x00000000000000000000000000000000000a0002", + icr: 1.02, + netDebtEusdWei: (8000n * WAD).toString(), + }, + }); + assert.equal( + decideUnderwriting(spInput({ liquity: both, wethWei: WAD })).kind, + "liquidate", + ); +});