Skip to main content
A corridor swap executes a route with one endpoint outside Arkade: a Lightning invoice or a Bitcoin address on one side, BTC within Arkade on the other. Neither network can see into the other, so two independent contracts settle the swap, one on each network, linked only by a shared payment hash. Three directions use this mechanism: Lightning send, Lightning receive, and onchain send. Their readiness differs: see Implementation Status.

Paired HTLCs

Each contract has two outcomes: the receiver claims it with the preimage P, or, after a timeout, the funder takes it back. Only P crosses from one network to the other, and it crosses in public. A claim publishes P in its witness, where the counterparty reads it to claim the other contract. The wire field payment_hash is sha256(P) in hex, as in BOLT11. Both scripts commit to ripemd160(sha256(P)), which each side derives from that field. No message, identifier, or solver attestation links the two contracts. If the hashes match, the pair is a swap. If they differ, they are two unrelated contracts that each time out. On Lightning send, P belongs to the invoice’s payee: neither the user nor the solver picks it. On the other two directions, the user’s wallet derives P from its own key, so in the usual case nothing secret needs storing. An HD wallet derives P from a fresh signing descriptor per swap: two swaps sharing a descriptor would share P, and a solver that learned one would learn the other. A single-key wallet’s tr(pubkey) descriptor repeats across swaps, so it derives P from a public salt stored with each swap. The salt is not secret: deriving P from it still needs the wallet’s key. Only a signer that cannot sign deterministically falls back to a stored random preimage.

The Arkade Contract

The Arkade side of every corridor swap is a VHTLC: one Taproot tree with nine spending paths, six consensus-enforceable and three emulator-enforced. A solver that predates the ninth path quotes the older eight-path tree (legacy), so the client derives both and keeps the one that matches the quoted lockup_address. sender funds the lockup and gets it back if the swap fails. receiver claims it with P. These are contract positions, and the party in each flips with direction: Every preimage-gated path checks that the witness item is 32 bytes (OP_SIZE 32 OP_EQUALVERIFY) before it compares the hash. Without that length check the claim paths would accept any witness value whose HASH160 matches, whatever its length. In the table, CLTV(t) opens a path at the absolute time t, and CSV(d) opens it a delay d after the lockup’s output confirms onchain. On the three nonInteractive paths, “operator” means two signatures: one from the operator’s cosigner that enables offchain spending, and one from the operator’s cosigner that emulates Arkade Script. claimDelay is not a quote field. Both sides derive it from the operator’s advertised unilateral exit delay, rounded up to a multiple of 512 seconds, and that shared derivation is what makes their scripts byte-identical. unilateralRefund uses the same delay: it needs both parties’ signatures, so a longer delay would add no safety. unilateralRefundWithoutReceiver is the one path whose timing could steal: a funder who refunds before a claimant holding P can claim takes the claimant’s money. It therefore opens 4096 seconds, eight 512-second steps, later. A send quote can set that last delay instead: profile.refund_without_receiver_delay replaces claimDelay + 4096s. The Lightning send requires the field, and an onchain send quote without it uses claimDelay + 4096s. The client refuses a value below claimDelay, not a multiple of 512 seconds, or above 0xffff × 512 seconds. On the Lightning send, it also refuses one shorter than the time left until refund_locktime. nonInteractiveClaim takes no signature from the receiver. The covenant, not a key, pins the payout. nonInteractiveRefund pays the sender but needs the receiver’s signature, not the sender’s, so the receiver can return a failed swap at once instead of making the funder wait for refund_locktime. nonInteractiveRefundWithoutReceiver is the only refund path that needs no participant signature: not from a sender whose key may be lost, and not from a receiver who may refuse. @arkade-os/swap does not spend it yet. When the wallet cannot produce the sender key, the package reports the refund as blocked (RefundNotLocallyPossibleError).

What the covenant enforces

The covenant reads one output: the one at the same index as the input it spends. That output must pay the pre-committed script and carry at least the input’s value. The covenant constrains nothing else in the transaction. Whoever builds the spend must place that output at the matching index. A transaction with no output there cannot satisfy the path, so a wrong index blocks the spend but cannot redirect funds. @arkade-os/swap builds no non-interactive spend: its refund uses refundWithoutReceiver, which has no index constraint. On the three emulator-enforced paths, the operator runs the covenant’s Arkade Script before it signs. Those paths commit to the key of the operator’s cosigner that emulates Arkade Script, which the client resolves from the SDK’s per-network pin. The other six paths do not use that key. Review Trust and Limitations before you use an emulator-enforced path with value.

Funding, claiming, refunding

A lockup may hold outputs from more than one funding send, so the claim and the refund read every output at the address, not only the first. The refund spends them all into one output. By default it pays the refund address the contract committed to at quote time, so an ordinary refund cannot send funds somewhere the funder did not name. Funding the derived address is the acceptance: no accept message exists, and the solver fills when it sees the funding. The swap client registers the lockup with the wallet’s contract manager before anything is funded. The wallet then watches the lockup from the moment it lands, and keeps it out of ordinary coin selection.

The Onchain Contract

The onchain:BTC side is a two-path taproot output whose internal key is the BIP-341 NUMS point. There is no key-path spend, ever — the only two ways to move the money are the paths themselves. When the user pays via Bitcoin, claimKey is the user’s payout key and refundKey is the solver’s HTLC key. Both spends have one input, one output, and no change: the fee is measured from a dummy-signed build and subtracted from the HTLC amount. The dust floor is 330 sats, not 546. 546 is the P2PKH figure, and both payout scripts here are Taproot, so the higher floor would refuse payouts the network relays, including refunds of returnable funds. The claim spend uses sequence 0xfffffffd and no locktime. The refund spend sets nLockTime to the HTLC’s locktime with sequence 0xfffffffe. It is consensus-valid only once that locktime has matured against median-time-past: gate a refund broadcast on the chain’s MTP, never on wall clock. The HTLC primitives hold no keys: signing is a callback over the BIP-341 sighash. They read Bitcoin through a ChainSource interface. The swap client builds one from a public Esplora server for the network, and corridors.onchain.chain.esploraUrl points it at your own. The low-level RfqSwapManager takes a ChainSource directly. Give it the optional getTxStatus method: without it, a claim still in the mempool reads as mined, so an onchain send can settle on a claim that never confirms.

Timeout Ordering

The first claim publishes P, and the second claim needs it. The contract claimed second must still be claimable after the first claim publishes P, so its refund must open last, with margin. With the order inverted, one side can take both legs. When the user pays via Bitcoin, the user claims the onchain HTLC first, and the solver then claims the Arkade lockup with the revealed P. If the user’s Arkade refund opened first, the user could take the onchain fill and refund the Arkade lockup before the solver claims it. The funding gate refuses such a quote with reason timelock_order. The margin also makes an outage survivable. The Arkade refund opens well after the onchain claim window shuts, so when a chain read fails, the swap client moves on to the refund check. An unreachable Bitcoin chain source therefore cannot strand the lockup. Consensus checks absolute timelocks against median-time-past (BIP-113), which trails wall clock by about an hour. On the funding side, that lag shrinks the usable window, so the gates demand extra headroom. On the refund side, the first pushes after a deadline are expected to fail, so the client keeps retrying past it. At the maximum depth, the last two set the floor for the onchain send. The HTLC locktime must be more than 6 × 600 + 5400 seconds (2.5 hours) out at funding time, so the Arkade refund_locktime must be more than 4.5 hours out. The 90-minute funding headroom never binds on that path. It binds the Lightning send, which has no second chain to wait on. The 90-minute onchain claim margin protects P. A claim publishes P in the mempool, and a claim broadcast while the counterparty’s refund path is open can lose the race and still hand over P. Past the margin, the safe move is to let the swap expire and take the Arkade refund. Two clocks disagree here by design. An onchain HTLC reads as claimable until MTP reaches its locktime, but the claim builder refuses from 90 minutes of wall clock before that point. Code that acts on the observed phase alone throws claim_window_closed at every poll for the whole margin and never falls back to the refund. The swap client runs these gates when it builds a quote, and accept refuses a quote past its expiry. Each failure carries a stable machine-readable reason: invoice_expired, quote_expired, insufficient_headroom, confirmations_out_of_range, claim_window_too_short, timelock_order.

Derive, Never Accept

The address in a quote is comparison-only. Derive the contract from the quote’s binding fields plus your own data, compare the result to the quoted address byte for byte, and refuse on any mismatch. Nothing in a quote is ever used because the solver said so. The client refuses a quote that is missing a binding field before it builds any script. Everything else in the tree is your own data: your invoice, the operator’s exit delay, and your own payout or refund address. The client reads one operator key from the operator, and the SDK pins the other. receiver_pk_script is binding but not trusted: it is consumed only so the covenant key of nonInteractiveClaim can be derived, and a wrong one simply produces a different address and a refusal. The Arkade lockup and the onchain HTLC are both byte-pinned by golden tests. Any change to them changes every address on both sides of a swap and requires coordinated deployment; a version mismatch surfaces as a refused quote at the address comparison, not as lost funds.

arkade:BTC → lightning:BTC

