Force-Close Request Binding

A multi-step force-close snapshot can now be used only by the same close request that initialized it.

Summary

The multi-step force-close flow begins with initializeForceClose, which stores a derived close price and a uPNL/current-price snapshot. settleUpnlForForceClose can then realize uPNL while the workflow is open, and finalizeForceClose refreshes the snapshot before closing the position. Previously, this stored state was keyed only by quoteId. It did not record which close request created it.

If that close request was canceled or expired, its stored detail could remain marked inProgress. A new close request for the same quote received a new closeId, price, and quantity, but the old force-close state still passed the continuation checks. Finalization could therefore combine the old derived price with the replacement request's current quantity, changing realized PnL, fees, balances, and the reported execution.

The failure sequence

Step Quote state Stored force-close state
Initialize request A closeId A, price A, quantity A Derived price A and snapshot A; inProgress = true
Cancel or expire A Returns to OPENED The historical detail can remain present
Create request B closeId B, price B, quantity B Still contains price and snapshot A
Continue without re-initializing Request B is current Previously consumed price A with quantity B

The fix

Initialization now stores the quote's current closeId as the operation binding. Every later force-close step uses one shared guard and proceeds only when all three conditions hold:

  • the stored force-close detail is still marked inProgress;
  • the quote is still in CLOSE_PENDING; and
  • the stored close ID equals the quote's current closeId.

The guard runs before snapshot refresh, force-close settlement, and finalization. A cancellation request therefore stops progression immediately even though it retains the same close ID, while an accepted cancellation, expiry, or replacement request cannot inherit the prior snapshot. Successful finalization clears the binding.

The binding is the final field of ForceCloseDetail, so the snapshot and the request that created it share one lifecycle. Appending the field preserves every existing struct member's storage offset and does not shift later fields in AccountStorage.Layout.

ABI and operations notes

  • The forceCloseDetails(uint256) return tuple now includes closeId as its final field. ABI artifacts, generated types, and consumers of this view should be regenerated for v0.8.6. The function selector is unchanged because return values are not part of selector derivation.
  • An uninitialized or pre-upgrade detail has closeId = 0 and cannot pass the continuation guard. Finish active multi-step workflows before upgrading. After the upgrade, an old workflow cannot continue. Cancel the old close request through the normal cancellation flow, or wait past its deadline and call expireQuote([quoteId]); a post-deadline cancellation may invoke the same expiry path. Confirm the quote has returned to OPENED, then create a replacement request and initialize a new workflow.
  • A replacement request receives a new closeId, so the old detail cannot pass its request-identity check.
  • Cancellation or expiry can leave historical detail with inProgress = true. Off-chain systems must not treat that flag alone as proof that the workflow can continue; quote status and request identity are also required.
  • forceCloseAndSettlePositionsUnified, the one-transaction path, remains valid because it initializes and consumes the same close request atomically.