Skip to main content
A corridor swap is a route with one endpoint outside Arkade: lightning:BTC or onchain:BTC on one side, arkade:BTC on the other. Neither network can see into the other, so the swap is settled by two independent contracts that share one payment hash — one on each network, each enforceable on its own network, linked by nothing but that hash. Four directed pairs are served. Their readiness differs (Implementation Status); the mechanism below is the same for all four.

Paired HTLCs

Each contract locks its funds behind two mutually exclusive outcomes: anyone who presents the preimage P may claim it, and after a timeout its funder may take it back. The preimage is the only thing that crosses the network boundary, and it crosses in public — claiming a hashlocked output means publishing P in a witness, where the counterparty reads it and uses it to claim the other contract. The two contracts commit to the same value in the same form: the wire field payment_hash is sha256(P) in hex, following the BOLT11 convention, and both scripts commit to ripemd160(sha256(P)), derived locally from that field on both sides. One preimage opens both claim leaves; nothing else links the two contracts. No message, no identifier, and no solver attestation is load-bearing here — if the hashes match, the pair is a swap, and if they do not, they are two unrelated contracts that will each time out. Who owns P is not the same on every direction: The Lightning send is the one direction where the user allocates no preimage material at all — an ordinary BOLT11 payment already has a secret, and it belongs to the payee. Every other direction inverts normal Lightning: the user generates P and the counterparty issues or funds against a hash it did not choose. On an HD wallet the preimage is a pure function of a freshly allocated signing descriptor, so nothing needs storing beyond the public descriptor. A new descriptor is allocated per swap rather than reused: two swaps sharing one would derive the identical P, and one solver learning its own secret would learn the other swap’s. A single-key wallet cannot derive a preimage this way and carries a stored random one instead.

The Arkade Contract

The Arkade side of every corridor swap is a VHTLC — one taproot tree with eight leaves, always all eight. There is no reduced variant: both covenant leaves are built on every quote, in every direction. Two of the tree’s parameters name the parties. sender is whoever funds the lockup and gets it back if the swap fails; receiver is whoever claims it with P. The assignment flips with direction, which is why the same leaf table reads differently on a send than on a receive. Every preimage-gated leaf below is prefixed with OP_SIZE 32 OP_EQUALVERIFY before the hash comparison. Without that length check the claim leaves would accept any witness value whose HASH160 matches, whatever its length. claimDelay is not a quote field. It is derived from your own Arkade server’s reported unilateral exit delay, rounded up to a multiple of 512 seconds, and the two refund tiers are one and two granularity steps above it — so the three tiers are ordered by construction and cannot drift apart. Both sides read the same server, and that derivation, not a quoted number, is what makes the two parties’ scripts byte-identical. Two leaves deserve reading closely. nonInteractiveClaim carries no signature from the receiver at all — its pubkey does not appear in the leaf. What pins the payout to the receiver is the covenant, not a key. nonInteractiveRefund deliberately keeps the receiver as a signer even though it pays the sender: that is what lets the counterparty release a failed swap the moment both sides agree it failed, instead of making the funder wait out refund_locktime. It carries no timelock and needs no signature from the sender, which makes it the only refund path still reachable when the sender key is permanently lost.

What the covenant enforces

The covenant reads exactly one output: the one at the same index as the input it is spending. That output must pay the pre-committed script and carry at least the input’s value. Everything else about the transaction — other inputs, other outputs, where change goes — is unconstrained. Index alignment is a liveness obligation on whoever assembles the spend, never a safety assumption for the other side: a transaction with no output at the matching index makes the leaf unsatisfiable, not exploitable. The client package builds neither non-interactive spend — its own refund uses the interactive refundWithoutReceiver leaf precisely because that leaf carries no per-index constraint. Both covenant leaves depend on the Emulator key, which the client package resolves from its per-network pin; it is never something an integrator sources. The interactive leaves survive without it — review Trust and Limitations before using any covenant path with value.

Funding, claiming, refunding

A lockup may be funded by more than one send, so both spends read every output at the address, not the first. The refund spends them all into one aggregate output whose destination defaults to the address the contract itself committed to at quote time, so an ordinary refund cannot send funds somewhere the funder did not name. Funding the derived address is acceptance. There is no accept message anywhere in this protocol; the solver fills by observing the funding, not by being told. The lockup is registered with the wallet’s contract manager before any address is handed back, which keeps it watched from the moment it lands and keeps it out of ordinary coin selection.

The Bitcoin L1 Contract

The onchain:BTC side is a two-leaf 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 leaves themselves. One shape serves both directions; only the key roles swap. Both spends are one input, one output, 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 holding the higher number would refuse payouts the network relays fine, which on a refund path means refusing to return returnable funds. The claim spend uses sequence 0xfffffffd and no locktime. The refund spend sets nLockTime to the HTLC’s locktime with sequence 0xfffffffe, and 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 package holds no keys and bundles no chain backend: signing is a callback over the BIP-341 sighash, and Bitcoin access is a four-method interface the integrator injects.

Timeout Ordering

Both contracts must be claimable in a fixed order, because the first claim is what makes the second one possible. That gives the rule: The contract claimed second must still be claimable after the contract claimed first has published the preimage — so the refund of the contract claimed first must open last, with margin. Inverting the order does not slow a swap down; it hands one side both legs. On arkade:BTC → onchain:BTC the user claims the L1 HTLC first and the solver then claims the Arkade lockup with the revealed P. If the user’s Arkade refund matured first, the user could take the L1 fill and immediately refund the Arkade lockup out from under a solver that still had a legitimate claim to make. The funding gate refuses that quote outright with reason timelock_order. The margin is also what makes an outage survivable. Because the Arkade refund is guaranteed to open a clear interval after the L1 claim window has already shut, an unreachable Bitcoin chain source cannot strand the lockup — a failed L1 read falls through to the refund path instead of ending the pass. Every wall-clock margin on this page exists for one reason: consensus decides timelocks by median-time-past, which trails wall clock by roughly an hour (BIP-113). On the funding side that eats into the window, so the gates demand extra headroom. On the refund side it means the first pushes after a deadline are expected to be rejected, so the retry window extends past it. Read together, the last two set the floor for the L1 send corridor: at the maximum depth the L1 locktime must be more than 6 × 600 + 5400 = 2.5 hours out at funding time, and therefore the Arkade refund_locktime more than 4.5 hours out. The 90-minute funding headroom is never the binding constraint on that path — it binds the Lightning send, which has no second chain to wait on. The 90-minute L1 claim margin encodes a strategy, not just slack. Broadcasting a claim publishes P in the mempool; claiming into the counterparty’s live refund window risks losing the race and handing over the secret. Past that point the safe move is to let the swap die and take the covenant refund. Two clocks disagree here by design, and driving off the wrong one is a recognizable failure: an L1 HTLC still reads as claimable right up until MTP reaches its locktime, while the claim builder refuses from 90 minutes of wall clock before the same instant. Code that dispatches straight off the observed phase spends that entire margin throwing claim_window_closed at every poll and never falls back. Every gate runs immediately before value moves, never at quote time, and 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. A missing binding field is refused before any script is built. Everything else in the tree is your own data — your invoice, your Arkade server’s signer key and exit delay, your own payout or refund address — or the Emulator key the package pins. 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. Both derivations are 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_expiredvalid_until has passed;
  • insufficient_headroom — less than 90 minutes before refund_locktime.
A fresh sender key is allocated per swap. On an HD wallet only a public descriptor comes back and nothing needs protecting; otherwise the raw key comes back and must be persisted. Losing it does not lose the funds outright — nonInteractiveRefund still reaches them — but that leaf needs the solver’s active cooperation rather than mere infrastructure uptime, so an unwilling solver plus a lost key is a total loss. After funding, the user may go offline. Recovery is refundWithoutReceiver after refund_locktime: the user and the Arkade server, with no solver signature and no Emulator involvement. The two CSV leaves would avoid the server but require a real unilateral exit and a strictly longer wait, so they are not the ordinary path. There is no “please refund me” message in this protocol — 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 on Arkade and still derives and verifies the lockup, because the tree the solver funds must be the one whose claim leaves 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 on-chain 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 leaf: the user and the Arkade server, no solver signature and no claim service, spendable the moment the lockup lands. P is disclosed at submit, not at confirmation — it rides to the server attached to the transaction — so every check that matters runs before signing. The solver reads P off 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 leaf 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 — an Arkade claim lands in seconds, and wall clock is already the conservative reading of a deadline consensus measures by median-time-past. The request carries P sealed to the claim service (covclaimd) so the swap could one day be claimed without you. That service cannot spend this covenant today, so the offline path the packet exists for does not run: stay online to claim (Implementation Status).

arkade:BTC → onchain:BTC

The Arkade lockup is the same tree as the Lightning send, from the same derivation and the same golden test; only the source of the payment hash differs — a user-generated 32-byte P instead of a BOLT11 hash. The user funds Arkade, the solver fills on L1, and the user claims that fill. The user cannot go offline after funding. It must claim the L1 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 L1 claim leaf 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_rangemin_confirmations outside 1 to 6;
  • claim_window_too_shorthtlc_locktime not more than min_confirmations × 600 + 5400 seconds away;
  • timelock_orderhtlc_locktime + 7200 falls after refund_locktime. A quote that carries refund_locktime only inside profile derives successfully and is then refused here.
Watch the HTLC address for the largest output at or above the required depth, then claim before the margin closes. Compare that output’s value against the quote’s to_amount yourself, before you claim — nothing in the package does it for you on this corridor. The watcher returns the largest output at the required depth and the claim gates only on the claim window; neither takes an expected amount. The asymmetry is worth stating because it is easy to miss: the Arkade receive leg carries this guard in the library, where the claim refuses to publish P against a short-funded lockup. Here the same mistake is yours to prevent, and it costs the same thing — claiming publishes P, which is what lets the solver take the full Arkade lockup, so a fill worth less than the quote claimed anyway pays the solver in full and you in part. Refusing leaves the swap to the Arkade refund at refund_locktime, which is the right outcome against a solver that underfunded. Recovery has one shape only: if the L1 claim window shuts, take the Arkade refund at refund_locktime. An L1 HTLC that reads as refundable is not an invitation to refund — that leaf is the solver’s, the user holds no key on it, and reaching that state means the claim was missed. Automation that watches this corridor without a Bitcoin chain source should fail immediately rather than retry, since watching blind lets the claim window pass in silence.

onchain:BTC → arkade:BTC

The user funds the L1 HTLC and the solver funds the Arkade lockup once the funding reaches the agreed depth. The user claims Arkade, publishing P, and the solver claims the L1 HTLC with it. The Arkade lockup here is the same tree as the Lightning receive, with the same claim rules and the same value check before publishing P. The funding transaction on L1 is the user’s own wallet’s job; the package derives the address and never builds that spend. Three properties of this direction are worth stating plainly:
  • No local timelock-ordering gate runs. The ordering between the user’s L1 refund leaf and the solver’s Arkade refund_locktime is left to the solver’s own safety check.
  • Nothing on the user’s side observes min_confirmations here. The depth is the solver’s to watch before it funds Arkade.
  • This direction is not monitored. It is deliberately excluded from the client-side swap manager, because its L1 half carries a second deadline and a second recovery action; a manager that drove only the Arkade half would let the L1 refund window pass in silence.
Recovery is the L1 refund leaf at htlc_locktime, which is the user’s own key and needs nobody’s cooperation — gated on the chain’s median-time-past.

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. On the send legs a quote is refused if from_amount is below to_amount; on the receive legs it is refused if to_amount exceeds from_amount. Neither send leg carries a price ceiling — a bad price is visible before anything is committed, unlike an opaque invoice or an underfunded lockup — while the Lightning receive leg accepts an optional absolute ceiling on what it will pay.

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. Arkade-to-Arkade exchanges use a different non-interactive swap contract that does bind asset identity. See Arkade 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 leaf 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 leaf’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: 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 L1 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.