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 preimageP 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 interactiverefundWithoutReceiver 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
Theonchain: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. Onarkade: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_expired—valid_untilhas passed;insufficient_headroom— less than 90 minutes beforerefund_locktime.
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_amountexactly.
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_amountcaptured 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 publishesPand 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.
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_range—min_confirmationsoutside 1 to 6;claim_window_too_short—htlc_locktimenot more thanmin_confirmations × 600 + 5400seconds away;timelock_order—htlc_locktime + 7200falls afterrefund_locktime. A quote that carriesrefund_locktimeonly insideprofilederives successfully and is then refused here.
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_locktimeis left to the solver’s own safety check. - Nothing on the user’s side observes
min_confirmationshere. 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.
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 betweenfrom_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’spayment_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.
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:
- inspect the locally derived contract and its spend history;
- inspect destination-network evidence — the Lightning payment or the L1 HTLC;
- validate terminal receipts returned by the solver;
- treat relay or solver status only as a hint;
- attempt recovery only after classifying the existing spends.
Sources
- RFQ Protocol — the wire family carrying these quotes; the reference corridor service is closed source while its HTLC state machine is audited
@arkade-os/swappackage- Hashlock contracts — the VHTLC on its own
- Implementation Status
Lightning
Code for both Lightning directions.
Trust and Limitations
What each contract path actually enforces.