Skip to content

Liquidity-pull stress event: withdraw pool depth during crash windows (#52) - #58

Merged
adachi-440 merged 7 commits into
mainfrom
feat/liquidity-pull-stress-event
Aug 11, 2026
Merged

Liquidity-pull stress event: withdraw pool depth during crash windows (#52)#58
adachi-440 merged 7 commits into
mainfrom
feat/liquidity-pull-stress-event

Conversation

@adachi-440

Copy link
Copy Markdown
Contributor

Closes #52.

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.

What the event is

A liquidityPull withdraws a fraction of the environment-owned depth for the length of a window and puts it back afterwards, on the same seed-sampled trapezoid as spike/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 onBlock drops a notification, which is the failure that once swallowed the whole lstSlash axis.

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.

alignWith had to be added. 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 stated rather than hoped for.

Measured (50% pull, test/liquidityPullDepth.test.ts)

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)

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-incident thins 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.liquidity is now in the observation, and documented in writing-agents.md. Agents cannot manage size against depth they cannot see, and absent ≠ "0" when the read fails.
  • ADR 0017's Status now records 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 it is annotated rather than superseded.
  • CI replays crash against a freshly deployed chain and asserts all three venues thinned and came back. calm carries 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 implement ALL_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: applied was committed from a mempool submission that was never confirmed (so an on-chain revert was invisible and never retried), and every restore landed short because increaseLiquidity derives 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

adachi-440 and others added 7 commits August 11, 2026 23:53
…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>
@adachi-440
adachi-440 merged commit 0ae9050 into main Aug 11, 2026
2 checks passed
@adachi-440
adachi-440 deleted the feat/liquidity-pull-stress-event branch August 11, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[sim] Liquidity-pull stress event: withdraw pool depth during crash windows

1 participant