> ## Documentation Index
> Fetch the complete documentation index at: https://docs.testnet.dev.adipredictstreet.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Recovering expired split / merge locks

> How to reclaim a vault's off-chain balance lock when the on-chain split or merge tx never confirmed.

When you request a `vault.splitPosition` or `vault.mergePositions`
co-signature from the backend, the platform places an **off-chain
lock** on your vault's balance for the duration of the signed
deadline:

* **Split** — locks USDC (`available -> locked`) so it can't be spent
  twice while the chain tx is in-flight.
* **Merge** — locks per-outcome ERC-1155 shares so a `SELL` order can't
  consume the same shares the merge plans to burn.

In the happy path your wallet submits the on-chain tx, the chain-event
router watches the corresponding `PositionSplit` / `PositionsMerged`
event, and the lock is **drained** (consumed by chain) with the
collateral side credited symmetrically. You never see the lock.

## When recovery is needed

Three failure shapes leave the lock stuck because the chain side never
moved:

1. **You never broadcast** — you got the backend signature back but
   never submitted the tx.
2. **The tx was dropped** — broadcast but the mempool evicted it
   before mining (low gas, replacement, etc.).
3. **The tx reverted** — landed in a block but failed the contract
   check; no `PositionSplit` / `PositionsMerged` event was emitted.

In any of these the off-chain `signed_ops` row hits the **EXPIRED**
state by wall-clock once `deadline` elapses, but the balance lock
remains until either:

* The backend's automatic sweeper job tops it up (default behaviour),
  **or**
* You call the user-driven recovery endpoint described below (used
  when the sweeper is intentionally disabled in observation-only mode
  for a deploy window).

## The recovery endpoint

`POST /api/dual-signed-ops/{opId}/release-expired`

Supported `op_kind`: **`SPLIT`** and **`MERGE`**. `CONVERT` / `REDEEM`
follow in subsequent releases.

The endpoint runs a chain of safety gates before releasing the lock so
a `signed_ops` row that did succeed on-chain can never be unlocked
again (which would double-credit the vault):

1. **Ownership** — caller's `X-User-Wallet` must own the op's
   `vault_address` (`403 vault_owner_mismatch`).
2. **Op kind** — `SPLIT` or `MERGE` only (`400 op_kind_unsupported`).
3. **Already terminal** — already-refunded rows return an
   idempotent `200` with `refundAppliedNow: false` so retries are
   safe.
4. **Age** — `now - deadline ≥ 90 s` (`425 op_too_recent`). The 90 s
   buffer matches the auto-sweeper's safety window so a tx mined just
   after the off-chain deadline can never be double-counted.
5. **Chain-watcher liveness** — refuses the call if chain-watcher's
   own `/health/chain-sync` reports a lag past the local threshold
   (`503 chain_watcher_lagging`). Without this gate an un-indexed
   confirmed split would look like a no-execute and could trigger a
   double-credit.
6. **Chain-row absence** — `LEFT JOIN` against the per-kind events
   table (`chain_watcher.vault_position_split_events` for `SPLIT`,
   `chain_watcher.vault_positions_merged_events` for `MERGE`) keyed by
   `(vault, conditionId, amount)`. If a matching row exists the call
   returns `409 op_confirmed_on_chain` with the chain `event_key`,
   `tx_hash`, and `blockNumber` in the error `details` so operators
   can re-drive the chain-event-router instead of unlocking duplicate
   funds.

On success the op transitions to `EXPIRED` with `refunded=true` and a
single `balance_events` row appears with reason
`split_user_refund_verified` (SPLIT) or `merge_user_refund_verified`
(MERGE) — distinct from the sweeper's `*_expired_refund` so audit
queries can attribute the unlock to the user-driven path.

See the full request / response shape in the
[API Reference](/api-reference/endpoint/dual-signed-ops/release-expired).
