Skip to content

LP capital drains to zero: settlement destroys accumulated positive PnL (both sites) — fixed in d448e1c9 #127

Description

@dcccrypto

Summary

Two sites in the K/F settlement path destroy an account's accumulated positive PnL far beyond what the arriving loss justifies. On a market whose liquidity provider is settled on a keeper cadence, this converts price volatility into permanent capital loss: the LP's capital becomes monotonically non-increasing and drains to zero regardless of price direction.

Fixed in d448e1c. This issue records the full investigation, because the conclusion reversed twice before landing and the reasoning matters.

The defect

Site 1 — apply_haircut_bounded_close_loss_to_pnl

if remaining_loss != 0 { junior_face_burned = old_positive_face; }

Any under-supported loss, however small, burned the account's entire positive face — and the loss was still charged to capital. A double charge.

Site 2 — apply_signed_kf_delta_to_pnl

junior_face_burned = new_face_support;

A gain arriving at a negative-PnL account was burned in full whenever any of the pre-existing loss stayed uncovered. source_credit_state_realizable_support_for_face returns 0 whenever positive_claim_bound_num == 0, and an underwater account has no positive claims by definition — so support was structurally always 0. An account that went underwater could never recover, no matter how far the price moved back in its favour, and no matter how much backing existed.

Evidence it is real (measured, not modelled)

1. Live on the deployed program. 25 minutes, 935 reads, zero errors. Price moved +391 units in the LP's favour; its equity fell $24.23 (should have risen $14.24). Four discrete destruction events:

profit held loss that arrived profit destroyed
$13.078319 $0.072860 $13.005459
$8.451720 $0.036430 $8.415290
$10.309645 $0.072860 $10.236785

A control inside the same recording: a genuine 111-unit adverse move with PnL already at 0 was charged $4.043716 — the honest amount, to the atom. The arithmetic is fine; the defect is confined to what happens to profit at a direction change.

2. On-chain ledger identity, 3/3 markets, to the cent. loss-domain fresh_reserved = $100.000000 seed + residual_crystallized_loss.

3. An accidental control. Two LPs, same keeper, same cadence, same hour — one with an exhausted backing pot, one funded. Error vs honest: −$38.47 versus −$0.000005.

4. Site 2, isolated. Drive an account underwater, then move the price $1,242 in its favour across 150 settlements$0.00 credited. Identical with a $50,000 funded pot. Post-fix the same probe recovers to +$1,207.25.

Why the fix is the correct semantics

A gain that merely nets against an outstanding loss is not a new claim — it is the counterparty's loss being realized against a debt the account already owes — so it requires no source backing. Only the excess above zero becomes a new claim, and that stays gated by realizable support, preserving the realizable-limited thesis.

spec.md v16.9.0 supports this directly. §2.2 says a round-trip that recovers the mark "settles as fresh positive PnL … so the recovered value returns to the account through realization", and that there is "no unrealized-drawdown ratchet and no double-charge". The realization-extent rule burns "the CONSUMED face". Both sites contradicted that.

Verification

  • tests/lp_burn_repro.rs — 28/28. Drives the real crank entry point (permissionless_crank_not_atomic / Refresh) with risk parameters read off a live market, emulates SVM rollback around the _not_atomic calls, and asserts honesty against the price the LP's own leg was settled to on every run, so no test can pass vacuously.
  • Adversarial battery: nonzero trading fees, 3 traders, mid-run position changes, real liquidation, multi-asset cross-domain, nonzero funding, Resolved/Recovery mode, and the exact site-2 boundary (gain == debt).
  • Mutation tested: reverting both fixes fails 16 tests; reverting either alone fails a disjoint set; restoring passes 28/28.
  • Engine suites: v16_spec 49, v16_fork_lp_vault 29, backing_double_claim 6, resolved_insolvent 2 — 0 failures.
  • Kani: 139-proof value-safety sweep → 138 pass. The single failure (proof_v16_public_backing_fee_charges_only_selected_domain) is pre-existing and fails identically with both fixes reverted. Both new asymmetric-A accrual proofs pass.

Conservation is preserved: unbacked positive PnL stays non-convertible and non-withdrawable while exposure is open, and the vault never exceeds deposits — including when the counterparty is bankrupted.

Corrections made during the investigation

Recording these because two earlier write-ups were wrong in opposite directions:

  • An earlier harness "settled" via sync_account_fee_to_slot_not_atomic — a fee routine that never settles a leg, with the slot number passed as the fee rate. Its original finding and its later retraction were both artifacts of it.
  • A reported "$0–$242 residual at high volatility" was my own measurement error, not the engine. The per-slot price clamp rejects steps, so the engine never reaches the path endpoint. Measured per settlement, the engine diverges ≤1 atom. The correct reference is the price the LP's leg was actually settled to, derived from k_snap.
  • A claim that the LP "cannot cash out" was wrong. Conversion is blocked only while exposure is open, by design. After closing, the LP is paid in full — measured $1,003.00 out on a $1,000 deposit, with an empty backing pot.

What this does NOT fix

  1. The wrapper path is unexercised. Every test drives the engine library directly; account plumbing, matcher, CPI and CU limits are untested. This should be closed before any deployment.
  2. A structural backing issue remains. An account's gains draw support from the opposite-side domain, but the LP vault is bound to a single domain at creation. An LP that ends up on the wrong side has a gain domain that no instruction can fund. The engine fix stops the destruction of profit; it does not create the backing.
  3. Still unexercised: ADL/socialization, B-settlement (SettleB, b_stale), insurance-backed liens, epoch resets, overflow magnitudes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions