This document explains how wavelength moves value between the Lightning
Network and an Ark instance, and how the same machinery settles a payment
between two clients of the same Ark without ever touching Lightning. It is
written for someone who already knows what a payment hash and a taproot output
are, but who has never read the swap code. By the end you should be able to
trace any swap from the command line down to the wire, name every server call,
and explain how the client and the operator's indexer cooperate to observe an
Ark output that no single party owns.
The code lives mostly in sdk/swaps, with the daemon-side glue
in waved and the cryptography in
lib/arkscript. The wire contract with the swap server is
swaprpc/swap.proto.
A swap is a four-party affair, even though the user only ever sees two of them.
| Actor | What it is | Where it lives |
|---|---|---|
The client (waved) |
The user's daemon. Holds the wallet, derives keys, drives the swap FSM. | This repo. |
The swap server (swapd) |
The bridge between Lightning and Ark. Intercepts Lightning HTLCs, funds vHTLCs, pays invoices. | Operator infrastructure (separate repo). |
The Ark operator (lumosd) |
The Ark service provider. Runs rounds, and — crucially for us — runs the indexer, the authoritative record of which virtual outputs exist. | Operator infrastructure (separate repo). |
| The counterparty | A Lightning node paying the invoice, or another client of the same Ark. | Anywhere. |
The single most important idea in this document is that the swap server and the Ark operator are different roles, and the indexer — owned by the operator — is the only witness to the on-Ark outputs a swap creates. A great deal of the system's behaviour follows from that one fact.
Both directions of a swap pivot on a virtual HTLC, or vHTLC: an Ark output
whose taproot tree encodes the same hashlock-and-timelock logic as a Lightning
HTLC, but settled inside Ark rather than on the Bitcoin base layer. It is built
by arkscript.NewVHTLCPolicy from a fixed tuple of
parameters:
Sender,Receiver,Server— three public keys (the meaning of "sender" and "receiver" flips with the swap's direction;Serveris the Ark operator key);PreimageHash— the invoice payment hash;- four timelocks — one absolute refund locktime (CLTV) and three relative unilateral-exit delays (CSV).
From those, NewVHTLCPolicy compiles a six-leaf taproot tree
(lib/arkscript/vhtlc.go, NewVHTLCPolicy):
collaborative (operator co-signs; settled off-chain via OOR)
1. Claim : HashLock(preimage) + Multisig([receiver, server])
2. Refund : Multisig([sender, receiver, server])
3. RefundWithoutReceiver : CLTV(locktime) + Multisig([sender, server])
unilateral exit (no operator key; CSV-gated; spent on-chain)
4. UnilateralClaim : CSV(delay) + HashLock(preimage) + Multisig([receiver])
5. UnilateralRefund : CSV(delay) + Multisig([sender, receiver])
6. UnilateralRefundWithoutReceiver : CSV(delay) + CLTV(locktime) + Multisig([sender])
The collaborative/exit split is the spine of the whole cancellation story, so read it carefully. The six leaves are three pairs — claim, refund, and refund-without-receiver — and each pair has one collaborative and one unilateral exit member:
- A collaborative leaf (1–3) always contains the operator key in its multisig. Because the operator co-signs, the spend can be done as an OOR transfer entirely inside Ark — no Bitcoin transaction, no confirmation wait. This is the fast, cheap, off-chain settlement path, and it is the one the client always tries first.
- A unilateral exit leaf (4–6) contains no operator key and is gated by a relative CSV delay. It is the fallback you take when the operator (or the counterparty) stops cooperating: you broadcast the vHTLC on-chain and, after the CSV matures, sweep it yourself. It costs a real transaction and a timelock wait, so it is strictly a recovery path of last resort.
These are not ad-hoc conventions — lib/arkscript enforces them
(ValidatePolicy): every collaborative leaf must carry the operator key, every
exit leaf must omit it and be CSV-gated, and no leaf may let the operator
spend unilaterally (Multisig{operator, operator} is rejected). So the
operator can always help settle but can never seize a vHTLC on its own. Hold
onto the three-pairs picture; §5 walks the off-chain-first, on-chain-fallback
ladder built directly on top of it.
Two further properties drive everything downstream. First, the output is
deterministic: any party who knows the tuple can compute the exact pkScript,
because the script is a pure function of the tuple (and of the lib/arkscript
version that compiles it — all parties must share that library). Second, it is
NUMS-keyed: there is no usable key-path spend, so the output belongs to no
single party. You can recognise a vHTLC, and you can spend it down one of its
leaves, but you cannot claim key-path ownership of it the way you can an ordinary
wallet output. Both properties return below.
Funding and claiming a vHTLC happen through OOR (out-of-round) transfers:
Ark's mechanism for moving a virtual output between owners without waiting for
the next round. When we fund a vHTLC we send an OOR transfer to its pkScript;
when we claim one we send an OOR transfer that spends its Claim leaf with the
preimage. The daemon's SendOORWithPolicyDetails and SendOORWithCustomInputs
do this work.
There are exactly two user-facing verbs, and they map cleanly onto the two directions of a swap.
flowchart LR
subgraph Receive["wave recv --offchain (out-swap)"]
direction LR
LN1["Lightning payer"] -->|pays invoice| SWAPD1["swapd"]
SWAPD1 -->|funds vHTLC| ARK1["Ark / vHTLC"]
ARK1 -->|client claims w/ preimage| ME1["client wallet"]
end
subgraph Pay["wave send invoice --offchain (in-swap)"]
direction LR
ME2["client wallet"] -->|funds vHTLC| ARK2["Ark / vHTLC"]
SWAPD2["swapd"] -->|claims vHTLC w/ preimage| ARK2
SWAPD2 -->|pays invoice| LN2["Lightning payee"]
end
The defining asymmetry is right there: on receive, the swap server funds the vHTLC; on pay, the client funds it. That single difference shapes how each side observes the output, as §3 and §4 make concrete.
Note also what does not go through this machinery. wave recv --onchain
allocates a plain wallet receive address through NewReceiveScript and never
creates a vHTLC at all; wave send <address> --onchain is a cooperative leave.
Neither is a swap. When this document says "receive" or "pay" without
qualification, it means the --offchain swap path.
This is the flow the user invokes with wave recv --offchain --amt N. The client
wants to be paid over Lightning and end up with the value as an Ark output.
The receive session begins in
prepareInvoice (out_swap.go:623). It does four
things that matter:
- Fetches the client's own identity key (
IdentityPubKey) and the operator's key (OperatorPubKey). These become the vHTLC'sReceiverandServer. - Generates a fresh preimage and its hash. The hash is the invoice's payment hash and the vHTLC's hashlock.
- Allocates the claim destination — an ordinary OOR receive script
(
AllocateReceiveScript, out_swap.go:650) — and registers it with the indexer. This is where the value lands after the vHTLC is claimed. - Calls the swap server's
RequestChannelIdRPC (out_swap.go:671), handing over(client_vhtlc_pubkey, payment_hash, amount_msat). The server allocates a virtual short-channel-id derived from the client key and payment hash, and returns a route hint. The client embeds that hint in the BOLT-11 invoice it hands back to the user.
The route hint is the hook. It tells the eventual Lightning payer to route the
final hop through swapd, which is how swapd gets the chance to intercept the
incoming HTLC instead of forwarding it.
When a Lightning payer pays the invoice, the HTLC arrives at swapd (the hinted
hop). Rather than forward it, swapd:
- Holds the incoming Lightning HTLC (it has the hash, not the preimage).
- Funds a vHTLC on Ark for the same hash and amount, with itself as
Sender, the client asReceiver, and the operator asServer. - Notifies the client by pushing an
OutSwapHtlcEventinto the client's swap mailbox. The event carries the amount, theVHTLCConfig(timelocks and the server's pubkey), and — because the HTLC is raw from the Lightning side — an onion blob the client must decrypt to prove it is the intended final hop.
The client now has everything it needs to reconstruct the vHTLC pkScript, but notice what it does not have: the output itself. The server funded it; it sits at a NUMS-keyed address that belongs to no wallet.
acceptOutSwapHtlcEvent (out_swap.go:996) validates
the event (payment hash matches, amount matches, onion decrypts), then builds
the policy and derives the script:
policy, _ := arkscript.NewVHTLCPolicy(arkscript.VHTLCOpts{
Sender: serverKey, // the swap server
Receiver: s.clientPubKey, // us
Server: s.operatorPubKey, // the Ark operator
PreimageHash: s.PaymentHash,
RefundLocktime: event.VHTLCConfig.RefundLocktime,
UnilateralClaimDelay: event.VHTLCConfig.UnilateralClaimDelay,
UnilateralRefundDelay: event.VHTLCConfig.UnilateralRefundDelay,
UnilateralRefundWithoutReceiverDelay: event.VHTLCConfig.UnilateralRefundWithoutReceiverDelay,
})
pkScript, _ := policy.PkScript() // out_swap.go:1078
// ... persisted into s.vhtlcPkScript, FSM -> ReceiveStateHTLCEventAcceptedReceiveStateHTLCEventAccepted is a durable checkpoint. Once the client reaches
it, the mailbox event has been consumed and acknowledged; on restart the session
resumes from this state rather than re-reading the mailbox. Funding detection
therefore picks up exactly where it left off across a daemon restart.
Having derived the pkScript, the client must observe that the server actually funded it. It cannot look in its own wallet, because the output is not there (§3.2). Its only authoritative witness is the operator's indexer.
waitForVHTLC (out_swap.go:1915) polls in a loop:
waitForVHTLC
└─ daemon.FindLiveVTXOByPkScript(pkScript)
└─ RPCServer.GetIndexedVTXOByPkScript (waved/rpc_swap_lookup.go:18)
└─ indexer.ListVTXOsByScriptsTaproot (proof-gated query)
Once the server has funded the vHTLC and the operator has indexed it as a live VTXO, the poll returns the outpoint and amount. The client then claims the vHTLC by spending its Claim leaf with the preimage (an OOR transfer to the claim destination it set up in §3.1), which simultaneously reveals the preimage to the swap server, who uses it to settle the held Lightning HTLC. Everyone is paid.
How the proof-gated query authorises the client is the subject of §7; it is worth reading before reasoning about any receive that takes longer than expected.
sequenceDiagram
autonumber
participant U as User / CLI
participant C as Client (waved)
participant S as swapd (swap server)
participant A as lumosd + indexer
participant L as Lightning payer
U->>C: wave recv --offchain --amt N
C->>C: AllocateReceiveScript (claim dest)
C->>A: RegisterReceiveScript(claim dest) %% the OOR claim address
C->>S: RequestChannelId(clientKey, hash, amt)
S-->>C: route hint + payer_fee_msat
C-->>U: BOLT-11 invoice (with route hint)
L->>S: pay invoice (HTLC, final hop = swapd)
S->>S: hold incoming HTLC
S->>A: fund vHTLC (Sender=swapd, Receiver=client)
S-->>C: mailbox: OutSwapHtlcEvent (config + onion)
C->>C: acceptOutSwapHtlcEvent -> derive pkScript
Note over C: FSM = HTLCEventAccepted (durable)
loop waitForVHTLC (every waitPollInterval)
C->>A: ListVTXOsByScripts(vHTLC pkScript) %% proof-gated, see §7
A-->>C: live VTXO / empty / auth error
end
C->>A: claim vHTLC (OOR spend, Claim leaf + preimage)
Note over S,A: preimage now revealed on-Ark
S->>L: settle held HTLC with preimage
C-->>U: RECV complete
This is wave send <invoice> --offchain. The client holds Ark value and wants a
Lightning invoice paid.
The client calls the swap server's CreateInSwap RPC (swap.proto:74) with
the invoice, a fee ceiling, and its vHTLC pubkey. The server replies with a
CreateInSwapResponse: the payment hash, the amount and fee, the server's
pubkey, the VHTLCConfig, a deadline, and — decisively — a settlement_type
(see §8).
Here is the mirror image of the receive flow. In
fundOrAdoptVHTLC (in_swap.go:718), the client
funds the vHTLC itself, via SendOORWithPolicyDetails, with itself as
Sender, the swap server as Receiver, and the operator as Server. Because
the client's own daemon performs the OOR transfer, the funded output lands in
the client's local VTXO store. The client can see its own funding without
asking anyone.
The swap server, watching the vHTLC appear, pays the Lightning invoice, learns the preimage from the Lightning settlement, and claims the vHTLC's Claim leaf — revealing the preimage to the client, which is the client's proof of payment.
Because the client funds and holds a local copy of the in-swap vHTLC, the pay
side does not depend on the indexer to know its own funding. fundOrAdoptVHTLC
queries the indexer opportunistically and treats a failure as non-fatal, then
proceeds to fund. The pay flow's authoritative source of truth is local; the
receive flow's is remote. That difference is the practical reason the two
directions behave differently under indexer latency.
sequenceDiagram
autonumber
participant U as User / CLI
participant C as Client (waved)
participant S as swapd (swap server)
participant A as lumosd + indexer
participant P as Lightning payee
U->>C: wave send <invoice> --offchain
C->>S: CreateInSwap(invoice, maxFee, clientKey)
S-->>C: hash, amount, fee, serverKey, VHTLCConfig, settlement_type
C->>C: derive vHTLC pkScript
C->>A: fund vHTLC via OOR (lands in LOCAL store)
Note over C: client owns a local copy of the funding
S->>A: observe funded vHTLC
S->>P: pay BOLT-11 invoice
P-->>S: preimage (settlement)
S->>A: claim vHTLC (Claim leaf + preimage)
C->>A: observe claim -> extract preimage
C-->>U: SEND complete (proof of payment = preimage)
If the Lightning payment fails, the client does not have to go on-chain to get
its money back. It walks a refund ladder whose first rungs all settle
off-chain via OOR: an immediate operator-co-signed cooperative refund (the
Refund leaf, leaf 2) if the server authorises one through
AuthorizeInSwapRefund (swap.proto:95); failing that, a CLTV-gated
cooperative refund the client can take with the operator without the swap
server's cooperation once the refund locktime elapses (the
RefundWithoutReceiver leaf, leaf 3); and only if cooperation breaks down
entirely does it fall through to a unilateral on-chain exit (leaf 6). Note that
the CLTV refund locktime gates an off-chain OOR spend of leaf 3 — it is not
itself an on-chain event. §5 walks this ladder, and its receive-side mirror, in
full.
Every swap so far has succeeded. Now suppose one does not: a Lightning payment dies, a counterparty goes dark, an operator stops answering. The money is locked in a vHTLC that belongs to nobody — so how does anyone get it back? The answer is a single rule applied in both directions: try to settle off-chain by cooperating; fall back to an on-chain unilateral exit only when cooperation is no longer possible. That rule is the three-pairs structure from §1 put in motion, and this section traces it from the funded vHTLC to the recovered coin.
The six leaves are not six equal options a party picks from. Each one has a specific owner and a specific moment. Read this table as "who can take the money, how, and when":
| Leaf | Chain | Who signs | Spent by | At what moment |
|---|---|---|---|---|
| 1 Claim | off-chain OOR | receiver + operator | the payee of the hash | the preimage is known — the happy path for both directions |
| 2 Refund | off-chain OOR | sender + receiver + operator | the payer | both sides agree to cancel before the timeout |
| 3 RefundWithoutReceiver | off-chain OOR | sender + operator | the payer | the timeout (CLTV) has passed and the receiver is unresponsive |
| 4 UnilateralClaim | on-chain, CSV | receiver alone | the payee | the payee knows the preimage but cannot get a cooperative claim through |
| 5 UnilateralRefund | on-chain, CSV | sender + receiver | sender & receiver together | both want to unwind but the operator will not co-sign |
| 6 UnilateralRefundWithoutReceiver | on-chain, CSV + CLTV | sender alone | the payer | the payer must reclaim and neither receiver nor operator will help |
The top three rows settle inside Ark in seconds; the bottom three demand a broadcast and a CSV wait. The client always reaches for a top row and touches a bottom row only when it must. The next four subsections show exactly how it decides.
The client does not wait until a swap is in trouble to think about recovery. The
instant a vHTLC is funded — before it commits to the risky half of the flow —
it stores a dormant recovery row in the daemon that already knows how to
unilaterally exit this exact output. A receive session arms a claim recovery
(ensureReceiveClaimRecoveryArmed, recovery.go:394);
a pay session arms a refund-without-receiver recovery
(ensurePayRefundRecoveryArmed, recovery.go:283).
Each row captures everything the daemon would need to sweep the output alone: the
outpoint and amount, all three participant keys, the CLTV refund locktime and the
three CSV delays, the destination script, and a fee-rate cap
(DefaultRecoveryMaxFeeRateSatPerKW, ~100 sat/vByte). The request carries a
deterministic idempotency key (recoveryRequestID), so a crash-and-retry returns
the same row instead of arming a second one.
An armed row is dormant, not active. Its existence changes nothing on its own; it simply means that if cooperation later fails, the escalation is a flag flip on durable state rather than a scramble to reconstruct vHTLC context after the daemon has already restarted. Arm the lifeboat while the sea is calm.
On a receive, the swap server funded the vHTLC and the client must claim it with
the preimage (§3.4). The cooperative claim is an OOR spend of leaf 1, and it is
the only outcome the client wants. But the client is racing a clock it does not
own: once the refund locktime passes, the swap server may take leaf 3 and
reclaim its own funding, leaving the client with nothing. So the receive flow
treats the locktime as a hard deadline. Before it starts or continues waiting it
checks that the locktime is not imminent
(ensureReceiveFundingStillPossible,
out_swap.go:1387), keeping a one-block buffer (defaultRefundLocktimeBuffer);
if the deadline has arrived it gives up with errSwapExpired rather than chase
a vHTLC the server is about to pull back.
If the cooperative claim keeps failing while the deadline approaches, the client
escalates (maybeEscalateReceiveClaimRecovery,
recovery.go:669): the daemon takes over the armed row and spends leaf 4
(UnilateralClaim) on-chain, sweeping the output with the preimage after its CSV
matures. Because a real deadline looms here, the policy lets deadline pressure
override the ordinary grace period — there is no point waiting out a one-hour
courtesy window if the sender can reclaim in twelve blocks.
On a pay, the client funded the vHTLC and wants either a preimage (proof it paid) or its money back. §4.4 named the ladder; here is each rung:
- Immediate cooperative refund (leaf 2).
tryCooperativeRefund(in_swap.go:1250) asks the swap server to co-sign the three-party Refund leaf throughAuthorizeInSwapRefund. The server signs only after it has safely failed the Lightning payment and holds no preimage, so an "unavailable" answer is not an error — it just means "keep waiting, the payment might still settle." When the server does sign, the client sweeps its funds off-chain at once, long before any locktime. - Timeout cooperative refund (leaf 3). If the server never authorises the immediate refund, the client waits for the CLTV refund locktime to mature (in_swap.go:1141) and then spends RefundWithoutReceiver as an OOR transfer (in_swap.go:1171) — still off-chain, and now without needing the swap server to cooperate at all, only the operator. This is the rung most often misread: the locktime gates an off-chain spend, it does not force a broadcast.
- Unilateral exit (leaf 6). Only if even that OOR cannot get through does
the client escalate the armed row
(
maybeEscalatePayRefundRecovery, recovery.go:583) and let the daemon sweep leaf 6 on-chain after its CSV. A pay refund has no absolute deadline the way a receive claim does, so here the grace period is the only automatic trigger.
Escalation from the off-chain rungs to the on-chain one is deliberately
reluctant, because an on-chain exit costs a transaction and a timelock. The
RecoveryPolicy (recovery.go:120) decides, and its
production default is conservative:
AutoEscalateis off by default. Out of the box the SDK never unilaterally exits on its own; an operator triggers it deliberately through theswapd recoveryCLI (EscalateVHTLCRecovery). The armed row waits patiently until told.- With auto-escalation enabled,
decideRecoveryEscalation(recovery.go:202) escalates only when waiting longer is unsafe or pointless: when the height plus a safety margin (MinRecoveryMarginBlocks, 12) reaches a refund-locktime deadline (deadline_margin), or when a grace period (CooperativeFailureGracePeriod, one hour) has elapsed since cooperation first failed (grace_elapsed). Until then it keeps retrying the cooperative path (within_grace_period). - The fee-rate cap is frozen into the row at arm time, so a restart or a manual escalation months later cannot quietly spend at a looser rate than the client originally consented to.
The same policy also supplies the admission budget, one lifecycle stage
earlier. Before a receiver accepts a proposed vHTLC at all,
validateReceiveClaimWindow asks
arkscript.VHTLCTiming.ValidateClaimWindow
whether the remaining RefundLocktime - height budget strictly exceeds
ExitAncestryDelayBlocks (72 — confirming the vHTLC's commitment-tree and OOR
ancestry) plus the vHTLC's own UnilateralClaimDelay plus
MinRecoveryMarginBlocks (12). A vHTLC that fails this check could be accepted
and then discovered, too late, to have no room for the on-chain rung described
above — so it is rejected up front rather than armed and escalated into a
window that was never wide enough.
Two exceptions keep that check from destroying value. A backend that cannot
answer BlockHeight yields a retryable error, never a terminal one — an
unreachable node is not a bad vHTLC. And a legacy direct-p2p in-Ark event
describes a vHTLC the sender has already funded: refusing it cannot undo the
exposure, and would forfeit a cooperative claim that is still available, so the
session logs Receive vHTLC has limited recovery window and proceeds.
Credit-shaped in-Ark events and Lightning out-swap events both fail closed,
because in those flows the rejection lands before the acknowledgement that
triggers server-side funding.
A recovery row moves through a small state machine: it starts ARMED
(dormant), advances to UNROLL_STARTED when the daemon begins the on-chain
exit, and ends COMPLETED, FAILED, or CANCELLED. The pivotal predicate
is recoveryIsActive (recovery.go:743): the moment a
row leaves ARMED for unroll, the SDK stops attempting cooperative OOR spends for
that vHTLC and simply reconciles against the daemon's progress — once the daemon
owns the exit, two parties racing to spend the same output would only collide.
The far more common ending is the happy one, where cooperation wins and the
lifeboat is never launched. When the off-chain path succeeds, the session calls
cancelVHTLCRecovery (recovery.go:497) with a reason
naming what won — server claim observed, cooperative refund accepted,
cooperative claim indexed, and so on — and the armed row retires unused.
flowchart TD
F["vHTLC funded"] --> ARM["arm recovery row (dormant)"]
ARM --> COOP{"cooperative OOR\nsucceeds?"}
COOP -->|yes| CANCEL["cancelVHTLCRecovery\n(reason: which path won)"]
CANCEL --> DONE["settled off-chain ✓"]
COOP -->|"no, keep retrying"| POL{"escalation policy:\ndeadline margin or\ngrace elapsed?"}
POL -->|no| COOP
POL -->|yes| ESC["escalate: daemon unroll\n(UNROLL_STARTED)"]
ESC --> SWEEP["sweep unilateral exit leaf\non-chain after CSV"]
SWEEP --> COMPLETE["recovery COMPLETED\n(settled on-chain)"]
None of this would be safe if only the client armed a lifeboat. The swap server arms its own recovery rows symmetrically (swapdk-server #43): on an out-swap it arms a refund-without-receiver row so it can reclaim the funding it advanced if the client never claims, and on an in-swap it arms a claim row before it pays the Lightning invoice so it can always collect the vHTLC it is about to earn. The same off-chain-first discipline governs the server: it co-signs the client's immediate cooperative refund only when it has terminally failed the payment and holds no preimage, and it checks that the outpoint, amount, policy template, and spend path all match the Refund leaf exactly before it signs (swapdk-server #55) — it will never sign a refund once a preimage exists, because then the right outcome is a claim, not a refund. Payments it cannot make atomically, such as AMP invoices, it fails fast into that same refund path (swapdk-server #67) rather than spinning, and an out-swap with no liquidity to fund the vHTLC fails the held Lightning HTLC immediately (swapdk-server #59) instead of stranding the payer until the invoice expires.
All of this machinery resolves to one of a handful of terminal states on the
session FSM — the only thing a caller actually observes
(sdk/swaps/CLAUDE.md lists the full state sets). The
reconciliation loops in recovery.go translate a
recovery row's outcome into that terminal state:
- Pay, money returned. Whether the client refunded off-chain (leaf 2 or 3) or
the daemon completed an on-chain exit (leaf 6), the session lands in
Refunded.reconcilePayRefundRecovery(recovery.go:783) drivespayEventRefundedwhen the recovery row reports COMPLETED. - Receive, money collected. A cooperative claim or a completed unilateral
claim recovery both end in
Completed(reconcileReceiveClaimRecovery→receiveEventCompleted, recovery.go:832): from the user's seat, an on-chain sweep that lands the funds is still a successful receive. - Recovery hit a wall. A recovery row that reports FAILED parks a pay session
in
NeedsInterventionand fails a receive session terminally (Failed), each carrying the daemon's last error. - The vHTLC was funded with the wrong amount. This short-circuits the
cooperative path entirely: a pay session goes straight to
RefundInitiated(in_swap.go:1688), a receive session toFailed— neverNeedsIntervention, because a wrong amount is unambiguous, not anomalous. NeedsInterventionis the "stop and call a human" state, reserved for genuinely anomalous server behaviour — most notably a vHTLC spent without a matching preimage. The SDK refuses to guess and parks the swap.
One detail worth its own line, because it is the pay side's whole point: the
client's proof that it paid is the preimage, which it lifts out of the swap
server's claim transaction. The server's claim can take slightly different shapes
across indexer versions, so extractPreimageFromCheckpoint
(preimage.go) scans the finalized checkpoint PSBT
with several strategies — final witness, condition witness, taproot spend
signature — and accepts a candidate only when its SHA256 equals the payment
hash. A preimage that does not hash to the expected value is not proof of
anything and is discarded.
Sections 5.3–5.8 describe the rungs and the states, but not the motor. The motor
is the client's own running reconciliation loops — and nothing in the ladder
turns while they are stopped. The swap server never pushes a "your payment
failed" event: SwapService (§9) has no status or subscription RPC, only the
three request-response calls. The client discovers that the server has safely
failed a pay by polling AuthorizeInSwapRefund
(tryCooperativeRefund, in_swap.go:1250) — a returned
co-signature is the notification, and the "unavailable" answer from §5.4
simply means "not yet, keep polling." A receive likewise advances only because
the client keeps re-querying the indexer and re-attempting the OOR claim. Stop
the client process and every rung freezes where it stood: the durable recovery
rows (§5.2) survive, but nobody is reading them.
This is why a funded swap whose off-chain settlement has not completed sits with
its vHTLC plainly live on the ledger and reads, to anyone watching
balances, as "pending" — for minutes, hours, or longer. That resting state is
almost always a sleeping client, not a stuck server. By the time the vHTLC is
funded the server has already done everything it will do unprompted: it failed
the Lightning payment, armed its own mirror row (§5.7), and stands ready to
co-sign leaf 2 the instant the client asks. The move that unblocks it is bringing
the client online so its loops run, not restarting the server.
The on-chain backstop is no more automatic. With AutoEscalate off by default
(§5.5) the armed row never unilaterally exits on its own; it waits for an
operator's swapd recovery escalation. So both fast and slow recovery wait on
someone acting — the client for the immediate off-chain refund, the operator
for the on-chain exit — and "nothing is happening yet" is the system's normal
idle, not a fault.
Reading a swap that looks stuck, then, comes down to three questions. Are the
funds safe? Yes — they sit in the live vHTLC, reclaimable by their owner along
the ladder. What is the real deadline? The CLTV refund locktime, and after it
the CSV on whichever on-chain leaf applies; on a slow chain those waits run to
hours or days, so "still pending" is often expected rather than alarming. What
is the fast resolution? Drive the cooperative refund (leaf 2) — off-chain and
immediate — by getting the client polling again, rather than waiting out any
timelock. The one shape that is not benign is NeedsIntervention (§5.8): that
state is reserved for genuinely anomalous server behaviour and is the only one
that means "stop and call a human."
Not every swap needs Lightning. When the sender and receiver are both clients of the same Ark, the server can bridge them with a single vHTLC settled entirely inside Ark — no Lightning hop, no held HTLC. This is the in-Ark settlement path.
The receive side discovers it not through a flag but through the type of
mailbox event it receives. The swap mailbox carries a SwapMailboxEvent whose
oneof is either:
OutSwapHtlcEvent— a Lightning-backed out-swap; server-funded; onion-shaped (the client must decrypt the onion); orInArkHtlcEvent— a same-Ark payment; carries the sender's pubkey directly (no onion), and may already include the fundedvhtlc_outpointand amount.
acceptIncomingVHTLCNotification (out_swap.go:962)
branches on this: acceptInArkHtlcEvent for the same-Ark case, the onion path
for Lightning. Because the in-Ark sender funds the vHTLC with an OOR transfer
that the receiver's own daemon may materialise locally, the receive flow keeps a
fallback — localLiveVTXOByPkScript (out_swap.go:2017) — that consults the local
live VTXO set in addition to the remote indexer.
Cancellation needs no special case here. An in-Ark swap rides the same six-leaf vHTLC as a Lightning-bridged one, so the receiver still arms a claim recovery and the sender still has the cooperative-refund and unilateral-exit leaves: the ladder of §5 applies unchanged, only without a Lightning HTLC behind it.
Every indexer query is proof-gated: a client may only enumerate outputs at a script it can prove a relationship to. This section describes the contract end to end, because it is the part of the system most easily misread.
The client signs each query scope with a BIP-340 Schnorr proof. The signing key
is the client's default principal — the wallet identity key, which for a
receive is also the vHTLC's Receiver key (see
indexer/client.go, newTaprootScope at :517 and
proofSignerPubKey at :463). Importantly, the signature commits to the
signer's own pubkey, not to the taproot output key — which matters because the
vHTLC output key is NUMS and nobody holds it. So the proof says, in effect, "I am
this participant key, and here is my signature over this query," and leaves the
authorisation decision to the server.
The operator's indexer authorises a script-scope query in two steps
(authorizeScriptScopeQuery): it verifies each scope proof, then authorises the
script against persisted state. The second step has two paths, consulted in
order:
-
Policy auth (by row). If a VTXO row already exists at the queried pkScript, the server reads that row's stored policy template and asks whether the proof's signer key is a queryable participant of it. A key qualifies when it has at least one settlement pair with the operator — both a participant-only auth leaf and an operator-backed sibling leaf that normalise to the same node. For a vHTLC, the receiver satisfies this: its
UnilateralClaimleaf (participant-only) and itsClaimleaf (participant + operator) normalise to the sameHashLock + Multisig([receiver])node. The sender qualifies symmetrically via the refund leaves; the operator itself does not qualify (operator keys are filtered out). The upshot: once the vHTLC is funded and indexed, both swap participants can query it with no prior registration. -
Registration auth (fallback). Only for scripts that have no row yet does the server fall back to per-principal registration: the caller must have previously registered the script. If neither a row nor a registration exists, the server returns
Unauthenticatedwith the message "script not registered for principal." The phrasing names the fallback that failed; the underlying state is simply "there is no indexed output at this script, and you have not pre-registered it."
flowchart TD
Q["ListVTXOsByScripts(pkScript, proof)"]
Q --> V["verify Schnorr proof -> signer key"]
V --> R{"row exists at pkScript?"}
R -->|yes| P{"signer key is a\nsettlement-pair participant?"}
P -->|yes| OK["authorised -> return VTXO(s)"]
P -->|no| DENY1["denied: not authorised for script"]
R -->|no| REG{"script registered\nfor this principal?"}
REG -->|yes| EMPTY["authorised -> empty result"]
REG -->|no| DENY2["Unauthenticated:\nscript not registered for principal"]
The receive flow registers its claim destination (§3.1) but not the vHTLC script, and it does not need to: by the time the client claims, the vHTLC is funded and indexed, so policy auth (path 1) authorises the receiver automatically. Pre-registration would only change the wording the server returns during the window before the row exists; it would not, by itself, make an output appear. This is why the only registration in the receive path is for the claim address, and the vHTLC is observed purely through policy-authorised queries.
Putting §3–§6 together, the client distinguishes a same-Ark (p2p) swap from a Lightning-bridged (external) swap differently in each direction:
flowchart TD
subgraph R["Receive (out-swap)"]
RE["incoming SwapMailboxEvent"]
RE -->|out_swap_htlc| RL["LIGHTNING:\nserver-funded, onion-shaped,\nindexer-observed"]
RE -->|in_ark_htlc| RA["IN-ARK (p2p):\nsender-funded, no onion,\nmay carry outpoint;\nlocal-store fallback"]
end
subgraph P["Pay (in-swap)"]
PE["CreateInSwapResponse.settlement_type"]
PE -->|SETTLEMENT_TYPE_LIGHTNING| PL["LIGHTNING:\nserver claims after paying invoice"]
PE -->|SETTLEMENT_TYPE_IN_ARK| PA["IN-ARK (p2p):\nsettled inside one Ark"]
end
- Receive keys off the mailbox event type:
out_swap_htlcversusin_ark_htlc(swap.proto:244-259). The Lightning event is onion-shaped and server-funded; the in-Ark event carries the sender's pubkey directly and may already include the funded outpoint. - Pay keys off
settlement_typein theCreateInSwapResponse(swap.proto:380), whereSETTLEMENT_TYPE_UNSPECIFIEDis treated as Lightning for backward compatibility.
Everything the client asks of the swap server lives in swaprpc.SwapService:
| RPC | Direction | Purpose |
|---|---|---|
RequestChannelId |
Receive | Allocate a virtual SCID from (clientKey, hash) and return the route hint the client embeds in its invoice, so swapd becomes the final hop. |
CreateInSwap |
Pay | Negotiate a vHTLC for a given invoice; returns the config, fee, deadline, and settlement_type. |
AuthorizeInSwapRefund |
Pay | Co-sign the immediate cooperative Refund leaf (leaf 2) of a funded in-swap after the server has safely failed its Lightning payment, letting the client reclaim its funds off-chain at once rather than wait out the CLTV locktime. See §5.4. |
Everything the client asks of the operator's indexer (via the daemon) is a
proof-gated query — chiefly ListVTXOsByScripts, reached through
FindLiveVTXOByPkScript → GetIndexedVTXOByPkScript
(waved/rpc_swap_lookup.go) → indexer.ListVTXOsByScriptsTaproot. The indexer
is not part of swapd; it belongs to lumosd. That separation — the swap server
funds, but the operator's indexer witnesses — is the defining structure of the
receive path.
The recovery ladder of §5 rides on a third surface: the local daemon's
waverpc recovery calls — ArmVHTLCRecovery, EscalateVHTLCRecovery,
CancelVHTLCRecovery, and GetVHTLCRecoveryStatus. These are daemon-owned, not
swap-server RPCs: the SDK arms and reconciles the rows
(sdk/swaps/recovery.go), while the daemon owns the
on-chain unroll once a row escalates.
docs/arkscript_spec.md— the tapscript policy system that compiles the vHTLC tree, and theValidatePolicyinvariants behind the collaborative/exit split of §1 and §5.docs/mailbox_architecture.md— how swap events are delivered and acknowledged.docs/RPC_MAILBOX_CONTRACT.md— envelope and ack semantics behind the proof-gated indexer calls.sdk/swaps/CLAUDE.md— the FSM states, key types, and invariants of the swap SDK.indexer/CLAUDE.md— the proof-of-control query client.