Curve StableSwap Depeg Cascade: From Oracle Shock to Liquidation Wave
Curve StableSwap is the AMM that lives on the credibility wall — invariant tuned for pegged pairs, fragile in exactly the regime where everyone needs it to work. Modeling a depeg cascade through Curve requires the StableSwap invariant, an oracle shock, and an agent population that responds. Here is the slice.
Part of the Protocol coverage series.
Curve StableSwap is the only adapter in Mayavi's protocol library that isn't a borrower. Aave V3, SparkLend, Compound V3 — all CDP-shaped protocols where a user deposits collateral, borrows against it, and faces liquidation if the health factor crashes. Curve is an AMM. The agent population on a Curve scenario isn't borrowers being liquidated — it's liquidity providers, arbitrageurs, and the swap traffic that runs through the pool when the peg breaks.
Phase 5 ships one Curve scenario today: a mainnet StableSwap depeg cascade. This post walks the StableSwap invariant, the oracle-shock injection that triggers the depeg, and the cascade dynamics that follow.
StableSwap, not CryptoSwap
Curve runs two different AMM invariants. StableSwap is tuned for pegged-asset pools (3pool USDC/USDT/DAI, FRAX/USDC, crvUSD/USDC) — the swap curve leans toward constant-sum (1:1 exchange) when the prices are near peg, and bends toward constant-product (Uniswap V2-style) as they drift apart. CryptoSwap is for volatile-asset pools (tricrypto, ETH/BTC variants) and uses a different invariant entirely. Mayavi's adapter targets StableSwap; CryptoSwap is documented but out of Phase 5 scope.
The StableSwap invariant is fragile in exactly the regime where it matters most: small price moves around the peg are absorbed by the curve's near-constant-sum region, but once one asset depegs sharply, the curve drains liquidity from one side faster than arbitrageurs can refill. By the time the pool re-prices, the depeg has already moved through every downstream protocol that took the StableSwap price as oracle truth.
That's the cascade. Modeling it requires:
- The StableSwap invariant — quote a swap, execute it, read the pool's
balancesandvirtual_price. - A way to trigger the depeg — an oracle shock injected into the contracts that downstream protocols read.
- An agent population whose behaviour depends on the oracle truth (typically: borrowers facing liquidation when their collateral repriced).
The Curve adapter
mayavi/protocols/curve.py wraps the StableSwap pool contract. The public surface is small and reads like the Uniswap V3 Quoter pattern from Post 2:
class CurvePool:
def quote(self, i: int, j: int, dx: int) -> int:
"""get_dy(int128 i, int128 j, uint256 dx) — static call, no state."""
def exchange(self, i: int, j: int, dx: int, min_dy: int) -> CallResult:
"""exchange(int128 i, int128 j, uint256 dx, uint256 min_dy)."""
def balances(self) -> list[int]:
"""[balances(uint256 i) for i in range(n_coins)]."""
def virtual_price(self) -> int:
"""get_virtual_price() — invariant value, drifts up with fees."""Two index-type subtleties carry inline comments because they're trap-prone:
int128for swap indices,uint256for getters.exchangeandget_dyuseint128 i, int128 j;balances(uint256)andcoins(uint256)useuint256. Some older StableSwap pools useint128forbalances/coinstoo — supporting those is a Phase 6 parameterisation, not a Phase 5 bug.- N is pool-specific. A pool's
add_liquiditytakesuint256[N]where N is the coin count (3 for 3pool, 2 for FRAX/USDC). The encoders infer N fromlen(amounts);CurvePoolrecordsn_coinson the instance so callers and the arg-length check have it.
Validation is the Quoter-bit-exact pattern from EIGEN, repointed at Curve: static-call get_dy, execute the same exchange, assert outputs match exactly. Curve's own published Vyper reference implementation is the source of truth.
The depeg-cascade scenario
The Curve depeg cascade lives at mayavi/scenarios/depeg_cascade.{py,yaml}. The setup, on mainnet at the pinned block:
- Five leveraged WETH-long Aave V3 borrowers — same shape as the Aave depeg-cascade scenarios from Post 4.
- Initial state: each supplies 1 WETH (~$2,562 collateral at the pinned block), borrows $1,700 USDC. Starting health factor ≈ 1.28.
- A
BorrowerAgentper position, target HF 1.5, rebalance threshold 1.2. - A liquidator wallet funded with $10M USDC headroom, hosting five
LiquidatorAgents. - At step 3 of an 8-step horizon (1 day per step, 7,200 blocks warp), a one-shot
OracleShockAgentoverrides AaveOracle's reported USDC price to $1.50 (50% spike — modeling a chaotic stablecoin flight-to-quality event).
After the shock, every borrower's HF crashes well below 1.0. Borrower agents try to repay back to target but their cash on hand from the initial borrow is short. The liquidators fire liquidationCall on their assigned borrowers in the next step.
What we assert end-to-end:
- ≥ 1 successful
liquidateaction recorded. - Exactly one
oracle_shockevent at the configuredshock_step. - The cascade completes within the horizon — no agent stuck in a step-internal deadlock.
This is the Aave-side bundle:
What the Curve bundle adds
The Aave depeg cascade tells one half of the story: what happens downstream of the depeg. The Curve bundle tells the upstream half — what the StableSwap pool itself does when a coin moves away from peg, and how the pool's quoted price compares to the (artificially shocked) oracle truth that the downstream protocols are reading.
A scenario that chains these together — one shock that fires both into Curve's pool composition and into Aave's oracle simultaneously, then watches the cascade flow through both pools in the same run — is what cross_protocol_shock does. The scenario composes three existing agents (OracleShockAgent + Aave V3 BorrowerAgent/LiquidatorAgent + a 3pool CurveDepegAgent) onto a shared mainnet fork at the same pinned block and step them under one shock event. The compose layer is in mayavi/scenarios/cross_protocol_shock.py; the YAML at mayavi/scenarios/cross_protocol_shock.yaml wires 3 Aave borrowers + 2 Curve sellers + a USDC oracle shock to $1.50 at step 3 of an 8-step horizon.
What this composed run buys over the two single-protocol bundles above:
- Same fork, same block, same shock step. The Aave borrowers and the Curve sellers see the same chain state and the same oracle event; there is no scenario-level skew to hand-wave.
- One scheduler, one timeline. The cascade's temporal shape (borrower repayment attempts pre-shock, oracle override at step 3, liquidator + StableSwap responses post-shock) lives in one PSUB schedule rather than two separately-rendered runs.
- No new protocol code. The adapters in
mayavi/protocols/aave_v3.pyandmayavi/protocols/curve.pyare unchanged — the cross-protocol composition is a scenario-layer construction. Adding Compound V3 to the same shock would be one more agent block in the YAML, not a protocol rewrite.
Where this leaves the protocol-coverage theme
Four posts, four protocols, twelve committed bundles. The shape of the work to come is now clear:
- The credibility wall (Post 2) holds. Every assertion in this theme runs against real on-chain state at a pinned block.
- The protocol adapters scale. Adding a fifth protocol is the same recipe: thin Python wrapper, dispatch table keyed by
chain_id, real-fork validation tests, scenario YAML. - The dispatch architecture catches its own bugs. The BNB decimal trap, the seconds-per-block silent miscalibration, the balance-slot mis-registrations — all caught by fail-loud per-chain tests, not silent fallbacks.
The next theme — RL agent reports — is what happens when we train a policy against this surface. Post 8 — Training a PPO Borrower walks Aave V3 borrower training across two backends (local GTX 1650 vs Modal A10G) and a fresh 200K-step run done specifically for this post.