Strict Deallocation

Deallocation preserves raw collateral for locked positions even when positive uPnL makes the account appear highly solvent. The safe paths also retain a Muon-attested floor re-marked to live notional plus gross accrued funding debt, so those liabilities cannot be masked by favorable price PnL.

A deallocation can be economically solvent at the moment it executes and still remove collateral that a later liquidation expects to find in storage. v0.8.6 separates those concerns. The existing uPnL-aware availability check remains, and a second raw-allocation floor protects the collateral represented by locked and pending positions.

The floor itself comes in two strengths. The raw floor is the stored credit valuation adjustment (CVA) and liquidation fee (LF) for locked and pending quotes. The safe floor extends only the safe deallocation paths with a Muon-attested value re-marked to live notional and the gross funding debt owed by the signed party. Normal deallocation keeps its existing signature format and does not consume either additional safe-path field, but it now enforces the stored raw floor.

The scope differs by party. PartyA always uses the raw floor. PartyB uses the new floors only after governance enables its per-solver strict-deallocation flag. Safe paths add the Muon-attested fields described below.

The two deallocation limits

Every protected deallocation is bounded by two independent amounts:

  1. Available balance answers whether the account remains solvent for the supplied uPnL after the requested amount leaves.
  2. Raw allocation above the protected floor answers whether storage still contains the CVA and liquidation fee collateral associated with locked and pending positions.

The permitted amount is the smaller limit. Positive uPnL can increase the first limit, but it never increases the second.

protectedFloor = lockedCva + lockedLf + pendingLockedCva + pendingLockedLf

remainingRawAllocation >= protectedFloor

The four field names are the locked and pending forms of the same two components: Cva is credit valuation adjustment and Lf is liquidation fee.

Maintenance margin is intentionally not part of this new floor. It remains part of the existing availability calculation. The floor is limited to values that later liquidation accounting takes directly from raw allocated balance.

Why the raw floor exists

Consider a PartyB with a large positive uPnL. The availability calculation may allow nearly all raw collateral to be deallocated because the unrealized profit keeps the account solvent. If the market then reverses, PartyB liquidation computes a residual liquidation fee and subtracts it from partyBAllocatedBalances.

Without the floor, that subtraction can exceed the remaining raw allocation and revert. The connection is insolvent, but liquidation cannot begin until collateral is added again. Strict deallocation prevents this state from being created by a valid deallocation.

The gap the raw floor leaves

The raw floor uses the CVA and liquidation-fee amounts currently stored for locked and pending quotes. An opened quote's values are scaled to its fill and are not subsequently re-marked to live prices. That leaves two related problems that the raw floor alone does not address.

Profit-financed collateral stripping. Positive uPnL raises the availability limit, so an account holding large unrealized profit can deallocate real collateral down to exactly the stored floor. The position then has no raw buffer above that floor: the liquidation trigger for PartyA is allocated − (cva + lf) + upnl < 0, so the first adverse tick makes the account liquidatable. With realistic liquidator latency, the account reaches late liquidation, where the counterparty absorbs a haircut.

Notional drift. After a large price move, a guarantee sized at open covers a much larger live exposure. A position opened at 2,000 with 40 of CVA and LF still has 40 of guarantee behind 6,000 of notional after a threefold move. The core diamond has no continuous price feed, so the stored guarantee does not rise with live notional.

The scaled locked-balance floor

The safe deallocation paths close both gaps with one additional signed value. The Muon app attests a scaledLockedBalance, the account's locked requirement re-marked to live notional, inside the same signature that carries uPnL and gross funding debt. On-chain, the position requirement is the stricter of the stored and attested floors, and the funding debt is then added:

retention = max(protectedFloor, scaledLockedBalance) + fundingDebt

remainingRawAllocation >= retention

fundingDebt is a uint256 gross liability inside the Muon-signed payload: it is the accrued funding amount owed by the party whose account is being safe-deallocated. Funding receivables are excluded. Net uPnL continues to include funding exactly as before; the debt is not subtracted from uPnL a second time. It is separately retained in raw collateral so favorable price PnL cannot mask it.

The Muon app derives the attested value from state the chain already stores. Per open quote, the locked values are scaled by the ratio of the live mark price to initialOpenedPrice, and each quote contributes the larger of its stored and scaled figure:

scaledLockedBalance = Σ max(storedLocked_q, storedLocked_q × markPrice_q / initialOpenedPrice_q)

initialOpenedPrice is used rather than openedPrice because funding charges and settlement rewrite openedPrice; dividing by it would leak funding payments into the margin rate. The ratio against the initial price applies exactly the guarantee rate both parties priced at open to the position's current size, so no contractual term is rewritten. Partial closes need no special handling: locked values and open quantity shrink proportionally together.

Signature and verification changes

