Summary
PartyA liquidation is supposed to settle from the accounting snapshot that made PartyA liquidatable. That snapshot includes PartyA UPNL, liquidation prices, and the funding debt implied by those prices and funding accumulators.
Before v0.8.6, the price-only liquidation path froze symbol prices from the Muon liquidation signature, but accumulated
funding could still be derived from funding storage later, when liquidatePositionsPartyA was called. If an epoch
passed and funding was rolled, or if epoch duration changed after funding accrued, settlement could mix frozen liquidation
prices with the wrong funding value.
v0.8.6 adds a Muon-signed snapshot liquidation path. Muon now signs PartyA historical insolvency data plus the price and
cumulative funding values needed for each (PartyB, symbolId) pair.
Affected scenario
The risky flow was:
- PartyA becomes liquidatable.
- Liquidator calls
liquidatePartyA(partyA, liquidationSig). - Liquidator calls
setSymbolsPrice(partyA, liquidationSig). - An epoch passes and funding is rolled, or epoch duration changes after funding has accrued.
- Liquidator later calls
liquidatePositionsPartyA(partyA, quoteIds). - Settlement is calculated with frozen liquidation prices, but funding may be reconstructed from storage that has moved past the liquidation timestamp.
Same-epoch funding updates are not the issue. The issue is losing historical reconstruction: live storage no longer returns the funding value Muon saw at liquidation time.
Impact
The issue could affect liquidation accounting when live funding storage no longer reconstructs the funding value at the liquidation timestamp.
Actual outcomes:
-
When the changed funding made
partyAAccumulatedUpnldiffer from the signed liquidation UPNL, the liquidation was marked disputed by the existing equality check. - In rare cases, wrong funding debts across positions or PartyBs could cancel out so PartyA's total UPNL stayed unchanged, allowing liquidation to pass with wrong settlement accounting.
New Muon payload
The snapshot path uses LiquidationSnapshotSig:
struct LiquidationPartyBSymbolState {
address partyB;
uint256 symbolId;
uint256 price;
int256 cumulativeLongFee;
int256 cumulativeShortFee;
}
struct LiquidationSnapshotSig {
bytes reqId;
uint256 timestamp;
bytes liquidationId;
int256 upnl;
int256 totalUnrealizedLoss;
LiquidationPartyBSymbolState[] states;
uint256 liquidationBlockNumber;
uint256 liquidationTimestamp;
uint256 liquidationAllocatedBalance;
bytes gatewaySignature;
IMuonSignatureVerifier.SchnorrSign sigs;
}
Each states entry freezes one PartyB-symbol pair:
partyB: the solver side of the position.symbolId: the market.price: liquidation price for that PartyB-symbol pair.cumulativeLongFee: cumulative long funding value at the liquidation timestamp.cumulativeShortFee: cumulative short funding value at the liquidation timestamp.
When the snapshot flow was added, the liquidation start path was refactored around historical insolvency data. That removed
the need for a separate snapshot-specific deferred signature. LiquidationSnapshotSig carries
liquidationBlockNumber, liquidationTimestamp, and liquidationAllocatedBalance directly.
The same change also reorganized the liquidation code so the price-only and snapshot paths reuse the same core liquidation process logic. The refactor split path-specific setup into smaller libraries and did not change the existing price-only liquidation rules.
In the multi-step path, liquidatePartyAWithSnapshot starts liquidation with no PartyB-symbol states. A later
setSymbolsPriceWithSnapshot call submits those states for the same liquidation and stores the signed prices and
funding values.
Snapshot storage model
The snapshot path adds two storage concepts to AccountStorage:
mapping(address => mapping(bytes => bool)) liquidationUsesPartyBSymbolSnapshots;
mapping(address => mapping(bytes => mapping(address => mapping(uint256 => LiquidationPartyBSymbolSnapshot)))) liquidationPartyBSymbolSnapshots;
liquidationUsesPartyBSymbolSnapshots is needed because the same open-position liquidation code supports both
paths. For a normal price-only liquidation, it reads symbolsPrices and reconstructs funding with
getAccumulatedFundingFee from the live funding state when positions are processed. For a snapshot-backed
liquidation, it must read signed PartyB-symbol data instead. The marker tells the liquidation code which source is allowed.
liquidationPartyBSymbolSnapshots stores the signed data for:
partyA -> liquidationId -> partyB -> symbolId
The stored snapshot contains:
struct LiquidationPartyBSymbolSnapshot {
bool isSet;
uint256 price;
int256 cumulativeLongFee;
int256 cumulativeShortFee;
}
The active PartyA liquidation's signed liquidationId is the storage key.
In the multi-step path, these fields are populated in two calls:
-
liquidatePartyAWithSnapshotstarts liquidation and setsliquidationUsesPartyBSymbolSnapshots[partyA][liquidationId] = true. This call requires an emptystatesarray. -
setSymbolsPriceWithSnapshotverifies that the signedliquidationIdand timestamp match the active liquidation, then writes eachstatesentry intoliquidationPartyBSymbolSnapshots.
The snapshot facet emits SetPartyALiquidationSnapshot with partyBs, symbolIds,
prices, and cumulative funding arrays. It also emits the existing SetSymbolsPrices event for
compatibility with existing liquidation indexers.
Method order before and now
The liquidation phases stay the same. What changes in v0.8.6 is the setup and price/funding source.
| Path | Liquidator call order | Price and funding source |
|---|---|---|
| Before v0.8.6, normal price-only |
|
Prices came from setSymbolsPrice. Funding was reconstructed later from
getAccumulatedFundingFee using the then-live funding state.
|
| Before v0.8.6, deferred price-only |
|
Prices came from deferredSetSymbolsPrice. Funding was still reconstructed later from
getAccumulatedFundingFee using the then-live funding state.
|
| v0.8.6 snapshot |
|
Prices and cumulative funding come from the signed PartyB-symbol snapshot stored by
setSymbolsPriceWithSnapshot.
|
New settlement behavior
When liquidatePositionsPartyA processes a quote, it reads
liquidationUsesPartyBSymbolSnapshots[partyA][liquidationId] to decide which accounting path to use.
If snapshot-backed pricing is active:
- the quote must have a snapshot for
(quote.partyB, quote.symbolId); - the close price comes from that snapshot;
- accumulated funding is calculated from the signed cumulative long/short values;
- pairs without accumulated funding active at the liquidation timestamp are represented by zero cumulative values; and
- missing state reverts with
LiquidationFacet: Missing signed state.
If snapshot-backed pricing is not active:
- the non-snapshot path remains unchanged;
- price comes from
setSymbolsPrice; and - funding is calculated with
getAccumulatedFundingFee(quote.id)when the position is processed.
Snapshot-backed pricing is all-or-nothing for an active liquidation. Once the snapshot marker is set, the liquidation cannot silently mix signed funding for some quotes with live-storage funding for others.
Price-only deprecation flag
An address with PROTOCOL_CONFIG_ROLE can require the new snapshot flow for future liquidation starts by calling:
setLegacyPartyALiquidationDeprecated(true)
When enabled, these price-only liquidation entrypoints revert with
LiquidationFacet: Legacy liquidation deprecated:
liquidatePartyAdeferredLiquidatePartyA
The flag does not abort a legacy liquidation that was already started: its later price, position-processing, and settlement steps remain available so the in-flight liquidation can finish.