Unilateral exit & status

5 min read
Guides
Web SDK

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.

ExitPlan.tsx
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.

StartExit.tsx
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.

ExitBatch.tsx
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.

ExitStatus.tsx
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.

ExitSummary.tsx
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.

Sweep.tsx
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>;
}