The user funds the Arkade lockup, the solver pays the invoice, and the solver learns P from the payee by paying — not by claiming. Its claim of the lockup comes afterwards, which is why that claim’s witness is treated as evidence the Lightning payment landed rather than as the moment the secret changed hands. The payment hash bound into the contract comes from your own decode of your own invoice, never from the quote. This corridor is exact-out: to_amount must equal the invoice amount exactly — a quote that reprices the invoice is not a quote for that invoice — and from_amount must be at least to_amount. Fund from_amount, the invoice plus the fee. Funding to_amount underfunds the lockup by exactly the fee. Requesting the swap performs a fixed order — quote, derive, compare the address, run the gates, register the contract, and only then return a fundable address — and refuses to hand back an address if any step fails:
  • invoice_expired — the invoice’s own expiry has passed;
  • quote_expired — valid_until has passed;
  • insufficient_headroom — less than 90 minutes before refund_locktime.
The sender key is the wallet’s own identity key, not a fresh child key. The package reuses it on purpose: no HD index is used up for a contract that may never be built, and the refund path sits on the same key as the refund address given at quote time. The wallet never returns a raw key — it answers with a descriptor and keeps the key — so there is nothing to persist or protect here. After funding, the user may go offline. Recovery is refundWithoutReceiver after refund_locktime, a consensus-enforceable path that the user and the operator sign, with no solver signature. The CSV paths avoid the operator but need a real unilateral exit and a longer wait, so they are not the ordinary path. No message asks the solver for a refund: the transport carries requests, status, and close, nothing else.

lightning:BTC → arkade:BTC

This inverts ordinary Lightning. The user generates P, sends only H, and the solver issues an invoice on a hash it does not know the secret for — which is why it must be a hold invoice. The solver funds the Arkade side; the user funds nothing within Arkade and still derives and verifies the lockup, because the tree the solver funds must be the one whose claim paths pay the user. Verify the invoice against your own H before you publish it. This is the one attack on this corridor that leaves no onchain trace: an invoice on a different payment hash pays the solver in full, and no lockup on your hash is ever funded. The request helper takes the BOLT11 decoder itself and does the comparison in-library, because a caller-supplied summary of an adversary’s invoice checks nothing. There is deliberately no check for “is this actually a hold invoice” — on the wire it is indistinguishable from an ordinary one. Three checks run on the invoice, and a failure means never publish it:
  • the decoded payment hash must equal your own H;
  • the amount must be greater than zero — an amountless BOLT11 decodes as zero and would let a payer pay anything, so a nullish check would miss it;
  • the amount must equal from_amount exactly.
Paying the invoice is the first enforceable commitment; that payment is the acceptance. The pay deadline is min(invoice expiry, valid_until) — a hold invoice’s window is minutes, not the quote’s hour — and the claim-window gate is measured from that deadline, not from now, because a payer may arm the swap at the last possible moment. refund_locktime on this leg is the solver’s deadline to reclaim, not yours. Median-time-past therefore extends your claim window instead of shrinking it, which is why this leg gets its own gate rather than the send-side headroom check. Before claiming, one more check stands between you and a total loss:
  • the lockup must carry the to_amount captured at request time, summed across every live output. The named attack is a solver that funds the correctly derived script with dust; local derivation — what protects every other corridor — proves nothing here, because the script was never the lie. Claiming anyway publishes P and hands over the full payer HTLC.
  • an absent or non-finite expected amount is refused outright, because an unusable comparand does not fail the check, it deletes it;
  • swept outputs are refused before the value check runs, since one aggregate transaction means a single dead input takes the live ones down with it.
The claim spends the claim path: the user and the operator sign, with no solver signature, as soon as the lockup lands. P is disclosed at submit, not at confirmation: it travels to the operator with the transaction, so every check runs before signing. The solver reads P from the public claim and settles the held HTLC with it. If you never claim, there is no user-side refund on this leg: every non-claim path of the covenant belongs to the solver. The swap is simply lost, the solver reclaims at refund_locktime, and the payer is refunded when the held HTLC lapses. The claim window closes at refund_locktime on wall clock with zero margin, deliberately — a claim through Arkade lands in seconds, and wall clock is already the conservative reading of a deadline consensus measures by median-time-past. The swap client claims only while it runs. To receive while the wallet is offline, run lnurl-server with OFFLINE_SELF_CLAIM on. It quotes the receive itself, holds P, and claims through nonInteractiveClaim, an emulator-enforced path that pays only the wallet’s address. Run Lightning Services sets it up.

Pay via Bitcoin

