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 includescloseIdas 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 = 0and 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 callexpireQuote([quoteId]); a post-deadline cancellation may invoke the same expiry path. Confirm the quote has returned toOPENED, 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.