Liquidity-pull stress event: withdraw pool depth during crash windows (#52) - #58
Merged
Merged
Conversation
…ice gaps The crash regime moved the price and nothing else: every pool kept its block-0 depth for the whole run, so an arb that paid off in `calm` paid off in `crash` at the same execution cost, only bigger. That made regime 6 a *larger-opportunity* regime rather than a crash, and left one axis untested — whether an agent can size into a market that has stopped being able to absorb it (issue #52). **A state, not an overlay.** spike/crash are pure functions of the block index and the price writer applies the multiplier. Depth has to actually leave the pool, so the trapezoid drives a *target depth* the coordinator reconciles against every block. Removing once and restoring once would strand the pool at the wrong depth whenever `onBlock` skips a notification — the failure that once let a dropped block swallow the whole lstSlash axis. **Not a freebie.** The withdrawal is proportional (both sides at the pool's current ratio), so the mid does not move and no risk-free edge opens. What changes is the cost of size, which is the half of a crash that was missing: the gap says what is on offer, the depth says how much of it anyone can take. **alignWith.** The same windowFrac range is not the same window — two events sampling [0.25, 0.7] of a 360-block run land ~160 blocks apart, so a pull configured next to a crash would almost never thin the book while the price gapped. The composition is a property of the pair, so it is now stated rather than hoped for. increaseLiquidity takes token amounts, not a liquidity target, so restoring exactly what was taken needs Uniswap's TickMath/LiquidityAmounts — ported in tickMath.ts and pinned against the reference contract's fixed points. Fails fast on a fork (the seeded pools belong to real Arbitrum LPs, not to the run) and on a roster that binds an agent to AGENT0_PRIVATE_KEY (the deployer's own key: two senders on one key race on the nonce). Uniswap observations now carry in-range depth. Agents cannot manage size against depth they cannot see, and depth is exactly what this event moves. Verified on the state dump (crash#606, 45 blocks, all five venues): - pool depth follows the target block by block (5.48e16 -> 2.93e16 -> 5.48e16) - the window closes on the seeded value exactly (drift 0 bps) - the no-arb monitor fires identically with and without the pull (same 2 warnings, same blocks, 143.5/64.3 vs 143.5/64.7 bps), and the first one lands before the window opens NOT CALIBRATED (issue #52 phase 3): the two magnitudes interact — a thinner book moves price further for the same flow — and the crash magnitude is still the one calibrated against block-0 depth. Balancer/Curve are phase 2. Participant LP marks (#41) and the victim health-factor calibration are untested against a moving book. Also records in ADR 0017 that the competition itself is one continuous economy with injected events, not the scenario matrix that ADR describes. The matrix survives as the practice/backtest path (ADR 0016), so the ADR is annotated rather than superseded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nst what was submitted Review of 6cb5a04. The reconcile loop trusted its own submissions, which breaks in two ways that are both on the normal path rather than in a corner: **A revert was invisible and never retried.** `sendNoMine` returns when anvil accepts the transaction, not when it succeeds. `applied` was set to the new target on that acceptance, so an on-chain revert (deadline, gas against the fixed RECONCILE_GAS, a token float that ran dry) left the pool at its old depth while this run believed the target had landed — `target === applied` on every later block, delta never recomputed. The catch only ever saw submission errors, contradicting the comment beneath it. **Every restore landed short.** `increaseLiquidity` derives liquidity from token amounts at *execution* price and takes the minimum of the two sides. The amounts came from a `slot0` read one block earlier, so the shortfall is roughly the price move in between — and the decay leg runs precisely while a crash is recovering, so the price is moving by construction. With `applied` set to the full target, that shortfall was permanent and reported as complete. Both fall out of the same change: hold the submitted hash per position, confirm the receipt on the next block, and re-read `positions(tokenId)` to resynchronize `applied` with the depth the position actually holds. A shortfall becomes the next block's delta; a revert re-derives the same delta and retries; a write still in the mempool leaves the position alone rather than withdrawing the same depth twice. A submission stuck past PENDING_TIMEOUT_BLOCKS is abandoned and resynchronized — safe precisely because deltas are re-derived from actual depth, so a late landing is corrected rather than compounded. Restores are capped at MAX_RESTORE_ATTEMPTS, since each is computed at a price that has since moved and could otherwise re-send for the rest of the run. Restore verification now reports instead of checking, and only once nothing is in flight. It previously anchored to the last block the window was *open*, so a dropped notification could make it read the position in the same call that submitted the restore, log the entire pull as `restore_incomplete`, and never re-check. **Teardown.** `EventSchedule` clamps `startBlock` to `runBlocks - span`, so `endBlock` can equal `runBlocks` and the only block that could issue the restore is the last one; the time limit can also cut in mid-window. The matrix runner's snapshot/revert hid this, but `sim:realtime` on a shared anvil would hand the next run a thinner venue. Restore after the block loop (mined — there is no next block to settle on), with agents already stopped so it cannot be traded against. **A failed depth read is no longer depth of zero.** `types.ts` documents "a pool whose depth could not be read reports no depth rather than 'zero depth'"; `.catch(() => 0n)` did the opposite, and an empty book is a state liquidityPull deliberately cannot produce (magnitude is exclusive of 1). The key is now omitted. **alignWith fails fast instead of silently mis-composing.** A follower whose trapezoid is longer than its anchor's cannot start where the anchor does when the anchor sits near the end of a run; sliding it earlier would un-align the pair, which is the one thing alignWith exists to guarantee. Chained alignment is refused rather than resolved according to the order the pass happens to visit events. Also: the coordinator's task-level failure is `stress_liquidity_task_failed`, distinct from liquidity.ts's per-position `stress_liquidity_pull_failed` — "no position was reconciled" is the case post-run analysis most needs to see, and it was being bucketed under an undefined market. And the determinism test asserted `f(x) === f(x)`, which passes for a `Math.random()` implementation; it now pins exact expected values. Verified on the state dump (crash#606, 45 blocks, five venues): depth follows the target block by block with the read-back value matching each previous target, no stuck or reverted writes, restored to seeded exactly (drift 0 bps), teardown a no-op. The shortfall path did not fire in this run — the price moved too little — so it is covered by construction rather than by measurement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…agent authors depth moves Two gaps the review of 6cb5a04 found in what that commit claimed. **The venue coverage bounds the effect, and the docs said the remaining work was calibration.** `crash.yaml` enables five venues and thins one. Both non-baseline agents in its own roster are cross-venue arbitrageurs, so they can route size to Balancer or Curve during the window and pay close to the pre-crash cost of size. Regime 6 is therefore still closer to "is there a gap?" than to "how much of it can I take?" — weaker than the version the commit message described. Recorded in CLAUDE.md, crash.yaml, public.yaml, backtest.md and ADR 0017 alongside the calibration caveat. **Participants were never told depth is now observable.** `writing-agents.md` is the document agents are written against, and the new `pool.liquidity` field appeared only in an SDK type comment and in CLAUDE.md. The regime asks a question that cannot be answered without in-range depth, so the field is now in the observation sample and in the reading notes — including that it is absent rather than "0" when the read fails, and that Balancer/Curve keep block-0 depth for now. Given the recurring "PR-derived strategy reads obs.pool directly and TypeErrors into a noop" failure, an undocumented new field is where that repeats. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ll looking like a price event Three structural points from the review of 6cb5a04, no behaviour change to the pull itself. **Event classification was two independent opt-in Sets over one union.** Adding a sixth StressEventType and forgetting either `POINT_EVENT_TYPES` or `PRICE_OVERLAY_TYPES` compiles, typechecks, and yields a schedule that logs itself in `stress_schedule` and then does nothing — which is the shape of the `pointEventsAt` regression already recorded in this file. Replaced with a total `Record<StressEventType, "overlay" | "point" | "state">`, so the compiler demands the decision and both predicates derive from it. **`activeEventAt` still meant "any window is open".** The victim health-factor read gates on it, and since a liquidityPull window is open while the price is untouched — with a trapezoid deliberately longer than the crash's — it fired on blocks where nothing had moved, against a comment promising the opposite. Callers that mean dislocation now use `activePriceEventAt`; `activeEventAt` keeps its any-kind meaning for logging and visualization. **Position discovery was 2N sequential round trips.** `tokenOfOwnerByIndex` then `positions` awaited per NFT, plus a third serial pass for balances — all independent reads, and every scenario in a matrix run pays them before its first block. Now two dependent rounds of `Promise.all`. The approvals stay sequential on purpose: they come from one key and each mines a block, so concurrent sends would race on the nonce. Verified on the state dump (crash#606, 45 blocks, five venues): same 15 reconcile writes on the same blocks, restored to seeded exactly, and no victim reads (this regime has no victims, so the gate change is exercised by lending-incident rather than here). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1 withdrew depth from Uniswap while Balancer, Curve, GMX and Aave kept their block-0 depth for the whole run. Both non-baseline agents in the crash regime's own roster are cross-venue arbitrageurs, so they could route around the thin book and pay close to the pre-crash cost of size — the regime still largely asked "is there a gap?" rather than "how much of it can I take?". The reconcile loop is now venue-agnostic, because all three venues expose the same thing: a proportional claim the environment owns, measured in its own unit (position liquidity, BPT, LP balance). Half the claim is half the depth, and giving it up returns both sides at the pool's current ratio, so the mid does not move. The one-coin exits (`remove_liquidity_one_coin`, EXACT_BPT_IN_FOR_ONE_TOKEN_OUT) would move it, which is why they are deliberately absent. **`venue` on a liquidityPull now defaults to every enabled venue.** Narrowing is opt-in: thinning one book while the others stay deep just relocates execution, which is the defect this phase exists to close. **Balancer's proportional join is built from balances, not named as a BPT amount.** ALL_TOKENS_IN_FOR_EXACT_BPT_OUT (kind 3) would have said exactly how much BPT to mint, but this deployment's WeightedPool does not implement it — every restore reverted for fifteen consecutive blocks, leaving the pool at 53% depth for the rest of the run and failing teardown too. Probed against the state dump: kind 3 reverts, kind 1 (EXACT_TOKENS_IN_FOR_BPT_OUT) succeeds. The amounts are scaled from the pool's own balances, which is a balanced deposit and so pays no swap fee. Curve's `add_liquidity` is built the same way. Both therefore land slightly short when the price moves between the read and the mine, which the settle-against-chain loop already carries into the next delta. Verified on the state dump (crash#606, 45 blocks, five venues): all three books thin to 53.5% of seeded together and all three return to seeded with drift 0 bps, no reverted, stuck or failed writes. Depth measured on chain, not inferred from the targets: uniswap 5.48e16 → 2.93e16, balancer 1.10e23 → 5.86e22, curve 5.48e22 → 2.93e22. One observation for phase 3: the persistent no-arb monitor now fires three times rather than two, and on a different pair. That is expected rather than alarming — a thinner book makes the same arbitrage more expensive to take, so dislocation survives longer — but it is a claim about the monitor's thresholds that has not been separated from the crash's own effect. Doing that needs a pull-without-crash run, which belongs with the slippage measurement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…me's books too (#52 phase 3) Measured on the state dump rather than argued from the shape of the code. `test/liquidityPullDepth.test.ts` pulls 50% of every book and quotes two sizes before and after, so the two claims the event rests on are pinned to numbers: uniswap price move 0.10bps | cost of 10 WETH 98.6 -> 195.3bps (x1.98) balancer price move 0.10bps | cost of 10 WETH 98.6 -> 195.3bps (x1.98) curve price move 0.00bps | cost of 10 WETH 17.2 -> 71.5bps (x4.15) The price a small trade sees is unchanged, which is the property that keeps this a constraint rather than a free arbitrage (ADR 0007). The cost of size roughly doubles, and quadruples on Curve, whose crypto-pool impact is far more convex. Uniswap and Balancer agreeing to the decimal is a sanity check, not a coincidence: a full-range V3 position and a 50/50 weighted pool seeded to the same depth are the same constant product. **The crash magnitude was left alone, against expectation.** Phase 1 recorded that the two magnitudes must interact — a thinner book should make the same flow move price further — and that the crash had therefore been calibrated against block-0 depth. Measured against a pull-free control on the same seed, the pool-fair dislocation inside the window went 2087 -> 2100bps. In a 45-block run with three agents the dislocation is already almost entirely the crash's own; what the thin book changes is the cost of *taking* it. Worth re-checking at R=360 with a full roster, where more of the gap is arbitraged away. The claim is retracted in ADR 0017 and in crash.yaml rather than left standing. **lending-incident now thins its books on the crash window.** Depth is what decides whether a liquidator can unwind the collateral it seizes, and with a deep book taking a breached position is close to riskless. Verified against a control on seed 202: min health factor 0.9479 in both, two liquidations on the same two blocks in both, and the liquidator's score identical to the cent — health factors are set by the oracle, not by pool depth, so the ADR 0009 §4 calibration holds. The measurable effect today is nil, because the shipped liquidator never sells what it seizes; the regime describes the environment, not the strategies currently in it, and a liquidator that does unwind should meet a thin book. **#41 LP marks stay sane while depth moves.** An LP agent through the window reports no `unpriced` holdings and a smooth value series (354.9k -> 284.2k -> 350.4k, tracking the crash's beta rather than the withdrawal). **No-arb monitor.** The 0.1bps price move above is the answer to phase 2's open question: a proportional pull does not create cross-venue dislocation, so the extra persistent warning is the monitor observing that a thinner book makes existing dislocation more expensive to arbitrage away — the intended effect, not a false positive. These are anvil numbers on a 45-block run. The competition targets a real chain, so the ratios are the finding; the absolutes will need re-measuring once the platform settles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The deploy+backtest job replayed only `calm`, which carries no stress events, so nothing in CI touched the one part of the overlay that writes to the *venues* rather than to the price — even though `config/regimes/**` and `sdk/src/protocols/**` are already in its path filter, so it was running and proving nothing about this. That gap is not theoretical. Everything in issue #52 was verified against a state dump generated in August from a different commit, and the Balancer join had to be rebuilt because this deployment's WeightedPool does not implement ALL_TOKENS_IN_FOR_EXACT_BPT_OUT — a mismatch invisible until a real pool refuses the call. This job is the only place that puts the withdrawal in front of venues the deployer just built. 80 blocks rather than the 12 used elsewhere: the pull's trapezoid is 21 blocks and its start is drawn from windowFrac's upper bound, so a shorter run cannot hold the window and fails fast instead of silently mis-composing the pair. Asserts what the exit code cannot: that depth was withdrawn from all three venues, that no write failed or reverted, and that every venue came back to its seeded depth. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #52.
The
crashregime moved the price and nothing else: every pool kept its block-0 depth for the whole run, so an arb that paid off incalmpaid off incrashat the same execution cost, only bigger. That made regime 6 a larger-opportunity regime rather than a crash, and left one axis untested — whether an agent can size into a market that has stopped being able to absorb it.What the event is
A
liquidityPullwithdraws a fraction of the environment-owned depth for the length of a window and puts it back afterwards, on the same seed-sampled trapezoid asspike/crash.It is a state, not an overlay. spike/crash are pure functions of the block index and the price writer applies the multiplier. Depth has to actually leave the pool, so the trapezoid drives a target depth the coordinator reconciles against every block — removing once and restoring once would strand the pool at the wrong depth whenever
onBlockdrops a notification, which is the failure that once swallowed the wholelstSlashaxis.It is a constraint, not a freebie. The withdrawal is proportional: the environment gives up a fraction of its own claim and gets both sides back at the pool's current ratio. Uniswap calls that claim position liquidity, Balancer calls it BPT, Curve calls it an LP balance, and half the claim is half the depth in each. Measured: a small trade's executable price moves ≤0.1bps with half the book gone.
alignWithhad to be added. SamewindowFracrange is not the same window — two events sampling[0.25, 0.7]of a 360-block run land ~160 blocks apart, so a pull configured next to a crash would almost never thin the book while the price gapped. The composition is a property of the pair, so it is stated rather than hoped for.Measured (50% pull,
test/liquidityPullDepth.test.ts)Uniswap and Balancer agreeing to the decimal is a sanity check rather than a coincidence: a full-range V3 position and a 50/50 weighted pool seeded to the same depth are the same constant product. Curve's crypto pool is far more convex.
One expectation was wrong and is retracted in the ADR. Phase 1 recorded that the two magnitudes must interact — a thinner book should make the same flow move price further — and that the crash magnitude therefore needed re-calibrating. Against a pull-free control on the same seed, the pool-fair dislocation inside the window went 2087 → 2100bps, i.e. unchanged. In a 45-block run with three agents the dislocation is already almost entirely the crash's own; the thin book changes the cost of taking it. Worth re-checking at R=360.
Also
lending-incidentthins its books on the crash window too. Verified against a control on seed 202: identical min health factor (0.9479), same two liquidations on the same blocks, identical liquidator score — health factors come from the oracle, not from depth, so the ADR 0009 §4 calibration holds. The measurable effect today is nil because the shipped liquidator never sells what it seizes; the regime describes the environment, not the strategies currently in it.protocols.uniswap.pool.liquidityis now in the observation, and documented inwriting-agents.md. Agents cannot manage size against depth they cannot see, and absent ≠"0"when the read fails.crashagainst a freshly deployed chain and asserts all three venues thinned and came back.calmcarries no stress events, so nothing in CI touched this path — and the Balancer join had to be rebuilt because this deployment's WeightedPool does not implementALL_TOKENS_IN_FOR_EXACT_BPT_OUT, exactly the kind of mismatch only a real pool reveals.Review
A review of the first commit found two defects that are fixed in
88bcd4b, both on the normal path:appliedwas committed from a mempool submission that was never confirmed (so an on-chain revert was invisible and never retried), and every restore landed short becauseincreaseLiquidityderives liquidity from token amounts at execution price. Both fall out of settling against the chain — hold the hash, confirm the receipt, re-read the position — which also gave the teardown a correct base.Not done
🤖 Generated with Claude Code