The safe-path SingleUpnlWithPendingBalanceSig expands from its v0.8.5 uPnL-and-pending-balance form with both fundingDebt and scaledLockedBalance. PartyA and PartyB safe verifiers pack the fields in this order: uPnL, funding debt, pending balance, then scaled locked balance. Carrying every safe-removal input in one signature is deliberate: separate attestations would let a caller pair a fresh profit figure with stale debt or a stale locked-balance floor. Normal SingleUpnlSig is unchanged.

Both safe paths verify under a new MuonFunction.RemoveMargin category, appended to the enum so existing ordinals are preserved. Because margin removal transfers value, a stale signature can still extract collateral. A signature is a bearer instrument for its whole validity window, and one maximal use extracts everything it authorizes. The freshness window, rather than per-use consumption, bounds staleness. Operators should configure a tight window for RemoveMargin via setMuonFunctionUpnlValidTime; until one is set, verification falls back to the global window. See Muon UPNL Validity Overrides.

PartyA policy

The raw floor is always enabled for PartyA. Both deallocate and safeDeallocate require the remaining allocation to cover locked and pending CVA plus liquidation fee. Only safeDeallocate also applies the signed scaled locked-balance floor and retains the signed gross funding debt. There is no user-level toggle.

safeDeallocate also retains its separate Muon pendingBalance reservation for operations that are committed off-chain but not yet represented by an on-chain quote. That value is not the same as pendingLockedBalances, and both protections apply at the same time. Its floor is the scaled one, and it verifies under MuonFunction.RemoveMargin:

availableBalance >= pendingBalance + amount
remainingRawAllocation >= max(protectedFloor, scaledLockedBalance) + fundingDebt

Normal deallocate keeps its existing SingleUpnlSig wire format and retains only the stored position floor. It is gated by legacyDeallocateDeprecated, the eventual retirement path for deallocation that does not carry the scaled position floor or funding debt.

PartyB policy

PartyB protection is configured per solver and is disabled by default for backward compatibility. A PartyB manager can enable or disable it for a registered solver:

setPartyBStrictDeallocation(address partyB, bool enabled)
isPartyBStrictDeallocationEnabled(address partyB) returns (bool)

When enabled, the floor applies to deallocateForPartyB and to the origin bucket of transferAllocation. Covering the transfer path matters because otherwise protected collateral could be moved to an empty allocation bucket and deallocated from there. These normal paths remain unchanged and retain the stored CVA and LF position floor only.

When disabled, PartyB retains the existing uPnL-aware deallocation behavior. Integrators should query the flag rather than assuming that every solver follows the same policy.

Solvers get their own safe path, safeDeallocateForPartyB(amount, partyA, sig). It mirrors deallocateForPartyB with the same modifiers, DeallocateForPartyB event, and bucket rules. Availability must cover the attested pendingBalance plus the requested amount, and when the strict flag is enabled the retention floor becomes max(protectedFloor, scaledLockedBalance) + fundingDebt. Keeping this floor inside the strict-flag branch is what makes enforcement per-solver: a solver that has not been switched on can use the safe payload without the new raw floor being enforced on-chain yet. Enforcement can move one solver at a time.

Cross-mode buckets

An isolated PartyB uses the named PartyA's allocation bucket. An active cross-mode PartyB instead backs all current positions from the shared address(0) bucket. A nonzero PartyA bucket left behind after enabling cross mode is a legacy migration bucket, not part of the active cross pool.

For a cross-mode PartyB, positions are backed by the aggregate address(0) allocation bucket. Strict deallocation therefore measures both raw allocation and locked requirements against that aggregate bucket. Frontends should also use address(0) when previewing a normal cross-bucket deallocation.

A per-PartyA bucket created before cross mode is a migration remainder, not backing for the active cross pool. Deallocating that legacy bucket keeps its historical rule: the active cross pool must be solvent, but the requested amount is not capped by cross availability and the strict raw floor is not applied to the legacy bucket. This allows stranded allocation to be drained after migration. safeDeallocateForPartyB follows these same bucket rules, including the legacy-drain exemption; only isolated and active cross-bucket safe paths apply the pending reservation and strict scaled-plus-funding-debt floor.

AccountLayer routing

Virtual accounts reach the core diamond through the AccountLayer, so its MarginFacet gains safeRemoveMargin(virtualAccount, amount, sig) as the sibling of the existing removeMargin. The layer checks the account owner, non-zero amount, virtual account existence, pause state, and reentrancy. It then executes core safeDeallocate as the virtual account and sweeps the freed balance to the parent sub-account, emitting the same RemoveMargin event as the legacy router. All solvency judgment deliberately stays in core: a check added at the layer would be bypassed by anyone calling core directly, whereas the core-side floor covers virtual accounts automatically because core sees each one as an ordinary PartyA. Core revert reasons bubble through the router unchanged.

New view functions

Choose the preview that matches the transaction:

  • Normal PartyA deallocate: maxDeallocatableForPartyA.
  • Current PartyA safeDeallocate: maxRemovableMarginForPartyA with every signed field.
  • Normal PartyB deallocateForPartyB: maxDeallocatableForPartyB.
  • PartyB safeDeallocateForPartyB: maxRemovableMarginForPartyB.

