Unilateral exit & status
Every VTXO leaves Ark one of two ways: a cooperative leave the operator
settles in the next round, or a unilateral exit you force on-chain yourself.
useWalletExit starts one
outpoint; useWalletExitBatch
starts several at once. useWalletExitPlan,
useWalletExitStatus, and
useWalletExits preview and
track them. sweepWallet has no dedicated hook yet, so reach it through
useWalletEngine().client,
the escape hatch every granular hook is built on.
Cooperative or unilateral?
Try cooperative first. It settles in roughly one round instead of waiting out
a CSV timelock, and it costs nothing beyond the round’s normal fees. It’s
also the default: calling exit({ outpoint }) with no forceUnrollAck is a
cooperative leave.
Reach for unilateral only when the operator is unreachable or unresponsive,
or a cooperative attempt already failed and you need a way out regardless of
that. It’s trustless, since it needs no cooperation from the operator, but it
is slower and costs real sats: it materializes a recovery transaction tree
on-chain, then waits out the VTXO’s CSV timelock before you can sweep the
funds. Preview the cost with getExitPlan before committing (see below). See
Leaving Ark for the full decision writeup,
including what each path looks like in the activity feed.
Unilateral is a separate, explicit branch, not something the SDK falls back
to on your behalf: a cooperative failure rejects the call and never silently
starts a unilateral exit underneath you. Starting one requires the exported
FORCE_UNROLL_ACK constant:
import { FORCE_UNROLL_ACK } from '@lightninglabs/wavelength-react';
await client.exit({ outpoint, forceUnrollAck: FORCE_UNROLL_ACK });FORCE_UNROLL_ACK exists so the unilateral path is something you opt into on
purpose, not something a typo or a copy-pasted request can trigger by
accident. destination (cooperative) and forceUnrollAck (unilateral) are
mutually exclusive on ExitRequest; you pick the branch up front.
Preview with getExitPlan
Before starting a unilateral exit, call client.getExitPlan({ outpoints })
(or the equivalent useWalletExitPlan
hook) to preview funding requirements for each VTXO. Show the plans
summary to the user so they can make an informed decision before broadcasting.
Pass confTarget (in blocks) to influence the fee rate used for the
recommended-funding estimate, the same way you would for other fee-sensitive
calls.
import { useState } from 'react';import { useWalletEngine } from '@lightninglabs/wavelength-react';import type { GetExitPlanResult } from '@lightninglabs/wavelength-react';
function ExitPlan({ outpoints }: { outpoints: string[] }) { const { client } = useWalletEngine(); const [plan, setPlan] = useState<GetExitPlanResult | null>(null);
const preview = async () => { // confTarget is optional; omit it to use the client's default fee estimate. const exitPlan = await client.getExitPlan({ outpoints, confTarget: 6 }); setPlan(exitPlan); };
return ( <div> <button onClick={preview}>Preview exit</button> {plan && ( <p> Can start: {String(plan.canStart)}. Recommended funding:{' '} {plan.totalRecommendedFundingSat} sats. </p> )} </div> );}If canStart is false, check each ExitPlanEntry.infeasibilityReason: a
plain funding shortfall is fixable by sending sats to fundingAddress, but
sweep_below_dust and uneconomical are structural and no amount of funding
helps. isExitInfeasibilityFundable
makes that split for you, so you can show a “fund your wallet” affordance for
the fixable case and a plain “can’t exit this VTXO” message for the rest.
Start a single exit
useWalletExit follows the same throw-and-capture convention as the other
mutation hooks: an exit action plus exitPending/exitError/exitData.
Do not call it twice for the same outpoint; check
useWalletExitStatus first if the user may have
already started an exit in a previous session.
import { FORCE_UNROLL_ACK, useWalletExit } from '@lightninglabs/wavelength-react';
function StartExit({ outpoint }: { outpoint: string }) { const { exit, exitPending, exitError } = useWalletExit();
const startExit = () => exit({ outpoint, forceUnrollAck: FORCE_UNROLL_ACK });
return ( <> <button disabled={exitPending} onClick={startExit}> Start exit </button> {exitError && <p role="alert">{exitError.message}</p>} </> );}exitData resolves with an ExitResult
whose path discriminates the outcome. Which fields are populated depends on
path:
| Field | Populated when |
|---|---|
queuedOutpoints |
path === 'cooperative' |
created |
path is 'unilateral' or 'unilateral_fallback' |
actorID |
path is 'unilateral' or 'unilateral_fallback' |
cooperativeError |
retained for source compatibility; not populated by the current RPC |
unilateral_fallback is also retained for source compatibility, but the
current RPC never returns it: cooperative failures reject instead of
silently entering that legacy branch.
Batch exits with useWalletExitBatch
Exiting more than one outpoint by hand means calling exit() in a loop and
handling funding contention yourself, since exit funding is never reserved:
an earlier start can consume the fee inputs a later one needs.
useWalletExitBatch does that work for you. It resolves once every outpoint
has started, not completed; a unilateral exit keeps running for hours or
days after that. On the unilateral path it re-plans between starts, so it
notices a funding conflict before attempting the next exit rather than after,
and it stops cleanly (rather than retrying) if the daemon rejects a start
mid-batch.
import { useWalletExitBatch } from '@lightninglabs/wavelength-react';
function ExitAll({ outpoints }: { outpoints: string[] }) { const { exitBatch, exitBatchPending, exitBatchError, exitBatchEvents } = useWalletExitBatch();
const startAll = () => exitBatch({ mode: 'unilateral', outpoints, confTarget: 6 });
return ( <div> <button disabled={exitBatchPending} onClick={startAll}> Exit all </button> <ul> {exitBatchEvents.map((event, i) => ( <li key={i}> {event.type} {'outpoint' in event ? `: ${event.outpoint}` : ''} </li> ))} </ul> {exitBatchError && <p role="alert">{exitBatchError.message}</p>} </div> );}Pass { mode: 'cooperative', outpoints, destination? } instead to queue
every outpoint into the next cooperative round; the cooperative path has no
funding-contention re-plan step, since it never touches the backing wallet.
Once the call settles, read exitBatchData.started for what ran,
.skipped for outpoints that already had a job running, .remaining for
what never started, and .stoppedBy for why, if the batch stopped early.
Track status and summary
useWalletExitStatus(outpoint, opts?)
fetches on mount and whenever outpoint changes, and defaults to the cheap,
phase-only call. Pass { detailed: true } when you need the recovery-tree
progress, the CSV maturity countdown, and a fee breakdown; that’s a live
round-trip, so leave it off for a plain status label. Pass { pollMs } to
poll while the component stays mounted: polling is opt-in and runs with no
visibility/focus gating, stopping only on unmount, so there’s nothing to
tear down yourself.
import { useWalletExitStatus } from '@lightninglabs/wavelength-react';
function ExitStatus({ outpoint }: { outpoint: string }) { const { status, statusPending, statusError } = useWalletExitStatus(outpoint, { detailed: true, pollMs: 5000, });
if (statusPending && !status) return <p>Loading...</p>; if (statusError) return <p role="alert">{statusError.message}</p>; if (!status?.found) return <p>No exit job for this outpoint.</p>; if (status.status === 'failed') return <p role="alert">{status.lastError}</p>;
return ( <p> {status.status} {status.cSV && !status.cSV.mature && ` - ${status.cSV.blocksRemaining} blocks to maturity`} </p> );}useWalletExits() reads the
wallet-wide portfolio instead of one outpoint: every in-progress exit plus
the combined amount, fees, and net recoverable. It fetches on mount and
refetches whenever wallet activity changes, so a completed exit drops out of
the summary without a manual refresh.
import { useWalletExits } from '@lightninglabs/wavelength-react';
function ExitSummary() { const { summary, summaryPending } = useWalletExits();
if (summaryPending && !summary) return <p>Loading...</p>; if (!summary || summary.totalExits === 0) return null;
return ( <p> {summary.totalExits} exit(s) in progress, ~{summary.totalEstNetRecoveredSat} sats recoverable. </p> );}Sweep the backing wallet
Once claimable outputs are ready, call client.sweepWallet() with
broadcast: false first to preview, then broadcast: true with a
destinationAddress to consolidate funds on-chain. broadcast defaults to
false when omitted, so a call without it is always a preview. sweepWallet
also accepts optional feeRateSatPerVByte and confTarget overrides for the
sweep transaction’s fee; both have sensible defaults when omitted, so most
callers can leave them unset. The preview result’s estimatedFeeSat and
netAmountSat are useful to show the user what a broadcast would cost and
net before they commit to it. If preview.canBroadcast is false, do not
attempt the broadcast call; show preview.failureReason to explain why
instead.
import { useWalletEngine } from '@lightninglabs/wavelength-react';
function Sweep() { const { client } = useWalletEngine();
const sweep = async () => { const preview = await client.sweepWallet({ destinationAddress: 'bc1q...', broadcast: false, });
if (!preview.canBroadcast) { console.error(preview.failureReason); return; }
await client.sweepWallet({ destinationAddress: 'bc1q...', broadcast: true, }); };
return <button onClick={sweep}>Sweep to on-chain wallet</button>;}