@arkade-os/swap 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,562 @@
1
+ # @arkade-os/swap
2
+
3
+ Client-side [Arkade Intents](https://arkade.money) asset swaps: discover markets, quote and
4
+ validate, create offers, track them, cancel them, and rebuild the whole record set from chain after
5
+ a wallet restore. Framework-free TypeScript over `@arkade-os/sdk`: the core API and
6
+ `InMemoryAssetSwapRepository` use no DOM and no Node-specific APIs, so they run in Node, the
7
+ browser, and React Native alike. `IndexedDbAssetSwapRepository` is the one exception — it needs a
8
+ platform-provided or polyfilled IndexedDB.
9
+
10
+ ## Roles
11
+
12
+ Arkade Intents names two participants:
13
+
14
+ - **user** — states an intent and, through a wallet or application, approves and funds it. That is
15
+ the consumer of this package: it prices a swap against the registry's markets, funds the derived
16
+ contract, and tracks it to a fill or a cancellation.
17
+ - **solver** — supplies inventory and pricing, and fills the funded contract by delivering
18
+ `wantAmount` to the user's script over the covenant's `fulfill` path. Some specifications and
19
+ repositories use *provider* or *market maker* as synonyms.
20
+
21
+ **`maker` and `taker` in this package name contract positions, not product roles.** The covenant
22
+ programs bind `makerWP`, and the `Offer` type carries `makerPkScript` and `makerPublicKey`; those
23
+ identify the side that funds the swap and receives `wantAmount`. Read them as script field names.
24
+
25
+ Arkade Intents documentation deliberately avoids maker and taker for the participants themselves.
26
+ A resting maker order is firm once taken, and nothing here is: the user funds first, and if no
27
+ solver fills, the deposit comes back through `cancelOffer` rather than through an executed trade.
28
+ Naming the sides *user* and *solver* says who does what without borrowing a guarantee the contract
29
+ does not make.
30
+
31
+ ## Request for quote
32
+
33
+ Every Arkade Intents route is request-for-quote: the user states an intent, receives the solver's
34
+ terms as a quote, funds the contract it derives from those terms, and a solver fills it. This
35
+ route is no exception — what is specific to it is *where the quote is resolved*. `quoteOffer`
36
+ prices the swap client-side from the market card the solver publishes: its price feed and its fee,
37
+ the same two inputs a relay quote would carry. Same protocol, one fewer network hop, and a quote
38
+ that is ready before the user finishes typing an amount.
39
+
40
+ The card commits a solver to a price; a fill commits it to your swap. Nothing is signed and no
41
+ inventory is reserved until a solver lands on the funded contract, so treat the quote as terms to
42
+ show and validate — which is what `validatePlan` is for — rather than as a reservation.
43
+
44
+ Relay-negotiated quotes are where this is going, and not only here: every corridor — Lightning,
45
+ onchain, and intra-Arkade alike — converges on asking solvers for quotes over the relay, under one
46
+ message family. What stays specific to this route is the settlement script, not the negotiation:
47
+ both legs live in the same ledger, so a non-interactive swap covenant replaces the HTLC that
48
+ cross-ledger corridors need. The quote you resolve locally today is the quote a solver will answer
49
+ with then.
50
+
51
+ ## Funding, then fill or cancel
52
+
53
+ Every swap has the same two beats, on this route and on the cross-ledger corridors:
54
+
55
+ 1. **Funding** — the user funds the contract it derived from the quote. Funding *is* acceptance;
56
+ there is no accept message to send, here or anywhere in Arkade Intents.
57
+ 2. **Fill, or cancel** — a solver fills by delivering the other side, or the user takes the
58
+ deposit back.
59
+
60
+ Cancel is this route's refund path. Where an HTLC corridor refunds through a timelocked leaf, this
61
+ covenant refunds through `cancelOffer` — a 2-of-2 with the Arkade server, **no solver signature
62
+ involved**. Same job, same guarantee that the money comes home, reached by a script that fits a
63
+ single-ledger swap.
64
+
65
+ The one thing to design for: the covenant carries no timelock, so an offer keeps its place until
66
+ it is filled or cancelled. There is no window to miss, no deadline to race, and no expired state
67
+ to recover from — the trade-off is that the deposit comes back when you ask for it, so a UI that
68
+ funds an offer should keep cancelling within reach.
69
+
70
+ ## The seven layers
71
+
72
+ 1. **`offer`** — the swap covenant itself. Two program JSONs (want-BTC / want-asset), the
73
+ `Offer` type, the TLV wire codec (`encodeOffer`/`decodeOffer`, `OFFER_PACKET_TYPE`), address
74
+ derivation (`offerVtxoScript`), and the user-side operations `createOffer`/`cancelOffer`. Identical
75
+ offers always derive identical swap addresses — the program JSONs are hashed into the address,
76
+ so their bytes are frozen (guarded by a golden test).
77
+ 2. **`markets`** — solver discovery and pricing guardrails: `discoverMarkets` (1-hour cached
78
+ registry fetch with stale-cache fallback), `findMarket`, `validatePlan` (balance, both-side
79
+ limits, BTC-leg dust), `QUOTE_OPTIONS`, and `makeCachedFeedFetch` for rate-limited price feeds.
80
+ 3. **`store`** — the persisted `AssetSwap` records (`getAssetSwaps`/`addAssetSwap`/
81
+ `updateAssetSwap`), thin helpers over an `AssetSwapRepository`. Read failures degrade to an
82
+ empty list; write failures throw so pre-funding records can be retried before money is sent.
83
+ 4. **`restore`** — `restoreAssetSwaps` rebuilds lost records by scanning sent virtual txs for
84
+ offer packets and binding each funding vtxo to its spend. Incremental: answered txids are
85
+ remembered in the repository (`getScannedTxids`/`markTxidsScanned`) so nothing is fetched
86
+ twice.
87
+ 5. **`watch`** — `watchOfferSwaps` drives swap status from the wallet's own contract events, so a
88
+ fill shows up without re-running a scan. Registration is what makes it possible: only a
89
+ registered covenant is watched. See "Live status" below.
90
+ 6. **`rfq`** — the user side of quoted swaps: RFQ negotiation over HTTP or a
91
+ relay, then non-interactive filling (see below). All four reference-solver corridors:
92
+ `arkade:BTC -> lightning:BTC` and `arkade:BTC -> onchain:BTC` (send), `lightning:BTC ->
93
+ arkade:BTC` and `onchain:BTC -> arkade:BTC` (receive), plus `arkade:BTC|asset ->
94
+ arkade:BTC|asset` (quote, then take by funding an offer from layer 1).
95
+ 7. **`onchainHtlc`** — the Bitcoin-L1 side of `arkade:BTC <-> onchain:BTC`: a NUMS-keyed taproot
96
+ HTLC as pure local derivation (golden-pinned), claim/refund spend builders with signing as a
97
+ callback, the injected `ChainSource` seam (the package holds no L1 backend and no keys),
98
+ preimage extraction from a spend's witness, and crash-recovery classification.
99
+ `claimPacket` seals P to covclaimd for the receive directions.
100
+
101
+ Everything the package persists — swap records, the restore-scan cursor, and the markets cache —
102
+ goes through a single `AssetSwapRepository`, following the Arkade repository convention
103
+ (versioned interface, `AsyncDisposable`, one backend per platform). Two backends ship here:
104
+ `InMemoryAssetSwapRepository` and `IndexedDbAssetSwapRepository` (built on the SDK's shared
105
+ IndexedDB manager, like the Boltz plugin's repositories). Construct one and pass it wherever the
106
+ package asks for a repository; `discoverMarkets` also accepts none, for a one-shot uncached
107
+ discovery.
108
+
109
+ ## Creating an offer
110
+
111
+ Fund the returned address with the side you deposit, embedding the payload, and the solver does
112
+ the rest:
113
+
114
+ ```ts
115
+ // BTC -> asset
116
+ const o = await createOffer(wallet, ARK, EMULATOR_PUBKEY, { wantAmount: 1000n, wantAsset });
117
+ await wallet.send({ address: o.address, amount: 1000, extensions: [o.extension] });
118
+
119
+ // asset -> BTC (the sats are the VTXO carrier for the asset)
120
+ const o = await createOffer(wallet, ARK, EMULATOR_PUBKEY, { wantAmount: 1000n, offerAsset });
121
+ await wallet.send({
122
+ address: o.address,
123
+ amount: 500,
124
+ assets: [{ assetId, amount: 1000n }],
125
+ extensions: [o.extension],
126
+ });
127
+ ```
128
+
129
+ `EMULATOR_PUBKEY` is the covenant co-signer's x-only key — the solver's deployment, not yours.
130
+ `createOffer` does not fetch or verify it: clients have no network path to the emulator, only the
131
+ solver and covclaimd do. Obtain it out of band, before calling `createOffer`, from the solver's
132
+ signed registry/corridor card and check it against whatever value you independently trust.
133
+
134
+ ### What `createOffer` gives you back
135
+
136
+ `createOffer` is pure derivation — it broadcasts nothing. The offer only becomes real when the
137
+ deposit lands at `address`.
138
+
139
+ | Field | What it is |
140
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
141
+ | `address` | The swap address to fund with your deposit. Identical offers derive an identical address, so the **funding txid**, not the address, identifies one deposit. |
142
+ | `extension` | Pass straight to `wallet.send`'s `extensions`. It carries the offer inside the funding tx so the solver can discover the offer from the txid alone. |
143
+ | `offerHex` | The encoded offer. **Persist this** — it is the only input `cancelOffer` needs to rebuild the covenant. |
144
+ | `swapPkScript` | The covenant's scriptPubKey: the key an indexer watches to spot the deposit and its later spend. |
145
+
146
+ The minimum you must keep to stay in control of a swap is `offerHex` plus the funding txid.
147
+ Everything else — status, amounts, timestamps — `restoreAssetSwaps` rebuilds from chain, and the
148
+ offer bytes themselves are recoverable from the funding tx if the record is lost.
149
+
150
+ ## Live status
151
+
152
+ ```ts
153
+ const watcher = await watchOfferSwaps({ wallet, arkServerUrl: ARK, repository, onUpdate: render });
154
+ // later
155
+ watcher.stop();
156
+ ```
157
+
158
+ Because `createOffer` registers the covenant, the wallet already watches that script and emits a
159
+ spend event when the deposit moves — so a **fill** reaches you without re-running a scan, which is
160
+ what `restoreAssetSwaps` alone could never do.
161
+
162
+ How a spend is classified, cheapest answer first: a cancel this device made is already recorded by
163
+ `cancelOffer`, so nothing needs deciding; anything else is read off the spending transaction's
164
+ covenant leaf (`cancel` vs `fulfill`), which is exact and stays exact when one transaction fills
165
+ several offers at once. A spend that cannot be classified — the indexer has not caught up, say —
166
+ **leaves the record untouched** for the restore scan to decide later. Nothing is written on a
167
+ guess: a stored swap is skipped by every later scan, so a guess here would be permanent.
168
+
169
+ `onUpdate` is a notification for UI reactivity, not a second store; every write goes through the
170
+ repository.
171
+
172
+ ## Cancelling: the refund path
173
+
174
+ ```ts
175
+ const txid = await cancelOffer(wallet, ARK, swap.offerHex, {
176
+ repository,
177
+ fundingTxid: swap.fundingTxid,
178
+ swapAddress: swap.swapAddress,
179
+ });
180
+ ```
181
+
182
+ The call records its own outcome — `cancelling` before submitting, `cancelled` plus the spend txid
183
+ after — so a cancel needs no follow-up write from the caller, and the live watcher above finds a
184
+ record already resolved rather than re-deriving it.
185
+
186
+ **An unfilled offer never expires.** Neither program carries a timelock, so a deposit no solver
187
+ picked up keeps its place at the swap address — the terms stay open as long as you want them to,
188
+ with no deadline to miss and no "expired" state to unwind. Getting the deposit back is
189
+ `cancelOffer`, available from the moment funding lands and settling as soon as you ask.
190
+
191
+ The two ways out of the covenant are deliberately asymmetric:
192
+
193
+ - **`fulfill`** is signed by the **server alone**, but the covenant constrains it to pay output 0
194
+ to your payout script for at least `wantAmount`. A solver cannot take the deposit without
195
+ delivering the other side.
196
+ - **`cancel`** is a **2-of-2 of you and the server** — no solver signature. Your refund never
197
+ depends on the counterparty being reachable or willing, which is the same property the HTLC
198
+ corridors buy with a timelock, bought here with a cooperative path that needs no waiting.
199
+
200
+ So cancel _races_ a fill rather than pre-empting it. If the solver fills in the same moment,
201
+ `cancelOffer` throws `no spendable VTXO at the swap address` — that means the swap **completed**,
202
+ not that anything went wrong. Re-read the swap's state before treating it as an error;
203
+ `restoreAssetSwaps` tells the two spends apart afterwards and marks the record `fulfilled` rather
204
+ than `cancelled`.
205
+
206
+ Pass `fundingTxid` whenever you have it. Identical offers share an address; when several deposits
207
+ sit there, `cancelOffer` refuses to guess and throws unless `fundingTxid` selects one. Every
208
+ `AssetSwap` carries the txid, so the call above is the shape to prefer. `swapAddress` pins the
209
+ server key the covenant was built with, keeping cancel working across a server signer rotation —
210
+ without it, a rotated key is detected and reported explicitly rather than surfacing as a missing
211
+ VTXO.
212
+
213
+ ## RFQ: quote first, then fill without talking
214
+
215
+ `rfq` is the trader's side of a quoted swap. The negotiation is the ONLY interactive part —
216
+ after the quote, both corridors fill non-interactively, and there is deliberately no accept
217
+ message anywhere: **acceptance is funding**.
218
+
219
+ - **Arkade → Lightning** (`arkade:BTC->lightning:BTC`, implemented): the trader derives the
220
+ lightning-send covenant LOCALLY from the quote's binding fields plus its own data, refuses to
221
+ fund on any address mismatch, funds its own derivation before `valid_until`, and may go
222
+ offline. The solver observes the funding on-chain, pays the invoice, and claims with the
223
+ preimage — which lands publicly in the claim witness as the receipt. A failed swap refunds by
224
+ covenant to the trader's address, pushable by anyone, no trader keys or state.
225
+ - **Arkade ↔ arkade** (BTC↔asset, asset↔asset): the trader accepts a quote by creating and funding
226
+ an **offer** (layer 1) bound to the quoted terms before `valid_until`. The offer covenant only
227
+ releases the deposit to a fill that delivers the quoted amount, so the solver fills or nothing
228
+ moves; an unfilled offer is cancelled cooperatively. The quote wire shape ships here; the
229
+ reference solver serves the Lightning pair today.
230
+
231
+ ```ts
232
+ import { httpTransport, requestLightningSend } from "@arkade-os/swap";
233
+
234
+ // invoice facts from YOUR OWN decoder — the module takes facts, not a decoder
235
+ const swap = await requestLightningSend(
236
+ wallet,
237
+ arkServerUrl,
238
+ emulatorPubkey,
239
+ httpTransport(solverUrl),
240
+ {
241
+ invoice: { raw: bolt11, paymentHash, amountSats, expiresAt },
242
+ },
243
+ );
244
+ // quote verified against the LOCAL derivation and gated; now fund and go offline:
245
+ await wallet.send({ address: swap.address, amount: swap.fundAmount });
246
+ ```
247
+
248
+ Both `request*` functions register the lockup with the wallet's contract manager before returning
249
+ an address, the way `createOffer` registers its covenant: the lockup is watched from the moment it
250
+ lands and stays out of generic coin selection, so nothing can spend a live swap out from under
251
+ itself. A write failure throws `LockupRegistrationFailed` — the one throw here that does not mean
252
+ "walk away from this quote", since nothing is funded yet and the quote is still good. Keep
253
+ `swap.script`: it is the covenant object `RfqSwapManager` takes as a record's `lockup`, without
254
+ which the manager can only poll and cannot retire the row when the swap ends.
255
+
256
+ The trust model is the offer side's, applied to quotes: only `solver_pubkey`,
257
+ `refund_locktime`, `valid_until` and the amounts are used from a quote; every other contract
258
+ parameter is the trader's own data, and anything address-shaped from the solver is compare-only
259
+ (`AddressMismatch` means refuse-to-fund). `emulatorPubkey` is neither: it is not fetched or
260
+ verified by this library at all — clients have no network path to the emulator, only the solver
261
+ and covclaimd do — so it must arrive already obtained out of band, from the solver's signed
262
+ registry/corridor card, and already checked against whatever value you independently trust.
263
+ Refusals carry a closed reason set (`SwapRefusal`); unknown reasons are a generic decline. The
264
+ `swap-lightning-send.program.json` bytes are frozen the same way the offer programs are — a
265
+ golden test pins the compiled leaves and scriptPubKey to the reference solver's exact script.
266
+
267
+ Transports are symmetric-outbound: `httpTransport` (POST `/v1/swap`, GET `/v1/rfq/<rfq_id>`),
268
+ `relayTransport` (the dev broker framing), and `nostrRfqTransport` — the production one a
269
+ deployed solver actually listens on. Status by `rfq_id` reaches terminal states
270
+ `settled / refused / expired / refunded / stuck`; receipts (the preimage) appear only in
271
+ `settled`, and the chain itself is always the fallback nobody can withhold.
272
+
273
+ ### The Nostr transport is a separate entry point
274
+
275
+ ```ts
276
+ import { nostrRfqTransport } from "@arkade-os/swap/nostr";
277
+
278
+ const transport = nostrRfqTransport({
279
+ relays: card.transports.nostr.relays,
280
+ solverPubkey: card.discovery_pubkey,
281
+ });
282
+ ```
283
+
284
+ `nostr-tools` is an **optional peer dependency**, so it is only required if you import this
285
+ subpath — a consumer doing HTTP-only swaps never resolves it, and the package root pulls in
286
+ nothing Nostr-related. The trade is that importing `@arkade-os/swap/nostr` without `nostr-tools`
287
+ installed fails at resolution, which is the intended loud failure rather than a transport that
288
+ silently degrades.
289
+
290
+ Directed traffic rides kind `24859`, which is **ephemeral** (NIP-01's 20000–29999): a conforming
291
+ relay does not retain it. This must match the solver's `NOSTR_KIND_DIRECTED` — the two sides
292
+ subscribe by `kinds`, so a mismatch is not an error either can report. They simply never see each
293
+ other, and every request times out appearing to blame the solver.
294
+
295
+ ## Onchain corridor: `arkade:BTC -> onchain:BTC` (and back)
296
+
297
+ The off-board direction is implemented end to end on the user side. The user generates `P`
298
+ itself — `sha256(P)` is the wire `payment_hash`, and the script commitment is
299
+ `ripemd160(sha256(P))` in BOTH contracts, so one preimage unlocks the Arkade leaf and the L1 leaf.
300
+ The Arkade lockup is byte-identical to the lightning-send program (`htlcSendProgram` is an alias —
301
+ one artifact, one golden test); the L1 side is a two-leaf taproot HTLC with the BIP-341 NUMS
302
+ internal key (no key-path spend, ever): claim = `HASH160 <h160> EQUALVERIFY <claimKey> CHECKSIG`,
303
+ refund = `<locktime> CLTV DROP <refundKey> CHECKSIG`.
304
+
305
+ ```ts
306
+ import { hex } from "@scure/base";
307
+ import {
308
+ httpTransport,
309
+ requestOnchainSend,
310
+ awaitOnchainFill,
311
+ claimOnchainFill,
312
+ addAssetSwap,
313
+ preimageForRfqSecrets,
314
+ rfqSecretsToRecord,
315
+ } from "@arkade-os/swap";
316
+
317
+ const swap = await requestOnchainSend(
318
+ wallet,
319
+ arkServerUrl,
320
+ emulatorPubkey,
321
+ httpTransport(solverUrl),
322
+ { amount: 100_000, amountSide: "to", payoutPubkey },
323
+ );
324
+ // Persist the record, including secrets, BEFORE funding. This must succeed:
325
+ // if addAssetSwap throws, do not call wallet.send.
326
+ await addAssetSwap(repository, {
327
+ ...record,
328
+ id: swap.rfqId,
329
+ pair: "arkade:BTC->onchain:BTC",
330
+ paymentHash: swap.htlc.paymentHash,
331
+ swapAddress: swap.address,
332
+ swapPkScript: hex.encode(swap.swapPkScript),
333
+ htlcPkScriptHex: hex.encode(swap.htlc.pkScript),
334
+ htlcLocktime: swap.htlc.refundLocktime,
335
+ ...rfqSecretsToRecord(swap.secrets),
336
+ });
337
+ await wallet.send({ address: swap.address, amount: swap.fundAmount });
338
+
339
+ // Unlike lightning-send the user must STAY CLAIM-CAPABLE: watch for the fill
340
+ // and claim before the HTLC's refund leaf opens. chain is YOUR ChainSource.
341
+ const utxo = await awaitOnchainFill(chain, swap.htlc, minConfirmations);
342
+ await claimOnchainFill(chain, {
343
+ htlc: swap.htlc,
344
+ utxo,
345
+ preimage: await preimageForRfqSecrets(wallet, swap.secrets),
346
+ payoutPkScript,
347
+ feeRateSatVb,
348
+ sign,
349
+ });
350
+ ```
351
+
352
+ `requestOnchainSend` derives BOTH contracts locally from the quote's binding fields
353
+ (`solver_pubkey`, `refund_locktime`, `htlc_pubkey`, `htlc_locktime`, `min_confirmations`) and
354
+ refuses on any mismatch — `lockup_address` and `htlc_address` are compare-only. `assertFundable`
355
+ adds three onchain gates, run immediately before funding: `timelock_order` (the L1 locktime plus a
356
+ 2 h reorg margin must fall before the Arkade refund, so the user's escape hatch opens LAST),
357
+ `claim_window_too_short`, and `confirmations_out_of_range`. `claimOnchainFill` refuses to
358
+ broadcast — publishing `P` — with less than 90 minutes before the refund leaf opens: past that
359
+ point the safe move is to let the swap die and take the Arkade covenant refund rather than race
360
+ the solver's refund with `P` exposed. If the solver never fills, there is nothing to do: the
361
+ covenant refund pays the user's address after `refund_locktime`, pushable by anyone.
362
+
363
+ Crash recovery is record-driven, not chain-driven: `classifyOnchainHtlc` re-derives the HTLC's
364
+ state (unfunded / awaiting confirmations / claimable / refundable / claimed-with-P / swept) from
365
+ `ChainSource` plus the stored outpoint — without the stored record a spent HTLC is
366
+ indistinguishable from an unfunded one, which is why persisting before funding is mandatory. The
367
+ `AssetSwap` record carries the onchain fields (`paymentHash`, `signingDescriptor`,
368
+ `preimageHex` for caller-supplied P, `fallbackSecrets`, `htlcPkScriptHex`, `htlcLocktime`,
369
+ `l1Txid`) and the statuses `awaiting_fill / claimable / claimed / refunded_l1`.
370
+ `fallbackSecrets` is versioned and discriminated: `{ version: 1, type: "stored",
371
+ senderPrivateKeyHex, preimageHex? }`.
372
+
373
+ **On-board corridors are covered.** `requestLightningReceive` (`lightning:BTC -> arkade:BTC`) and
374
+ `requestOnchainReceive` (`onchain:BTC -> arkade:BTC`) mirror the send-side flows: quote → derive
375
+ BOTH contracts locally (the role-inverted VHTLC, and the L1 HTLC for the onchain leg) → verify
376
+ against the quote's compare-only addresses → gate. `requestLightningReceive` returns the solver's
377
+ hold invoice to pay; `requestOnchainReceive` returns the L1 HTLC to fund — the payment/broadcast
378
+ itself is the trader's own wallet's job, exactly as on the send corridors.
379
+
380
+ The invoice on the lightning-receive leg is the *solver's*, so the SDK owns the comparison rather
381
+ than taking the caller's facts about it: `requestLightningReceive` requires a `decodeInvoice`
382
+ callback (no BOLT11 dependency is added) and `verifyReceiveInvoice` binds the decoded invoice to
383
+ this swap's `H` and to `quote.from_amount` — an invoice on another payment hash is the one attack
384
+ here with no on-chain trace, since the payer pays it in full and no lockup on `H` is ever funded.
385
+ `assertReceivable` replaces `assertFundable` on this leg: the refund CLTV is the solver's, so the
386
+ window that can run out is the hold invoice's, and the claim window is measured from
387
+ `payDeadline = min(invoice expiry, valid_until)` — returned as the absolute `invoiceExpiresAt`,
388
+ which is the deadline to show a payer, not `valid_until`. The optional `maxPayAmount` caps `from_amount`
389
+ (`price_too_high`). The trader-side
390
+ completion lands in `claim.ts`: `claimReceiveLockup` waits for the solver's funding and pushes the
391
+ collaborative claim with the swap's own `P` and receiver key (covclaimd optional). Both request
392
+ flows return `expectedAmount` (the quote's `to_amount`) — persist it: `pushClaim` requires it and
393
+ refuses, with `LockupAmountMismatchError`, to publish `P` for a lockup funded below it. Matching the
394
+ `pkScript` is not enough on this leg, since a solver that funds the correctly derived script with
395
+ dust still settles the payer's HTLC in full once `P` is out. The gate sums every live output and
396
+ runs before signing — `P` reaches the Ark server at submit — and is skipped only for a lockup we
397
+ have already partially claimed (`partiallyClaimed`), where `P` is public anyway. The push itself is
398
+ core's `signAndSubmitOffchainTx` plus `claimWithPreimageIdentity`, with `verifyServerSignatures`
399
+ on: the server's countersignature is checked per input, against the leaf the local build spends,
400
+ before finalizing. Until covclaimd's
401
+ reference vectors are cross-checked, the `sealClaimPacket` test vector is pinned from this
402
+ implementation and marked provisional (`TODO(claim-packet-vectors)`).
403
+
404
+ `RfqSwapManager` drives the lightning-receive leg too, as `kind: "lightning_receive"` records
405
+ carrying `expectedAmount` and wired to a `claimLockup` callback (`pushClaim`, with `expectedAmount`
406
+ and `partiallyClaimed` passed through — the manager's value check decides *when* to act, the inner
407
+ one decides whether `P` is published). Nothing is asked of the solver: the reference solver's
408
+ `rfq_status_request` consults neither receive store, so a status poll answers `unknown` for every
409
+ one of these swaps and chain observation is the only workable design. States mean what they do on
410
+ the send legs with the roles swapped — `settled` is *our own* claim landing, matched by a
411
+ hash-verified preimage spend rather than by the txid we submitted, so a claim that lands without us
412
+ still counts; `claimed` is a local belief and not terminal; and **`refunded` is a loss**, the solver
413
+ having taken back a lockup we failed to claim. A lockup funded below `expectedAmount` is reported
414
+ `needs_counterparty` and never claimed, which is non-terminal — a solver that tops it up before
415
+ the window shuts makes it claimable again, and a lockup funded piecemeal moves `claimed` →
416
+ `claimable` → `claimed` again, so `onSwapUpdate` states say what to do next rather than track
417
+ progress in one direction. A `refunded` outcome from `waitForSwapCompletion` reports no `txid` even
418
+ when a claim was submitted and recorded: the chain never took it, and the record still carries
419
+ `claimArkTxid` for anyone diagnosing the loss.
420
+
421
+ **There is no client-side refund on this leg, and that is the whole answer to "what if I cannot
422
+ claim in time".** Every non-claim leaf of the covenant is the solver's, so `refundArkade` is never
423
+ called for a receive record and no amount of waiting produces one. The deadline is the quote's
424
+ `refund_locktime`: the manager claims right up to it and stops there, because publishing `P` into
425
+ the solver's live refund window risks losing the race and giving away the preimage anyway. Wall
426
+ clock with no margin is already conservative — the solver's leaf is a CLTV maturing against
427
+ median-time-past, which trails, so the real window runs past that instant rather than ending before
428
+ it. Past it the outcome is not symmetric with a send: the solver reclaims the lockup, the held
429
+ Lightning HTLC lapses, and **the payer is refunded** — the trader loses the incoming payment, not
430
+ funds it was holding. Which is why staying online to claim is an obligation and not a preference:
431
+ covclaimd cannot claim this covenant today, so the claim packet's offline path does not yet run.
432
+
433
+ ## RFQ secrets are derived, not stored
434
+
435
+ The two secrets an RFQ swap needs — the VHTLC `sender` key and, for an onchain send, the preimage —
436
+ are functions of the wallet seed plus one HD-allocated descriptor. The record keeps the descriptor,
437
+ which is public, so a copied browser profile or a device backup yields nothing spendable.
438
+
439
+ ```ts
440
+ const swap = await requestOnchainSend(/* … */);
441
+ swap.secrets; // { derivable: true, signingDescriptor } — persist it, it holds no secret
442
+ await saveSwap({ ...record, ...rfqSecretsToRecord(swap.secrets) });
443
+
444
+ // Later, from the seed plus that descriptor. Guard the lookup: offer-corridor
445
+ // records (and records that lost their secrets fields) carry no secrets at
446
+ // all, and a `!` here would crash the whole recovery loop on the first one.
447
+ const secrets = rfqSecretsOfRecord(record);
448
+ if (secrets) {
449
+ const preimage = await preimageForRfqSecrets(wallet, secrets);
450
+ }
451
+
452
+ // For a refund, take the composition instead of the guard: it turns all three
453
+ // ways a wallet can fail to produce the sender key — no secrets on the record,
454
+ // an unreadable fallback arm, a descriptor from another seed — into one typed
455
+ // `RefundNotLocallyPossibleError` carrying which. Wire `refundArkade` to this.
456
+ const sender = await senderIdentityForSwapRecord(wallet, record);
457
+ ```
458
+
459
+ `RfqSwapManager` catches that error and reports `needs_counterparty` with a `blockedReason`,
460
+ instead of retrying a push that cannot work until the refund window closes. The state is **not**
461
+ terminal: the lockup stays funded and watched, a solver claim still ends the swap `settled`, and a
462
+ `canRefundArkade` probe answering `ok` — after the right wallet is restored — returns it to
463
+ `pending`. The manager reports the same state when nothing is wired to act (`enableAutoActions:
464
+ false`, or no callbacks) and the window has passed.
465
+
466
+ `derivable: false` is the fallback for wallets that cannot allocate (static / `auto` / custom
467
+ signers). It carries the raw `senderPrivateKey` and, for onchain sends, `preimage`;
468
+ `rfqSecretsToRecord` stores them under `AssetSwap.fallbackSecrets` as a complete versioned
469
+ record. The discriminant is a type-level fact, so a consumer written against the derivable arm
470
+ alone will not compile against the fallback. A caller-supplied preimage on an HD wallet keeps
471
+ `signingDescriptor` for the sender key and stores only `preimageHex` as secret material.
472
+
473
+ Each swap **allocates** its own descriptor rather than peeking at the current one: two swaps sharing
474
+ a descriptor derive the *identical* preimage, so one solver learning its own preimage would learn the
475
+ other swap's. On restore, `adoptSwapDescriptor` moves the wallet's watermark past a restored record's
476
+ index so it cannot be handed out twice.
477
+
478
+ The derivation is `sha256(signSchnorrDeterministic(sha256("Arkade-RFQ-Preimage-v1" ‖ xonly(32) ‖
479
+ u32le(0))))`, mirroring NArk's Boltz scheme (`SwapsManagementService.cs:128-160`) with an
480
+ RFQ-scoped tag. NArk has no RFQ corridor yet, so this tag defines the scheme rather than matching
481
+ one; it is deliberately distinct from the Boltz tag so one wallet key cannot derive the same
482
+ preimage for both corridors.
483
+
484
+ **Not covered:** seed-only discovery after the swap repository is wiped. An unspent L1 HTLC reveals
485
+ too little public quote data to rediscover, so the record remains required.
486
+
487
+ **Gap-limit interaction:** every swap request — including one whose quote is refused — consumes one
488
+ index from the wallet's receive stream, and a swap index never becomes a funded receive contract,
489
+ so it looks *unused* to a seed-only `restore()` gap scan. Many consecutive swap allocations between
490
+ two funded receive indices can therefore exceed the scan's `gapLimit` (default 20) and stop it
491
+ before later-funded addresses are found. Keep the swap repository in backups (restore then adopts
492
+ each record's descriptor via `adoptSwapDescriptor`), or raise `gapLimit` on seed-only restores
493
+ after heavy swap use.
494
+
495
+ ## Breaking changes on this branch (pre-release migration notes)
496
+
497
+ The package is pre-release; these notes replace a changelog for consumers tracking the branch.
498
+
499
+ - **`requestLightningSend` / `requestOnchainSend` return `secrets`, not top-level raw key material.**
500
+ `senderPrivateKey` is gone from both return types; caller-owned onchain preimages live inside
501
+ `secrets` and must be persisted with the record. `pushRefundWithoutReceiver` /
502
+ `refundIfUnresolved` take `sender: Identity` instead of `senderPrivateKey: Uint8Array` — build
503
+ it from the record with `senderIdentityForSwapRecord`, which is what keeps a wallet that cannot
504
+ sign reporting `RefundNotLocallyPossibleError` rather than a `TypeError` at the push site;
505
+ `senderIdentityForRfqSecrets` is for callers that already hold resolved secrets. `AssetSwap`
506
+ gains `signingDescriptor?`,
507
+ `preimageHex?`, and complete stored-arm `fallbackSecrets?`. Landed while the package is
508
+ unpublished and consumer-free, which is the whole window for doing it: after a consumer ships,
509
+ the same change becomes a secret migration across every deployed wallet.
510
+ - **Every derived address changed, in both corridors.** The lightning-send lockup moved from the
511
+ 3-leaf program-artifact VHTLC to the 8-leaf `VHTLC.ScriptV2` (non-interactive claim and refund
512
+ leaves), and the L1 HTLC's claim leaf gained a `SIZE 32 EQUALVERIFY` preimage-length guard. Both
513
+ are pinned by golden tests (`test/rfq.test.ts`, `test/onchainHtlc.test.ts`). **Deployment must be
514
+ coordinated:** trader and solver derive the lockup independently and compare (`lockup_address` /
515
+ `htlc_address` are compare-only), so a version mismatch does not lose funds — it refuses every
516
+ quote at `verifyLockupAddress`. Upgrade both sides before expecting fills.
517
+ - **`cancelOffer` and `restoreAssetSwaps` take an options object.** `cancelOffer(wallet, url,
518
+ offerHex, { repository, fundingTxid?, swapAddress? })` — the repository is required because the
519
+ call now records its own outcome. `restoreAssetSwaps(indexer, txs, existingIds, { serverPubkey,
520
+ scanned? })` — the server key is required because a spend is classified by rebuilding the
521
+ covenant and matching the leaf it took.
522
+ - **`isCancelSpend` is gone**, replaced by `classifySpend`, and `Tx.assets` with it. The old test
523
+ read what a transaction moved, which a wallet reports as a _net_ delta: once the deposit is a
524
+ registered contract, an asset offer's cancel moves the asset out and back, nets to zero, and is
525
+ indistinguishable from a fill. Leaves have no such failure mode.
526
+ - **A spend that cannot be classified is no longer restored as `fulfilled`.** It leaves the funding
527
+ txid unanswered so a later scan decides it. Records are never written on a guess.
528
+ - **`AssetSwap` gained the secret-bearing `signingDescriptor?` / `fallbackSecrets?` fields**, and
529
+ `preimageHex` narrowed from "the claim preimage P" to "caller-supplied P only". The repository
530
+ version stays `1` — the package is unreleased, so there is no stored record to migrate — but a
531
+ field-mapped backend must persist the record whole: silently dropping `fallbackSecrets` on write
532
+ loses the stored arm's claim and refund keys.
533
+ - **A write that gates something irreversible throws; one that follows it does not.**
534
+ `addAssetSwap` and `updateAssetSwap` throw on a failed read or write — nothing irreversible may
535
+ happen until the record is durable, which is why `cancelOffer` writes its `cancelling` marker
536
+ before broadcasting. `updateAssetSwapBestEffort` is the other half: it records transitions that
537
+ follow an irreversible action (a broadcast claim, a spent lockup), so it cannot fail the caller,
538
+ and returns `{ swaps, persisted }` instead. `watchOfferSwaps` uses it and fires `onUpdate` only
539
+ when `persisted` is true — the callback is documented as following a persisted change, and a
540
+ consumer caching from it must not run ahead of the store.
541
+ - **`lightningSendProgram` and `htlcSendProgram` are gone** along with the program-artifact layer
542
+ they compiled. Derive scripts through `lightningSendVtxoScript` / `onchainHtlcScript`.
543
+ - **The receive corridors are wired, and the wire shape settled.** `lightningReceiveRequest` is
544
+ new; `onchainReceiveRequest`'s profile now matches the shipped solver schema (`payment_hash`,
545
+ `claim_packet`, `refund_pubkey`, `payout_address`, `payout_pubkey` — the earlier
546
+ `destination_address` / object-shaped `claim_packet` never interoperated). `sealClaimPacket`
547
+ drops the vestigial `arkadeScript` input: the packet was never cryptographically bound to it,
548
+ and the solver recomputes the script from its own row, so the wire carries only the ciphertext.
549
+ `requestLightningSend` now returns `fundAmount = quote.from_amount` — the invoice plus the
550
+ corridor's fee — and refuses quotes whose `to_amount` reprices the invoice; solvers charge
551
+ per-corridor fees on all four pairs, and funding the bare invoice amount underfunds by exactly
552
+ the fee.
553
+ - **`lightningSendVtxoScript` takes two new required fields**: `senderPubkey` (the trader's VHTLC
554
+ sender key — generate, persist, see `requestLightningSend`) and `receiverPkScript` (the solver's
555
+ claim destination, from `profile.receiver_pk_script`). Callers that built the lockup directly
556
+ must supply both; callers going through `requestLightningSend` are unaffected.
557
+ - **`RfqSwapManagerCallbacks` gained a required `claimLockup`**, and `RfqSwap` a third member,
558
+ `LightningReceiveSwap`. Required rather than optional for the same reason `claimOnchain` is: a
559
+ receive swap monitored with nothing wired to claim it expires quietly, and a compile error is the
560
+ right way to learn a corridor was added. A caller with only send swaps can satisfy it with a stub
561
+ that throws. `RfqSwapActionName` gains `"claimLockup"`, so an exhaustive `switch` over it needs a
562
+ new arm.