Three view functions were added with the raw floor:

maxDeallocatableForPartyA(address partyA, int256 upnl)

maxSafeDeallocatableForPartyA(
    address partyA,
    int256 upnl,
    uint256 pendingBalance,
    uint256 fundingDebt
)

maxDeallocatableForPartyB(
    address partyB,
    address partyA,
    int256 upnl
)

Two more accompany the scaled floor, taking the exact values Muon signed:

maxRemovableMarginForPartyA(
    address partyA,
    int256 upnl,
    uint256 pendingBalance,
    uint256 scaledLockedBalance,
    uint256 fundingDebt
)

maxRemovableMarginForPartyB(
    address partyB,
    address partyA,
    int256 upnl,
    uint256 pendingBalance,
    uint256 scaledLockedBalance,
    uint256 fundingDebt
)

All five views are v0.8.6 additions. Only maxSafeDeallocatableForPartyA and the two maxRemovableMarginFor… views accept funding debt; only the two maxRemovableMarginFor… views also accept scaled locked balance. For PartyB, scaled balance and debt retention apply only when strict deallocation is enabled. For cross mode, use address(0) for the active bucket or the original PartyA address for a legacy bucket.

maxSafeDeallocatableForPartyA is the compatibility preview for the earlier safe payload shape. It assumes scaledLockedBalance = 0. Current safe clients should use maxRemovableMarginForPartyA so the preview includes the exact scaled floor carried by the signature.

Integration flow

  1. For a safe removal, obtain fresh signed net uPnL, gross fundingDebt, pending balance, and scaledLockedBalance from Muon. They come from one signature over one price and state snapshot. Normal paths continue to use SingleUpnlSig.
  2. Call the matching maximum view, maxRemovableMarginFor… for the safe paths, and pass exactly the signed values. For PartyB, pass the PartyA address in isolated mode, address(0) for the active cross bucket, or the original PartyA address when draining a legacy bucket.
  3. Clamp the requested amount to the returned maximum and construct the corresponding deallocation transaction.
  4. Treat a later transaction revert as authoritative: account state, policy, upnl counter, or signed values may have changed after the preview. The floor check reverts with AccountFacet: Locked balance and funding debt must remain allocated.

Rollout order

The safe Muon payload and safe selectors require a coordinated cutover:

  1. Prepare Muon and safe clients. Extend SingleUpnlWithPendingBalanceSig and the safe view arguments behind a deployment flag. Muon must return net uPnL and gross funding debt from the same snapshot; configure a tight RemoveMargin validity window at the same time.
  2. Coordinated safe-selector upgrade. Cut the core Account, PartyBAccount, and View facets and the AccountLayer MarginFacet in one maintenance window. The facet updater removes the superseded safe selectors; normal selectors are unchanged.
  3. Safe-client cutover. Enable the prepared Muon and safe payloads as soon as the new safe selectors are live.
  4. PartyB enforcement. Flip strictDeallocationEnabledForPartyB per solver after monitoring its debt attestations. This enables the documented PartyB floors but does not disable normal deallocateForPartyB or transferAllocation; those paths remain available with the stored raw floor.
  5. PartyA legacy retirement. After PartyA clients have moved to the safe path, enable legacyDeallocateDeprecated. It disables core PartyA deallocate, which also makes AccountLayer removeMargin revert; clients must use safeDeallocate or safeRemoveMargin instead. zeroUpnlDeallocate remains unaffected because it requires no open or pending positions and remains restricted to BALANCE_SETTLER_ROLE or an authorized forwarding proxy.

uPnL counters, formerly nonces

The per-party value that Muon signatures embed was historically named partyANonces / partyBNonces. The name was wrong in a way that misleads design work: a nonce implies per-use consumption, but this value is a version counter of the inputs to uPnL. Fills, funding charges, settlement, liquidation, and force actions bump it whenever position or PnL state changes. A signature does not consume it. A signature therefore stays valid for its whole validity window until uPnL-relevant state changes, which is exactly why the RemoveMargin freshness window, not the counter, is the binding control on stale signatures.

Deallocation deliberately does not bump the counter: it changes no uPnL input, every deallocation re-reads current balances from storage, and one maximal use already extracts everything a signature authorizes, so consuming it would add friction without adding safety.

v0.8.6 renames the storage fields to partyAUpnlCounters / partyBUpnlCounters and the helpers to matching names. The rename is storage-layout-safe (member order and types are unchanged), and the external views nonceOfPartyA / nonceOfPartyB keep their names as deprecated aliases, so the ABI, the Muon app, and every integrator are untouched. Canonical twins were added alongside them; new integrations should prefer these:

upnlCounterOfPartyA(address partyA) returns (uint256)
upnlCounterOfPartyB(address partyB, address partyA) returns (uint256)

Both pairs read the same storage, so they always return identical values.