The Arkade lockup is the same tree as the Lightning send, built by the same function. Its payment hash comes from a user-generated 32-byte P instead of an invoice. Its sender key is the key that also derives P: a fresh child key on an HD wallet. The user cannot go offline after funding. The user must claim the onchain HTLC before htlc_locktime, or forfeit the fill and fall back to the Arkade refund. A caller-supplied preimage must be exactly 32 bytes: the onchain claim path pins OP_SIZE 32, so any other length funds an HTLC nobody can claim. The gates, on top of the send-side headroom check:
  • confirmations_out_of_range — min_confirmations outside 1 to 6;
  • claim_window_too_short — htlc_locktime not more than min_confirmations × 600 + 5400 seconds away;
  • timelock_order: htlc_locktime + 7200 falls after refund_locktime. A quote that carries refund_locktime only inside profile derives successfully and is then refused here.
The swap client watches the HTLC address for the largest output at or above the required depth, then claims before the margin closes. It compares that output’s value against the quote’s to_amount before it claims. It refuses to publish P against a short fill, because that claim would still give the solver the full Arkade lockup. Refusing leaves the swap to the Arkade refund at refund_locktime. The bare primitives on @arkade-os/swap/protocol, awaitOnchainFill and claimOnchainFill, take no expected amount: if you call them directly, compare the value yourself. The swap client re-publishes a claim the network dropped, and replaces one still unconfirmed after 20 minutes at a higher fee. Its claimed outcome means the claim confirmed. Recovery has one shape: if the onchain claim window shuts, take the Arkade refund at refund_locktime. An onchain HTLC that reads as refundable is not an invitation to refund. That path is the solver’s, and the user holds no key on it: reaching that state means the claim was missed. Automation that watches this route without a Bitcoin chain source should fail at once rather than retry, since watching blind lets the claim window pass unnoticed.

Fees

Corridor pricing can contain a basis-point spread that scales with the amount and a flat satoshi component for costs that do not. The quote carries no fee field: the difference between from_amount and to_amount is the complete cost of the swap under that quote. Fund from_amount. Every corridor refuses a quote whose to_amount exceeds its from_amount. The swap client’s pay, receive, and exchange take an optional maxFee ceiling. The client applies the lower of that and policy.maxFee, and throws MaxFeeExceeded before anything is funded. Only those three verbs check the ceiling: quote followed by accept funds without it.

Asset Boundary

The corridor contract commits to a destination and a minimum output value, and does not commit to an asset identity. The implemented corridor routes therefore carry BTC on both legs. Exchanges with both sides settling through Arkade use a different non-interactive swap contract that does bind asset identity. See Asset Swaps.

Recovery Rule

A relay timeout or a missing status response does not prove failure. The chain read is authoritative, and it decides the outcome without the solver’s help. Only a witness item that hashes to the quote’s payment_hash counts as a claim. Every other path either pays a covenant-pinned address or requires the funder’s own signature, so a lockup that was spent but not by a hash-verified claim means the money came back. A witness of the right shape is not proof; the hash is. Read that evidence carefully:
  • the spending record names the checkpoint transaction, which is the one carrying the path’s witness — the transaction after it is the wrong place to look for a preimage;
  • an empty output set, a spend the indexer cannot produce a transaction for, or an undecodable witness all mean unknown, never returned. Only a lockup whose every spend was actually observed can be called returned;
  • the right response to unknown is the same as to open: keep watching, and let the timelock — which no outage can move — end the wait.
A refund push is atomic: one transaction spending every output at the lockup into one aggregate output. Query both the spendable and the recoverable sets; reading only the spendable set reports “nothing to refund” over money still sitting at the script. Swept outputs are a different failure from an immature timelock and must not be retried as one. A swept output cannot be spent offchain by any key until it is recovered, and because every input rides one transaction, a single swept output refuses the whole push and names the outpoints rather than being filtered out — filtering would report success over money that never moved. Recovering early is itself hazardous: recovery sweeps every recoverable output into one settlement with no awareness of your CLTV, so an attempt before refund_locktime can fail the whole batch, including unrelated outputs. Expect the first refund pushes after refund_locktime to be rejected. The window opens by wall clock and matures by median-time-past, so retry on your poll interval until refund_locktime + 7200 and surface the last error then. A dead negotiation is not a reason to stop: only settled and refunded say anything about whether sats are still at the lockup.

Product Outcomes

Applications translate protocol state into route-specific language. The swap client reports these as the outcome values in parentheses: Never present funded, filling, or settled without saying what the user can safely do next. When local and remote state disagree, reconcile in this order:
  1. inspect the locally derived contract and its spend history;
  2. inspect destination-network evidence: the Lightning payment or the onchain HTLC;
  3. validate terminal receipts returned by the solver;
  4. treat relay or solver status only as a hint;
  5. attempt recovery only after classifying the existing spends.
Never retry a commitment because a status request timed out. On restart, restore registered contracts and persisted records before starting live subscriptions, and reconcile terminal evidence before enabling any automatic claim or refund action.

Sources

Lightning

Code for both Lightning directions.

Trust and Limitations

What each contract path actually enforces.