Paired HTLCs
Each contract has two outcomes: the receiver claims it with the preimageP,
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
Theonchain: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 publishesP, 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_untilhas passed;insufficient_headroom— less than 90 minutes beforerefund_locktime.
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_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 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-byteP 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_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 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 betweenfrom_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’spayment_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.
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 theoutcome 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:
- inspect the locally derived contract and its spend history;
- inspect destination-network evidence: the Lightning payment or the onchain 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
intent-solver: the open-source reference solver for these routes@arkade-os/swappackage- Hash Time Locked Contract: the VHTLC on its own
- Implementation Status
Lightning
Code for both Lightning directions.
Trust and Limitations
What each contract path actually enforces.