@arkade-os/swap 0.0.17 → 0.0.19

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 CHANGED
@@ -1,1101 +1,1102 @@
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 uses no DOM and no
6
- Node-specific APIs, so it runs in Node, the browser, and React Native alike. Four storage backends
7
- ship — in-memory (anywhere, nothing outlives the process), IndexedDB (browser), SQLite and Realm
8
- (React Native, on subpath entry points) — see "Storage backends" below.
9
-
10
- The one global the core API requires is `crypto.getRandomValues`. Node and browsers have it;
11
- React Native does not, so install `react-native-get-random-values` (or `expo-crypto`) and import
12
- it before this package. `crypto.subtle` is not used. `EventSource` and `WebSocket` are needed only
13
- by the watch and relay transports, both of which take an injected implementation.
14
-
15
- ## Roles
16
-
17
- Arkade Intents names two participants:
18
-
19
- - **user** — states an intent and, through a wallet or application, approves and funds it. That is
20
- the consumer of this package: it prices a swap against the registry's markets, funds the derived
21
- contract, and tracks it to a fill or a cancellation.
22
- - **solver** — supplies inventory and pricing, and fills the funded contract by delivering
23
- `wantAmount` to the user's script over the covenant's `fulfill` path. Some specifications and
24
- repositories use _provider_ or _market maker_ as synonyms.
25
-
26
- **`maker` and `taker` in this package name contract positions, not product roles.** The covenant
27
- programs bind `makerWP`, and the `Offer` type carries `makerPkScript` and `makerPublicKey`; those
28
- identify the side that funds the swap and receives `wantAmount`. Read them as script field names.
29
-
30
- Arkade Intents documentation deliberately avoids maker and taker for the participants themselves.
31
- A resting maker order is firm once taken, and nothing here is: the user funds first, and if no
32
- solver fills, the deposit comes back through `cancelOffer` rather than through an executed trade.
33
- Naming the sides _user_ and _solver_ says who does what without borrowing a guarantee the contract
34
- does not make.
35
-
36
- ## Request for quote
37
-
38
- Every Arkade Intents route is request-for-quote: the user states an intent, receives the solver's
39
- terms as a quote, funds the contract it derives from those terms, and a solver fills it. This
40
- route is no exception — what is specific to it is _where the quote is resolved_. `quoteOffer`
41
- prices the swap client-side from the market card the solver publishes: its price feed and its fee,
42
- the same two inputs a relay quote would carry. Same protocol, one fewer network hop, and a quote
43
- that is ready before the user finishes typing an amount.
44
-
45
- The card commits a solver to a price; a fill commits it to your swap. Nothing is signed and no
46
- inventory is reserved until a solver lands on the funded contract, so treat the quote as terms to
47
- show and validate — which is what `validatePlan` is for — rather than as a reservation.
48
-
49
- Relay-negotiated quotes are where this is going, and not only here: every corridor — Lightning,
50
- onchain, and intra-Arkade alike — converges on asking solvers for quotes over the relay, under one
51
- message family. What stays specific to this route is the settlement script, not the negotiation:
52
- both legs live in the same ledger, so a non-interactive swap covenant replaces the HTLC that
53
- cross-ledger corridors need. The quote you resolve locally today is the quote a solver will answer
54
- with then.
55
-
56
- ## Funding, then fill or cancel
57
-
58
- Every swap has the same two beats, on this route and on the cross-ledger corridors:
59
-
60
- 1. **Funding** — the user funds the contract it derived from the quote. Funding _is_ acceptance;
61
- there is no accept message to send, here or anywhere in Arkade Intents.
62
- 2. **Fill, or cancel** — a solver fills by delivering the other side, or the user takes the
63
- deposit back.
64
-
65
- Cancel is this route's refund path. Where an HTLC corridor refunds through a timelocked leaf, this
66
- covenant refunds through `cancelOffer` — a 2-of-2 with the Arkade server, **no solver signature
67
- involved**. Same job, same guarantee that the money comes home, reached by a script that fits a
68
- single-ledger swap.
69
-
70
- The one thing to design for: the covenant carries no timelock, so an offer keeps its place until
71
- it is filled or cancelled. There is no window to miss, no deadline to race, and no expired state
72
- to recover from — the trade-off is that the deposit comes back when you ask for it, so a UI that
73
- funds an offer should keep cancelling within reach.
74
-
75
- ## The seven layers
76
-
77
- 1. **`offer`** — the swap covenant itself. Two program JSONs (want-BTC / want-asset), the
78
- `Offer` type, the TLV wire codec (`encodeOffer`/`decodeOffer`, `OFFER_PACKET_TYPE`), address
79
- derivation (`offerVtxoScript`), and the user-side operations `createOffer`/`cancelOffer`. Identical
80
- offers always derive identical swap addresses — the program JSONs are hashed into the address,
81
- so their bytes are frozen (guarded by a golden test).
82
- 2. **`markets`** — solver discovery and pricing guardrails: `discoverMarkets` (1-hour cached
83
- registry fetch with stale-cache fallback), `findMarket`, `validatePlan` (balance, both-side
84
- limits, BTC-leg dust), `QUOTE_OPTIONS`, and `makeCachedFeedFetch` for rate-limited price feeds.
85
- 3. **`store`** — the persisted `AssetSwap` records (`getAssetSwaps`/`addAssetSwap`/
86
- `updateAssetSwap`), thin helpers over an `AssetSwapRepository`. Read failures degrade to an
87
- empty list; write failures throw so pre-funding records can be retried before money is sent.
88
- 4. **`restore`** — `registerAssetSwapRestore` attaches durable swap recovery to an explicit
89
- `wallet.restore()`. The underlying `restoreAssetSwapRepository` / `restoreAssetSwaps` scan sent
90
- virtual txs for offer packets and bind each funding vtxo to its spend. The scan remains
91
- available directly for ordinary startup and later reconciliation; answered txids are remembered
92
- in the repository (`getScannedTxids`/`markTxidsScanned`) so nothing is fetched twice.
93
- 5. **`watch`** — `watchOfferSwaps` drives swap status from the wallet's own contract events, so a
94
- fill shows up without re-running a scan. Registration is what makes it possible: only a
95
- registered covenant is watched. See "Live status" below.
96
- 6. **`rfq`** — the user side of quoted swaps: RFQ negotiation over HTTP or a
97
- relay, then non-interactive filling (see below). All four reference-solver corridors:
98
- `arkade:BTC -> lightning:BTC` and `arkade:BTC -> onchain:BTC` (send), `lightning:BTC ->
99
- arkade:BTC` and `onchain:BTC -> arkade:BTC` (receive), plus `arkade:BTC|asset ->
100
- arkade:BTC|asset` (quote, then take by funding an offer from layer 1).
101
- 7. **`onchainHtlc`** — the Bitcoin-L1 side of `arkade:BTC <-> onchain:BTC`: a NUMS-keyed taproot
102
- HTLC as pure local derivation (golden-pinned), claim/refund spend builders with signing as a
103
- callback, the injected `ChainSource` seam (the package holds no L1 backend and no keys),
104
- preimage extraction from a spend's witness, and crash-recovery classification.
105
- `claimPacket` seals P to covclaimd for the receive directions.
106
-
107
- Everything the package persists — swap records, the restore-scan cursor, and the markets cache —
108
- goes through a single `AssetSwapRepository`, following the Arkade repository convention
109
- (versioned interface, `AsyncDisposable`, one backend per platform). Construct one and pass it
110
- wherever the package asks for a repository; `discoverMarkets` also accepts none, for a one-shot
111
- uncached discovery.
112
-
113
- ## Storage backends
114
-
115
- | Backend | Import from | For |
116
- | ------------------------------ | ------------------------------------- | ----------------------------------------------- |
117
- | `InMemoryAssetSwapRepository` | `@arkade-os/swap` | tests, one-shot scripts — nothing survives exit |
118
- | `IndexedDbAssetSwapRepository` | `@arkade-os/swap` | the browser (or a polyfilled IndexedDB) |
119
- | `SQLiteAssetSwapRepository` | `@arkade-os/swap/repositories/sqlite` | React Native, over your SQLite driver |
120
- | `RealmAssetSwapRepository` | `@arkade-os/swap/repositories/realm` | React Native, over your Realm instance |
121
-
122
- Neither subpath adds a dependency: they take the SDK's structural `SQLExecutor` / `RealmLike`
123
- handles, so you pass the database you already opened.
124
-
125
- ### Restore an imported wallet
126
-
127
- Register swap recovery before the application calls the core wallet's explicit `restore()`:
128
-
129
- ```ts
130
- import {
131
- IndexedDbAssetSwapRepository,
132
- registerAssetSwapRestore,
133
- } from "@arkade-os/swap";
134
-
135
- const repository = new IndexedDbAssetSwapRepository();
136
- const unregisterSwapRestore = registerAssetSwapRestore(wallet, {
137
- arkServerUrl,
138
- repository,
139
- onResult: ({ changes, coverageError }) => {
140
- if (coverageError) console.warn("Swap coverage was incomplete", coverageError);
141
- console.info(`Restored or updated ${changes.length} swaps`);
142
- },
143
- });
144
-
145
- await wallet.restore();
146
- ```
147
-
148
- Core address, contract, history, and balance recovery finishes before the swap scan starts. The
149
- helper reads the recovered wallet history, normalizes it for the scan, rebuilds durable records,
150
- and repairs covenant coverage. Registering it again on the same wallet replaces the prior
151
- registration through the stable `arkade-os:asset-swap` hook ID, so setup is idempotent. Call
152
- `unregisterSwapRestore()` when that integration no longer owns the wallet.
153
-
154
- A direct `Wallet` exposes the indexer and current Ark server key the helper needs. A proxy or
155
- custom `IWallet` that does not expose them must pass both explicitly:
156
-
157
- ```ts
158
- registerAssetSwapRestore(serviceWorkerWallet, {
159
- arkServerUrl,
160
- repository,
161
- indexer,
162
- serverPubkey,
163
- });
164
- ```
165
-
166
- `coverageError` is result-level: records were already persisted, so the helper still delivers the
167
- complete result to `onResult` and a later restore can retry coverage safely. Scan, persistence, or
168
- `onResult` failures reject the hook; `wallet.restore()` reports hook failures through its
169
- `AggregateError` after attempting the other registered hooks.
170
-
171
- Keep calling `restoreAssetSwapRepository` directly during ordinary startup or when history may
172
- arrive later. Hooks run only during an explicit `wallet.restore()`, and the repository cursor makes
173
- the manual reconciliation idempotent against records the hook already rebuilt.
174
-
175
- All four carry both record types: asset swaps and the monitored RFQ swaps
176
- (`saveRfqSwap` / `getRfqSwap` / `getAllRfqSwaps` / `removeRfqSwap`). Each keeps them in a store of their own — a
177
- second object store on IndexedDB, an `…rfq_swaps` table on SQLite, the `ArkadeRfqSwap` class on
178
- Realm — since the two record types have different keys and no consumer wants them interleaved.
179
-
180
- **Records are stored whole.** The SQLite and Realm backends serialize each record to **JSON** in a
181
- `data` column, with only `status` / `createdAt` (and an RFQ record's `state` / `updatedAt`) mapped
182
- out for querying — so a field they do not know about survives, which is what the `quote`-shaped
183
- extension in `MIGRATION.md` relies on. It is also what keeps an RFQ record's corridor `profile`
184
- intact: `profile.hashlock` is a nested object holding the payment hash and any preimage material, and
185
- a field-mapped backend is exactly what would lose it. JSON is
186
- the boundary, though, and it is narrower than IndexedDB's structured clone: a `Date` in a
187
- consumer-added field comes back an ISO **string**, a `Set` or `Map` comes back empty, and a `bigint`
188
- makes `saveSwap` **throw**. `AssetSwap` and `RfqSwapRecord` are both JSON-safe by design (amounts are
189
- strings, binary is hex), and a corridor `profile` is plain JSON by the handler contract; keep your own
190
- added fields — and any corridor profile you write — that way too.
191
-
192
- ### SQLite
193
-
194
- ```ts
195
- import { SQLiteAssetSwapRepository } from "@arkade-os/swap/repositories/sqlite";
196
- import { SQLiteWalletRepository, type SQLExecutor } from "@arkade-os/sdk/repositories/sqlite";
197
-
198
- const db = await SQLite.openDatabaseAsync("wallet.db"); // expo-sqlite
199
- // Build the executor ONCE and hand this same instance to every repository on
200
- // the database: the SDK serializes transactions in a chain keyed by this
201
- // object, so a per-repository literal splits the chain and two BEGIN
202
- // IMMEDIATEs can interleave.
203
- const executor: SQLExecutor = {
204
- run: (sql, params) => db.runAsync(sql, params ?? []),
205
- get: (sql, params) => db.getFirstAsync(sql, params ?? []),
206
- all: (sql, params) => db.getAllAsync(sql, params ?? []),
207
- };
208
-
209
- const swaps = new SQLiteAssetSwapRepository(executor);
210
- const wallet = new SQLiteWalletRepository(executor); // same instance
211
- ```
212
-
213
- Sharing the executor is **necessary** for that serialization, not sufficient for atomicity across
214
- all wallet storage: it disciplines the repositories that enter the chain — this one,
215
- `SQLiteIntentRepository`, `SQLiteVirtualTxRepository`, and the wallet repository's migration path —
216
- and nothing else. `SQLiteWalletRepository` and `SQLiteContractRepository` still write raw, so their
217
- writes can land inside whatever transaction happens to be open.
218
-
219
- Three tables land in your database, prefixed `arkade_`: `arkade_asset_swaps`,
220
- `arkade_asset_swap_scanned_txids`, `arkade_asset_swap_markets`. Pass `{ prefix: "myapp_" }` if your
221
- app already owns those names.
222
-
223
- ### Realm
224
-
225
- ```ts
226
- import Realm from "realm";
227
- import { AssetSwapRealmSchemas, RealmAssetSwapRepository } from "@arkade-os/swap/repositories/realm";
228
- import { ArkRealmSchemas } from "@arkade-os/sdk/repositories/realm";
229
-
230
- const realm = await Realm.open({
231
- schema: [...ArkRealmSchemas, ...AssetSwapRealmSchemas, ...yourOwnSchemas],
232
- schemaVersion: YOUR_VERSION, // these schemas are new: bump yours when adding them
233
- });
234
- const swaps = new RealmAssetSwapRepository(realm);
235
- ```
236
-
237
- Four classes land in your Realm namespace: `ArkadeAssetSwap`, `ArkadeRfqSwap`,
238
- `ArkadeAssetSwapScannedTxid`, `ArkadeAssetSwapMarketsCache`. Unlike SQLite there is no prefix option
239
- — a Realm schema name is baked into the schema objects you register — so reconcile against your own
240
- models by name.
241
-
242
- `ArkadeRfqSwap` arrived after the other three. **If you already shipped them, add it and bump
243
- `schemaVersion` again**: Realm creates schemas at open, so a config still listing three fails on the
244
- first RFQ read rather than at open. SQLite needs nothing — its DDL runs `CREATE TABLE IF NOT EXISTS`
245
- on every init, so the table appears on the next operation.
246
-
247
- ## Creating an offer
248
-
249
- Fund the returned address with the side you deposit, embedding the payload, and the solver does
250
- the rest:
251
-
252
- ```ts
253
- // BTC -> asset
254
- const o = await createOffer(wallet, ARK, { wantAmount: 1000n, wantAsset });
255
- await wallet.send({ address: o.address, amount: 1000, extensions: [o.extension] });
256
-
257
- // asset -> BTC (the sats are the VTXO carrier for the asset)
258
- const o = await createOffer(wallet, ARK, { wantAmount: 1000n, offerAsset });
259
- await wallet.send({
260
- address: o.address,
261
- amount: 500,
262
- assets: [{ assetId, amount: 1000n }],
263
- extensions: [o.extension],
264
- });
265
- ```
266
-
267
- The covenant co-signer ("emulator") key defaults to the SDK's per-network pin, resolved from the
268
- network the Ark server reports — never fetched from the emulator itself. Pass
269
- `params.emulatorPubkey` (33-byte compressed hex, the same contract as `Arkade.connect`'s option)
270
- to override it for a self-hosted emulator, an unpinned network (signet, testnet), or a key
271
- rotation the SDK hasn't shipped yet.
272
-
273
- ### What `createOffer` gives you back
274
-
275
- `createOffer` is pure derivation — it broadcasts nothing. The offer only becomes real when the
276
- deposit lands at `address`.
277
-
278
- | Field | What it is |
279
- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
280
- | `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. |
281
- | `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. |
282
- | `offerHex` | The encoded offer. **Persist this** — it is the only input `cancelOffer` needs to rebuild the covenant. |
283
- | `swapPkScript` | The covenant's scriptPubKey: the key an indexer watches to spot the deposit and its later spend. |
284
-
285
- The minimum you must keep to stay in control of a swap is `offerHex` plus the funding txid.
286
- Everything else — status, amounts, timestamps — `restoreAssetSwaps` rebuilds from chain, and the
287
- offer bytes themselves are recoverable from the funding tx if the record is lost.
288
-
289
- ## Live status
290
-
291
- ```ts
292
- const watcher = await watchOfferSwaps({ wallet, arkServerUrl: ARK, repository, onUpdate: render });
293
- // later
294
- watcher.stop();
295
- ```
296
-
297
- Because `createOffer` registers the covenant, the wallet already watches that script and emits a
298
- spend event when the deposit moves — so a **fill** reaches you without re-running a scan, which is
299
- what `restoreAssetSwaps` alone could never do.
300
-
301
- How a spend is classified, cheapest answer first: a cancel this device made is already recorded by
302
- `cancelOffer`, so nothing needs deciding; anything else is read off the spending transaction's
303
- covenant leaf (`cancel` vs `fulfill`), which is exact and stays exact when one transaction fills
304
- several offers at once. A spend that cannot be classified — the indexer has not caught up, say —
305
- **leaves the record untouched** for the restore scan to decide later. Nothing is written on a
306
- guess: a stored swap is skipped by every later scan, so a guess here would be permanent.
307
-
308
- `onUpdate` is a notification for UI reactivity, not a second store; every write goes through the
309
- repository.
310
-
311
- ## Cancelling: the refund path
312
-
313
- ```ts
314
- const txid = await cancelOffer(wallet, ARK, swap.offerHex, {
315
- repository,
316
- fundingTxid: swap.fundingTxid,
317
- swapAddress: swap.swapAddress,
318
- });
319
- ```
320
-
321
- The call records its own outcome — `cancelling` before submitting, `cancelled` plus the spend txid
322
- after — so a cancel needs no follow-up write from the caller, and the live watcher above finds a
323
- record already resolved rather than re-deriving it.
324
-
325
- **An unfilled offer never expires.** Neither program carries a timelock, so a deposit no solver
326
- picked up keeps its place at the swap address — the terms stay open as long as you want them to,
327
- with no deadline to miss and no "expired" state to unwind. Getting the deposit back is
328
- `cancelOffer`, available from the moment funding lands and settling as soon as you ask.
329
-
330
- The two ways out of the covenant are deliberately asymmetric:
331
-
332
- - **`fulfill`** is signed by the **server alone**, but the covenant constrains it to pay output 0
333
- to your payout script for at least `wantAmount`. A solver cannot take the deposit without
334
- delivering the other side.
335
- - **`cancel`** is a **2-of-2 of you and the server** — no solver signature. Your refund never
336
- depends on the counterparty being reachable or willing, which is the same property the HTLC
337
- corridors buy with a timelock, bought here with a cooperative path that needs no waiting.
338
-
339
- So cancel _races_ a fill rather than pre-empting it. If the solver fills in the same moment,
340
- `cancelOffer` throws `no spendable VTXO at the swap address` — that means the swap **completed**,
341
- not that anything went wrong. Re-read the swap's state before treating it as an error;
342
- `restoreAssetSwaps` tells the two spends apart afterwards and marks the record `fulfilled` rather
343
- than `cancelled`.
344
-
345
- Pass `fundingTxid` whenever you have it. Identical offers share an address; when several deposits
346
- sit there, `cancelOffer` refuses to guess and throws unless `fundingTxid` selects one. Every
347
- `AssetSwap` carries the txid, so the call above is the shape to prefer. `swapAddress` pins the
348
- server key the covenant was built with, keeping cancel working across a server signer rotation —
349
- without it, a rotated key is detected and reported explicitly rather than surfacing as a missing
350
- VTXO.
351
-
352
- ## RFQ: quote first, then fill without talking
353
-
354
- `rfq` is the trader's side of a quoted swap. The negotiation is the ONLY interactive part —
355
- after the quote, both corridors fill non-interactively, and there is deliberately no accept
356
- message anywhere: **acceptance is funding**.
357
-
358
- - **Arkade → Lightning** (`arkade:BTC->lightning:BTC`, implemented): the trader derives the
359
- lightning-send covenant LOCALLY from the quote's binding fields plus its own data, refuses to
360
- fund on any address mismatch, funds its own derivation before `valid_until`, and may go
361
- offline. The solver observes the funding on-chain, pays the invoice, and claims with the
362
- preimage — which lands publicly in the claim witness as the receipt. A failed swap refunds by
363
- covenant to the trader's address, pushable by anyone, no trader keys or state.
364
- - **Arkade ↔ arkade** (BTC↔asset, asset↔asset): an arkade asset leg names the asset id itself —
365
- `arkade:<68-hex>`, built with `arkadeAssetLeg` (the deprecated coarse `ARKADE_ASSET` is served by
366
- no solver). The trader accepts a quote by creating and funding
367
- an **offer** (layer 1) bound to the quoted terms before `valid_until`. The offer covenant only
368
- releases the deposit to a fill that delivers the quoted amount, so the solver fills or nothing
369
- moves; an unfilled offer is cancelled cooperatively. The quote wire shape ships here; the
370
- reference solver serves the Lightning pair today.
371
-
372
- ```ts
373
- import { httpTransport, requestLightningSend, SwapRefusal } from "@arkade-os/swap";
374
-
375
- // invoice facts from YOUR OWN decoder — the module takes facts, not a decoder
376
- const swap = await requestLightningSend(wallet, arkServerUrl, httpTransport(solverUrl), {
377
- invoice: { raw: bolt11, paymentHash, amountSats, expiresAt },
378
- });
379
- // quote verified against the LOCAL derivation and gated; now fund and go offline:
380
- await wallet.send({ address: swap.address, amount: swap.fundAmount });
381
- ```
382
-
383
- Both `request*` functions register the lockup with the wallet's contract manager before returning
384
- an address, the way `createOffer` registers its covenant: the lockup is watched from the moment it
385
- lands and stays out of generic coin selection, so nothing can spend a live swap out from under
386
- itself. A write failure throws `LockupRegistrationFailed` — the one throw here that does not mean
387
- "walk away from this quote", since nothing is funded yet and the quote is still good. Keep
388
- `swap.script`: it is the covenant object `RfqSwapManager` takes as a record's `lockup`, without
389
- which the manager can only poll and cannot retire the row when the swap ends.
390
-
391
- The trust model is the offer side's, applied to quotes: only `solver_pubkey`,
392
- `refund_locktime`, `valid_until` and the amounts are used from a quote; every other contract
393
- parameter is the trader's own data, and anything address-shaped from the solver is compare-only
394
- (`AddressMismatch` means refuse-to-fund). The emulator key is neither: as above, it is a
395
- per-network pin inside the SDK, not solver data.
396
- Refusals carry a closed reason set (`SwapRefusal`). A solver may also return a recognised
397
- `error_code` with client-safe context. The error exposes these as `errorCode`, `field`, `actual`,
398
- `expected`, `limit`, and `unit`, and includes useful numeric context in its message. Match
399
- `errorCode` for a specific remedy while treating `reason` as the compatible fallback:
400
-
401
- ```ts
402
- if (error instanceof SwapRefusal && error.errorCode === "invoice_cltv_too_large") {
403
- console.error(`Invoice CLTV is ${error.actual} blocks; solver limit is ${error.limit}`);
404
- }
405
- ```
406
-
407
- Unknown diagnostic codes and fields stay generic. `RFQ_REFUSAL_ERROR_CODES` and
408
- `isRfqRefusalErrorCode` expose the accepted vocabulary. The `swap-lightning-send.program.json`
409
- bytes are frozen the same way the offer programs are — a golden test pins the compiled leaves and
410
- scriptPubKey to the reference solver's exact script.
411
-
412
- Transports are symmetric-outbound: `httpTransport` (POST `/v1/swap`, GET `/v1/rfq/<rfq_id>`),
413
- `relayTransport` (the dev broker framing), and `nostrRfqTransport` — the production one a
414
- deployed solver actually listens on. Status by `rfq_id` reaches terminal states
415
- `settled / refused / expired / refunded / stuck`; receipts (the preimage) appear only in
416
- `settled`, and the chain itself is always the fallback nobody can withhold.
417
-
418
- ### The Nostr transport is a separate entry point
419
-
420
- ```ts
421
- import { nostrRfqTransport } from "@arkade-os/swap/nostr";
422
-
423
- const transport = nostrRfqTransport({
424
- relays: card.transports.nostr.relays,
425
- solverPubkey: card.discovery_pubkey,
426
- });
427
- ```
428
-
429
- `nostr-tools` is an **optional peer dependency**, so it is only required if you import this
430
- subpath — a consumer doing HTTP-only swaps never resolves it, and the package root pulls in
431
- nothing Nostr-related. The trade is that importing `@arkade-os/swap/nostr` without `nostr-tools`
432
- installed fails at resolution, which is the intended loud failure rather than a transport that
433
- silently degrades.
434
-
435
- Directed traffic rides kind `24859`, which is **ephemeral** (NIP-01's 20000–29999): a conforming
436
- relay does not retain it. This must match the solver's `NOSTR_KIND_DIRECTED` — the two sides
437
- subscribe by `kinds`, so a mismatch is not an error either can report. They simply never see each
438
- other, and every request times out appearing to blame the solver.
439
-
440
- ## Onchain corridor: `arkade:BTC -> onchain:BTC` (and back)
441
-
442
- The off-board direction is implemented end to end on the user side. The user generates `P`
443
- itself — `sha256(P)` is the wire `payment_hash`, and the script commitment is
444
- `ripemd160(sha256(P))` in BOTH contracts, so one preimage unlocks the Arkade leaf and the L1 leaf.
445
- The Arkade lockup is byte-identical to the lightning-send program (`htlcSendProgram` is an alias —
446
- one artifact, one golden test); the L1 side is a two-leaf taproot HTLC with the BIP-341 NUMS
447
- internal key (no key-path spend, ever): claim = `HASH160 <h160> EQUALVERIFY <claimKey> CHECKSIG`,
448
- refund = `<locktime> CLTV DROP <refundKey> CHECKSIG`.
449
-
450
- ```ts
451
- import { hex } from "@scure/base";
452
- import {
453
- httpTransport,
454
- requestOnchainSend,
455
- awaitOnchainFill,
456
- claimOnchainFill,
457
- addAssetSwap,
458
- swapSecretsToRecord,
459
- } from "@arkade-os/swap";
460
-
461
- const swap = await requestOnchainSend(wallet, arkServerUrl, httpTransport(solverUrl), {
462
- amount: 100_000,
463
- amountSide: "to",
464
- payoutPubkey,
465
- });
466
- // Persist the record, including secrets, BEFORE funding. This must succeed:
467
- // if addAssetSwap throws, do not call wallet.send.
468
- await addAssetSwap(repository, {
469
- ...record,
470
- id: swap.rfqId,
471
- pair: "arkade:BTC->onchain:BTC",
472
- paymentHash: swap.htlc.paymentHash,
473
- swapAddress: swap.address,
474
- swapPkScript: hex.encode(swap.swapPkScript),
475
- htlcPkScriptHex: hex.encode(swap.htlc.pkScript),
476
- htlcLocktime: swap.htlc.refundLocktime,
477
- ...swapSecretsToRecord(swap.secrets),
478
- });
479
- await wallet.send({ address: swap.address, amount: swap.fundAmount });
480
-
481
- // Unlike lightning-send the user must STAY CLAIM-CAPABLE: watch for the fill
482
- // and claim before the HTLC's refund leaf opens. chain is YOUR ChainSource.
483
- const utxo = await awaitOnchainFill(chain, swap.htlc, minConfirmations);
484
- await claimOnchainFill(chain, {
485
- htlc: swap.htlc,
486
- utxo,
487
- preimage: swap.secrets.preimage,
488
- payoutPkScript,
489
- feeRateSatVb,
490
- sign,
491
- });
492
- ```
493
-
494
- `requestOnchainSend` derives BOTH contracts locally from the quote's binding fields
495
- (`solver_pubkey`, `refund_locktime`, `htlc_pubkey`, `htlc_locktime`, `min_confirmations`) and
496
- refuses on any mismatch — `lockup_address` and `htlc_address` are compare-only. `assertFundable`
497
- adds three onchain gates, run immediately before funding: `timelock_order` (the L1 locktime plus a
498
- 2 h reorg margin must fall before the Arkade refund, so the user's escape hatch opens LAST),
499
- `claim_window_too_short`, and `confirmations_out_of_range`. `claimOnchainFill` refuses to
500
- broadcast — publishing `P` — with less than 90 minutes before the refund leaf opens: past that
501
- point the safe move is to let the swap die and take the Arkade covenant refund rather than race
502
- the solver's refund with `P` exposed. If the solver never fills, there is nothing to do: the
503
- covenant refund pays the user's address after `refund_locktime`, pushable by anyone.
504
-
505
- Crash recovery is record-driven, not chain-driven: `classifyOnchainHtlc` re-derives the HTLC's
506
- state (unfunded / awaiting confirmations / claimable / refundable / claimed-with-P / swept) from
507
- `ChainSource` plus the stored outpoint — without the stored record a spent HTLC is
508
- indistinguishable from an unfunded one, which is why persisting before funding is mandatory. The
509
- `AssetSwap` record carries the onchain fields (`paymentHash`, `signingDescriptor`, `preimageHex`
510
- for a P that cannot be re-derived, `htlcPkScriptHex`, `htlcLocktime`, `l1Txid`) and the statuses
511
- `awaiting_fill / claimable / claimed / refunded_l1`.
512
-
513
- **On-board corridors are covered.** `requestLightningReceive` (`lightning:BTC -> arkade:BTC`) and
514
- `requestOnchainReceive` (`onchain:BTC -> arkade:BTC`) mirror the send-side flows: quote → derive
515
- BOTH contracts locally (the role-inverted VHTLC, and the L1 HTLC for the onchain leg) → verify
516
- against the quote's compare-only addresses → gate. `requestLightningReceive` returns the solver's
517
- hold invoice to pay; `requestOnchainReceive` returns the L1 HTLC to fund — the payment/broadcast
518
- itself is the trader's own wallet's job, exactly as on the send corridors.
519
-
520
- The invoice on the lightning-receive leg is the _solver's_, so the SDK owns the comparison rather
521
- than taking the caller's facts about it: `requestLightningReceive` requires a `decodeInvoice`
522
- callback (no BOLT11 dependency is added) and `verifyReceiveInvoice` binds the decoded invoice to
523
- this swap's `H` and to `quote.from_amount` — an invoice on another payment hash is the one attack
524
- here with no on-chain trace, since the payer pays it in full and no lockup on `H` is ever funded.
525
- `assertReceivable` replaces `assertFundable` on this leg: the refund CLTV is the solver's, so the
526
- window that can run out is the hold invoice's, and the claim window is measured from
527
- `payDeadline = min(invoice expiry, valid_until)` — returned as the absolute `invoiceExpiresAt`,
528
- which is the deadline to show a payer, not `valid_until`. The optional `maxPayAmount` caps `from_amount`
529
- (`price_too_high`). The trader-side
530
- completion lands in `claim.ts`: `claimReceiveLockup` waits for the solver's funding and pushes the
531
- collaborative claim with the swap's own `P` and receiver key (covclaimd optional). Both request
532
- flows return `expectedAmount` (the quote's `to_amount`) — persist it: `pushClaim` requires it and
533
- refuses, with `LockupAmountMismatchError`, to publish `P` for a lockup funded below it. Matching the
534
- `pkScript` is not enough on this leg, since a solver that funds the correctly derived script with
535
- dust still settles the payer's HTLC in full once `P` is out. The gate sums every live output and
536
- runs before signing — `P` reaches the Ark server at submit — and is skipped only for a lockup we
537
- have already partially claimed (`partiallyClaimed`), where `P` is public anyway. The push itself is
538
- core's `signAndSubmitOffchainTx` plus `claimWithPreimageIdentity`, with `verifyServerSignatures`
539
- on: the server's countersignature is checked per input, against the leaf the local build spends,
540
- before finalizing. The `sealClaimPacket` vector is cross-checked against
541
- covclaimd's own `preimage.Decrypt`, and the TLV framing against its `DeserializeClaim`, so both
542
- are pinned by the reference implementation rather than by this one.
543
-
544
- `RfqSwapManager` drives the lightning-receive leg too, as `kind: "lightning_receive"` records
545
- carrying `expectedAmount` and wired to a `claimLockup` callback (`pushClaim`, with `expectedAmount`
546
- and `partiallyClaimed` passed through — the manager's value check decides _when_ to act, the inner
547
- one decides whether `P` is published). Nothing is asked of the solver: the reference solver's
548
- `rfq_status_request` consults neither receive store, so a status poll answers `unknown` for every
549
- one of these swaps and chain observation is the only workable design. States mean what they do on
550
- the send legs with the roles swapped — `settled` is _our own_ claim landing, matched by a
551
- hash-verified preimage spend rather than by the txid we submitted, so a claim that lands without us
552
- still counts; `claimed` is a local belief and not terminal; and **`refunded` is a loss**, the solver
553
- having taken back a lockup we failed to claim. A lockup funded below `expectedAmount` is reported
554
- `needs_counterparty` and never claimed, which is non-terminal — a solver that tops it up before
555
- the window shuts makes it claimable again, and a lockup funded piecemeal moves `claimed` →
556
- `claimable` → `claimed` again, so `onSwapUpdate` states say what to do next rather than track
557
- progress in one direction. A `refunded` outcome from `waitForSwapCompletion` reports no `txid` even
558
- when a claim was submitted and recorded: the chain never took it, and the record still carries
559
- `claimArkTxid` for anyone diagnosing the loss.
560
-
561
- **There is no client-side refund on this leg, and that is the whole answer to "what if I cannot
562
- claim in time".** Every non-claim leaf of the covenant is the solver's, so `refundArkade` is never
563
- called for a receive record and no amount of waiting produces one. The deadline is the quote's
564
- `refund_locktime`: the manager claims right up to it and stops there, because publishing `P` into
565
- the solver's live refund window risks losing the race and giving away the preimage anyway. Wall
566
- clock with no margin is already conservative — the solver's leaf is a CLTV maturing against
567
- median-time-past, which trails, so the real window runs past that instant rather than ending before
568
- it. Past it the outcome is not symmetric with a send: the solver reclaims the lockup, the held
569
- Lightning HTLC lapses, and **the payer is refunded** — the trader loses the incoming payment, not
570
- funds it was holding. Which is why staying online to claim is an obligation and not a preference:
571
- covclaimd cannot claim this covenant today, so the claim packet's offline path does not yet run.
572
-
573
- ## Swap secrets come from the wallet, not from this package
574
-
575
- This package holds no key logic at all. It names the leg it is building and the SDK answers:
576
-
577
- ```ts
578
- // a leg we fund — all it needs is the key that refunds it
579
- const { pubkey: refundPubkey, descriptor: refundDescriptor } = await provisionRefundKey(wallet);
580
- // a leg we claim — the key that receives it, and the P that unlocks it
581
- const { pubkey, descriptor, preimage, paymentHash, mustPersistPreimage } =
582
- await provisionClaimSecret(wallet);
583
- ```
584
-
585
- Where the key comes from is the wallet's decision, invisible here: an HD wallet allocates a fresh
586
- descriptor per swap, a static wallet answers with its one `tr(pubkey)`. The record keeps the
587
- descriptor, which is public, and `contractSigner(wallet, descriptor)` recovers the signer.
588
-
589
- What each swap stores, and what is recoverable:
590
-
591
- | Wallet answers with | Spending key | Preimage (when the leg needs one) | Secret at rest |
592
- | ------------------------- | --------------------- | ------------------------------------- | ----------------- |
593
- | fresh HD descriptor | re-derives from seed | derives deterministically | none |
594
- | static `tr(pubkey)` | the wallet's identity | derives from a public per-swap salt | none |
595
- | a signer that cannot sign | | | |
596
- | deterministically | the wallet's identity | random, stored on the record | the preimage only |
597
-
598
- The preimage split follows the **descriptor's shape**, not the wallet's type. An HD child
599
- descriptor is unique to its swap, so `sha256(sign_det(...))` over the key alone is safe. A static
600
- descriptor is the same key for every swap, so that derivation would repeat across swaps — one
601
- solver learning its own preimage would learn every other swap's — and the uniqueness has to come
602
- from the message instead: the SDK mints 32 random bytes per swap, signs a **salted** message, and
603
- stores the salt in the clear.
604
-
605
- **The salt is not a secret.** Knowing it yields nothing without the seed, which is the whole
606
- difference from the preimage it replaces: the record goes from carrying a per-swap _secret_ to a
607
- per-swap _public_ value, exactly what `signingDescriptor` already is. Recoverability is unchanged
608
- in shape — keep the record and the swap recovers from the seed.
609
-
610
- Only a signer that cannot sign deterministically at all — an external or extension signer — still
611
- gets a random stored preimage. `mustPersistPreimage` says which you got, and it is the only thing
612
- to branch on. A stored preimage remains the one secret at rest in the design, and it is never a
613
- private key.
614
-
615
- ```ts
616
- const swap = await requestOnchainSend(/* … */);
617
- // `swapSecretsToRecord` stores the public descriptor always, then whichever of
618
- // `preimageSaltHex` (derivable) or `preimageHex` (not) the wallet produced.
619
- await saveSwap({ ...record, ...swapSecretsToRecord(swap.secrets) });
620
-
621
- // Later, from the seed plus the record's public fields. Only ask for a
622
- // preimage the corridor gave us one for: a lightning send's P belongs to the
623
- // payee, so this throws on those records rather than inventing something the
624
- // chain will never match. `LIGHTNING_SEND_PAIR` is exported from this package.
625
- if (record.pair !== LIGHTNING_SEND_PAIR) {
626
- const preimage = await preimageForSwapRecord(wallet, record);
627
- }
628
-
629
- // For a refund, take the composition instead of the guard: it turns all three
630
- // ways a wallet can fail to produce the sender key — the record names no
631
- // descriptor, the descriptor is another seed's, the wallet holds the key but
632
- // cannot sign — into one typed `RefundNotLocallyPossibleError` carrying which,
633
- // and lets a signer outage stay retryable. Wire `refundArkade` to this.
634
- const sender = await senderIdentityForSwapRecord(wallet, record);
635
- ```
636
-
637
- `RfqSwapManager` catches that error and reports `needs_counterparty` with a `blockedReason`,
638
- instead of retrying a push that cannot work until the refund window closes. The state is **not**
639
- terminal: the lockup stays funded and watched, a solver claim still ends the swap `settled`, and a
640
- `canRefundArkade` probe answering `ok` — after the right wallet is restored — returns it to
641
- `pending`. The manager reports the same state when nothing is wired to act (`enableAutoActions:
642
- false`, or no callbacks) and the window has passed.
643
-
644
- **The two claim callbacks may be omitted.** `setCallbacks` accepts
645
- `AvailableRfqSwapManagerCallbacks` — the full contract with `claimOnchain` and `claimLockup`
646
- optional — so a consumer driving only lightning sends installs neither instead of stubbing them to
647
- throw. Dispatch is already kind-gated, so neither is reachable there. `saveSwap` is optional too
648
- (see below); `refundArkade` stays required.
649
-
650
- `RfqSwapManagerCallbacks` itself is unchanged and still means "fully wired", so a helper taking one
651
- and calling `claimOnchain` keeps its guarantee; only the parameter widens, which every existing
652
- caller satisfies. What moves from compile time to runtime is bought back as a **block**: a kind
653
- whose claim is missing reports `needs_counterparty` naming the gap, non-terminal and re-evaluated
654
- every pass, lifted the moment `setCallbacks` supplies it. Not `failed` — `setCallbacks` is
655
- installable late by design, and a terminal state would foreclose the late wiring this exists for.
656
- A manager with *no* callbacks at all keeps today's manual mode on the L1 half: it reports
657
- `claimable` and you act by hand.
658
-
659
- **Take `arkadeRefunder` rather than assembling `refundArkade` by hand.** It composes the atomic
660
- push and keeps the three rules the manager relies on structural instead of documented — an empty
661
- lockup returns `null`, and both `RefundNotLocallyPossibleError` and `LockupNeedsRecoveryError`
662
- propagate untouched.
663
-
664
- ```ts
665
- manager.setCallbacks({
666
- // `repository` is how it reaches `profile.signer`: the live swap the manager
667
- // passes carries no descriptor, so the refund key is resolved by `rfqId`.
668
- refundArkade: arkadeRefunder({ ark, indexer, wallet, repository }),
669
- saveSwap,
670
- });
671
- ```
672
-
673
- Keep the covenant on the swap (`request*`'s `script`, as a record's `lockup`): the refund is built
674
- from it, and a swap carrying only `lockupPkScript` is refused rather than pushed.
675
-
676
- ### Let the manager own the records
677
-
678
- Give `RfqSwapManager` a `repository` and it persists RFQ swaps itself — the restore loop, the
679
- retention pass and every write, none of which a consumer has to compose:
680
-
681
- ```ts
682
- const manager = new RfqSwapManager({
683
- indexer,
684
- contracts: await wallet.getContractManager(),
685
- repository, // any AssetSwapRepository
686
- });
687
- manager.setCallbacks({ refundArkade, claimLockup });
688
-
689
- // Rebuild what was stored: retention first, then each record's covenant from
690
- // its own contract row, then `rebuildRfqSwap`. No caller input at all.
691
- const { restored, failed, pruned } = await manager.restoreFromRepository();
692
- await manager.start();
693
-
694
- // A NEW swap arrives with the request-time half a live record cannot carry.
695
- await manager.addSwap(swap, {
696
- kind: "lightning_send",
697
- lockupAddress: request.lockupAddress,
698
- profile: rfqSecretsProfile(secrets, paymentHash),
699
- fundingArkTxid,
700
- amount,
701
- });
702
- ```
703
-
704
- That second argument is the whole point. Composing the write by hand runs into an **origin trap**:
705
- `updateRfqSwapRecord(record, swap)` needs the record that does not exist yet, and
706
- `createRfqSwapRecord(origin, swap)` needs request-time facts the live swap never carried — so a
707
- swap's *first* record cannot be built from the swap alone. `addSwap`'s `origin` is where those
708
- facts arrive, and the manager keeps them for the swap's life. Omit it and one of two things
709
- happens: the store already holds a record, which *is* the origin, and it is read back; or it does
710
- not, and you get `RfqSwapOriginRequired` at the door rather than an unwritable record a pass later.
711
- An origin whose `kind` or `lockupAddress` is not this swap's is refused at that same door, for the
712
- same reason: the write that would catch it happens a pass later, with the funding broadcast.
713
- `start(swaps)` applies the same rule and is otherwise unchanged. Restored swaps carry their own.
714
-
715
- `restoreFromRepository` returns three disjoint lists, and every stored record is in exactly one.
716
- A record that cannot be rebuilt — no contract row (`LockupContractMissing`), covenant params that
717
- do not derive the funded address, a corridor with no handler — lands in `failed` with its error and
718
- stays in the store; it never strands the others and it is never silently dropped. `pruned` names
719
- what retention removed: terminal and more than `RFQ_SWAP_RETENTION_SECONDS` past `updatedAt`, never
720
- `needs_counterparty`. Retention runs first, so a retired record costs no contract lookup on its way
721
- out; `pruneRetiredSwaps()` is public for a process that wants it on its own cadence. Pass
722
- `{ params }` to take covenants from somewhere other than the contract store.
723
-
724
- **Two sinks, and both gate.** With a repository wired the canonical `RfqSwapRecord` is written
725
- first, then `saveSwap` if one is installed, and the pass counts as persisted only when both
726
- succeeded — which is exactly today's rule for `saveSwap`, applied to whichever sinks exist. A
727
- rejection from either leaves the record dirty and monitored, so waiters stay unsettled and a
728
- terminal swap is not finalized until the write it claims lands. A failed canonical write skips
729
- `saveSwap` entirely: projecting a state the record of record has just refused would put the
730
- secondary sink ahead of the primary. If your `saveSwap` writes that same repository by hand, delete
731
- the duplicate when you wire the dep — otherwise every pass writes twice — and keep the callback for
732
- genuinely secondary sinks. With neither wired, state stays in memory and dies with the process.
733
-
734
- **Terminal records name the transaction that ended them.** `RfqSwap.lockupSpendArkTxids` is
735
- stamped from the chain read that resolved the swap — the solver's claim on a send leg, its reclaim
736
- on a receive one, the trader's own claim when a receive settles. Nothing local produces those
737
- transactions, so no other field can name them, and without the stamp the only way to find them is
738
- another lockup read per terminal swap. Absent when the indexer named the checkpoint but not the ark
739
- transaction: fewer txids beats a wrong one.
740
-
741
- `preimageForSwapRecord` is the read path to wire, not a hand-rolled `contractPreimage` call: it
742
- knows which of the record's fields are derivation inputs, and it verifies the result against
743
- `paymentHash`. A caller that forgets to pass the salt gets a _wrong_ preimage from a wallet that can
744
- derive, not an error — and that surfaces as an opaque script failure at claim time.
745
-
746
- Every refusal is a `PreimageNotRecoverableError` carrying a `reason`: `no-secrets` (the record
747
- predates the descriptor), `malformed-record`, `not-derivable` (nothing to derive from, or a key this
748
- wallet does not hold), or `hash-mismatch` (derived, but wrong — a tampered salt or the wrong seed).
749
- Branch on `reason`, never on message text. It is deliberately **not**
750
- `RefundNotLocallyPossibleError`: that one means no local refund is possible and `RfqSwapManager`
751
- reports `needs_counterparty` for it, which is a different verdict from a claim-path read failing.
752
-
753
- A caller-supplied preimage keeps `signingDescriptor` for the sender key and stores only
754
- `preimageHex` as secret material.
755
-
756
- On an HD wallet each swap **allocates** its own descriptor rather than peeking at the current one:
757
- two swaps sharing a descriptor derive the _identical_ preimage, so one solver learning its own
758
- preimage would learn the other swap's. (Static wallets share their one descriptor by design — the
759
- per-swap salt is what separates their preimages instead.) On restore, `adoptContractDescriptor`
760
- (from `@arkade-os/sdk`) moves the wallet's watermark past a restored record's index so it cannot be
761
- handed out twice; a static descriptor names no index and adopts as a no-op.
762
-
763
- Two derivations, picked by the descriptor's shape:
764
-
765
- ```
766
- HD child sha256(sign_det(sha256("Arkade-RFQ-Preimage-v1" ‖ xonly(32) ‖ u32le(0))))
767
- static/salted sha256(sign_det(sha256("Arkade-Contract-Preimage-Salted-v1" ‖ xonly(32) ‖ salt(32))))
768
- ```
769
-
770
- The first mirrors NArk's Boltz scheme (`SwapsManagementService.cs:128-160`) with an RFQ-scoped tag.
771
- NArk has no RFQ corridor yet, so this tag defines the scheme rather than matching one; it is
772
- deliberately distinct from the Boltz tag so one wallet key cannot derive the same preimage for both
773
- corridors.
774
-
775
- The salted tag is corridor-generic where the first is not, and that asymmetry is deliberate: the v1
776
- tags must be per-corridor because v1 pins its message index, leaving the tag as the only separation
777
- between two corridors reaching the same key. The salted form mints a fresh salt per swap, so no two
778
- swaps share a message within a corridor or across two — the salt carries the separation, and the tag
779
- names the layer rather than the corridor.
780
-
781
- **Not covered:** seed-only discovery after the swap repository is wiped. An unspent L1 HTLC reveals
782
- too little public quote data to rediscover, so the record remains required.
783
-
784
- **Gap-limit interaction:** every swap request — including one whose quote is refused — consumes one
785
- index from the wallet's receive stream, and a swap index never becomes a funded receive contract,
786
- so it looks _unused_ to a seed-only `restore()` gap scan. Many consecutive swap allocations between
787
- two funded receive indices can therefore exceed the scan's `gapLimit` (default 20) and stop it
788
- before later-funded addresses are found. Keep the swap repository in backups (restore then adopts
789
- each record's descriptor via `adoptContractDescriptor`), or raise `gapLimit` on seed-only restores
790
- after heavy swap use.
791
-
792
- ## Upgrading from 0.0.3
793
-
794
- 0.0.1–0.0.3 are published. Under npm's 0.0.x rules `^0.0.3` resolves to exactly 0.0.3, so nothing
795
- auto-upgrades into the changes below — but a consumer that does upgrade meets them all in one jump,
796
- so they are written as one migration rather than per-release fragments.
797
-
798
- **Key provisioning moved into the SDK.** `packages/swap/src/secrets.ts` is gone. `deriveSwapSecrets`,
799
- `randomSwapSecrets`, `preimageForRfqSecrets`, `senderIdentityForRfqSecrets`, `rfqSecretsToRecord`,
800
- `rfqSecretsOfRecord`, `isPerSwapDescriptor`, `RFQ_PREIMAGE_TAG` and `SwapSecrets` no longer exist.
801
- Import `provisionRefundKey`, `provisionClaimSecret`, `contractSigner`, `contractPreimage`,
802
- `isPerArtifactDescriptor` and `ARKADE_SWAP_PREIMAGE_TAG` from `@arkade-os/sdk` instead;
803
- `swapSecretsToRecord` and `senderIdentityForSwapRecord` stay in this package. No consumer branches
804
- on wallet type any more, and no swap record can carry a private key.
805
-
806
- **`contractPreimage` takes an options object.** `contractPreimage(wallet, descriptor, stored?)`
807
- became `contractPreimage(wallet, descriptor, { stored?, salt? })`. Prefer `preimageForSwapRecord`,
808
- which reads both fields off the record and verifies against `paymentHash`.
809
-
810
- **Static wallets derive their preimage instead of storing it.** New records from such wallets carry
811
- `preimageSaltHex` and no `preimageHex`; `mustPersistPreimage` is now `false` for them, so the
812
- "persist the preimage" warning stops firing. Nothing at rest is secret unless the signer cannot sign
813
- deterministically at all.
814
-
815
- **`AssetSwap` gains `preimageSaltHex?`, and `AssetSwapRepository.version` is `2`.** External
816
- repository implementations must recompile — deliberately, because a field-mapped backend that drops
817
- `preimageSaltHex` leaves the swap unclaimable exactly as one dropping `preimageHex` does. Records
818
- written by 0.0.1–0.0.3 need no rewrite and no migration: the field is optional, older rows resolve
819
- through their stored `preimageHex` or their HD descriptor, and `DB_VERSION` is unchanged.
820
-
821
- ## Breaking changes on this branch (pre-release migration notes)
822
-
823
- Notes from before 0.0.1, kept for consumers who tracked the branch.
824
-
825
- - **`refundIfUnresolved` reports an exited lockup, and its input gained `paymentHash`.**
826
- `RefundOutcome` has a new `{ outcome: "exited"; outpoints; status }` variant: a lockup whose
827
- outputs were unilaterally exited lives onchain under the VHTLC script, where no offchain refund
828
- reaches it. It used to come back as `nothing_to_refund`, which reads as "already resolved" over
829
- money still sitting at the script — the swept case gets `needs_recovery` for the same reason, and
830
- this is deliberately **not** that variant: recovery into a fresh batch is a spend no batch can
831
- make for an onchain output. Complete the unroll and spend the outputs onchain instead.
832
-
833
- Two required changes for direct callers. The input gains **`paymentHash`** (`sha256(P)` hex — the
834
- quote's `payment_hash`, which callers already hold): the exit is read through `readLockupFate`,
835
- and the VHTLC script cannot supply it, since its `preimageHash` is a `hash160` of the same secret.
836
- And the `indexer` parameter widened from `RefundIndexer` to **`LockupSpendIndexer`**, so it must
837
- now carry `getVirtualTxs` as well as `getVtxos` — a real `RestIndexerProvider` already does.
838
-
839
- A lockup funded in two sends of which only one exited reports `exited` for the whole thing and
840
- leaves the live half unrefunded. That matches `RfqSwapManager`, which reports the same lockup
841
- `exited` on the same any-output rule; the two must not disagree.
842
- - **`RfqSwapManager` can own its own persistence.** New optional
843
- `RfqSwapManagerDeps.repository`, new `restoreFromRepository()` and `pruneRetiredSwaps()`, and
844
- `addSwap(swap, origin?)` gains an optional second argument. Nothing narrows and nothing is
845
- removed, so no existing caller changes: without a repository the manager persists through
846
- `saveSwap` exactly as before. Two things to know if you wire it. `saveSwap` becomes a **second**
847
- sink rather than the only one — it still gates waiters and finalization, so its semantics are
848
- unchanged, but a callback that writes the same repository by hand now double-writes and should
849
- drop the duplicate. And `addSwap` for a swap the store has never seen throws
850
- `RfqSwapOriginRequired` unless you pass its origin, which is the only way its first record can be
851
- written at all.
852
- - **`saveSwap` is optional at installation**, alongside the two claims — the same
853
- `AvailableRfqSwapManagerCallbacks` relaxation extended one field. Omit it with a repository wired
854
- and the record store is the only sink; omit both and state is process-local, which is what a
855
- manager with no callbacks already did.
856
- - **`RfqSwap` and `RfqSwapRecord` gained `lockupSpendArkTxids?: string[]`** — the ark transactions
857
- that spent the lockup, stamped by the manager from the chain read that ended the swap. Optional
858
- and additive: no repository version bump, and a backend storing records whole already carries it.
859
- - **`RfqSwapState`, `RFQ_SWAP_TERMINAL_STATES` and `isRfqSwapTerminal` moved to
860
- `src/rfqSwapState.ts`** so the record layer can read them without importing the manager at
861
- runtime. `swapManager.ts` re-exports all three and the package entry point is unchanged, so no
862
- import path breaks.
863
- - **`rfqSwapOriginOf(record)` is new** — a record's immutable half on its own. A record *is* an
864
- origin plus manager state, so passing one where an origin is wanted type-checks and quietly
865
- carries the old `failure`, `blockedReason` and `refundArkTxid` past `managerState`, which can
866
- only set those fields and never clear them. Use this instead of spreading the record.
867
- - **`arkadeRefunder({ ark, indexer, wallet, repository })` ships the `refundArkade` wiring** that
868
- was prose in two places. New export, nothing removed.
869
- - **`rfqSwapActivityInputs({ repository, indexer })` derives `SwapActivityInput[]` from the record
870
- store** — the correlation helper `activity.ts` promised. `SwapActivityInput["kind"]` is now
871
- `RfqSwapRecord["kind"]` rather than a literal union repeating it; source-compatible. Corridor
872
- handlers gained an optional `activityTxids(profile)` so a leg's own claim txid comes from the
873
- handler instead of a kind switch. The `indexer` is optional and consulted only for what a record
874
- cannot answer: a record predating `fundingArkTxid`, and the counterparty's spend on a swap no
875
- refund of ours ended. An unreachable indexer costs that record its extra txids, never a throw.
876
- - **`setCallbacks` takes `AvailableRfqSwapManagerCallbacks`** — `RfqSwapManagerCallbacks` with the
877
- two kind-gated claims optional. Nothing breaks: the strict interface is untouched and the widened
878
- parameter accepts every existing caller. A consumer driving one kind stops stubbing the claims it
879
- cannot reach; in exchange, a missing claim blocks at runtime (`needs_counterparty`, non-terminal)
880
- instead of being unrepresentable.
881
- - **The repository interface is at version `4`.** It gained
882
- `getRfqSwap(rfqId): Promise<RfqSwapRecord | undefined>` — every backend is already keyed by
883
- `rfqId`, so a consumer updating one record no longer scans them all. A miss returns `undefined`;
884
- retention prunes terminal records, so absence is ordinary. All four in-tree backends implement it
885
- and `DB_VERSION` is unchanged; a custom implementor adds the two-line read and bumps its own
886
- `version` to `4`.
887
- - **`RfqSwapOrigin` gained `fundingArkTxid?`** — the ark transaction that funded the lockup. It is
888
- origin, not manager state: the caller broadcasts the funding and knows the txid, while the manager
889
- watches the lockup by script and never learns it. Optional and stored whole, so no migration.
890
- Consumers stashing it in `profile` should move it: `profile` is merged as
891
- `{ ...profile, ...handler.project(swap) }` on every write, so a key a corridor also projects is
892
- silently overwritten.
893
- - **`readLockupFate` names the spends it observed.** `claimed` and `returned` now carry
894
- `spends: readonly LockupSpend[]`, one per spent lockup output, with the `checkpointTxid` that
895
- `spentBy` names and the `arkTxid` that rode it. History correlation wants `arkTxid`; the
896
- checkpoint txid is the wrong value to correlate on alone. `unknown` and `open` claim no spend.
897
-
898
- - **Every derived address changed again, in both corridors — the unilateral ladder was re-spaced.**
899
- `unilateralRefundDelay` now sits **level with** `claimDelay` instead of one 512s step above it,
900
- and `unilateralRefundWithoutReceiverDelay` sits `SOLO_REFUND_HEADROOM_SECONDS` (4096s, newly
901
- exported) above it instead of two steps. The old ladder spaced all three leaves one step apart as
902
- though they were interchangeable rungs; they are not. Only `unilateralRefundWithoutReceiver` is a
903
- solo path for the funder, so it is the only one whose timing can steal, and one 512s tick was
904
- never enough for a claimant to complete a unilateral exit in. The two-signature refund needs no
905
- separation at all, since neither party can spend that leaf alone. This tracks the reference
906
- solver's [lightning-swap-service#81](https://github.com/arkade-os/lightning-swap-service/pull/81);
907
- the two derivations must produce **the same three delay values** for the same operator, which is
908
- what keeps the derived addresses identical. **Deployment must be coordinated** on the same terms
909
- as the entry below: for a quote not yet funded, a mismatch refuses it at `verifyLockupAddress`
910
- rather than losing funds.
911
-
912
- **An in-flight lockup funded before the upgrade needs care, and the entry below understates
913
- this.** The delays are not quote fields and are not persisted on the swap record
914
- (`AssetSwap` keeps `swapPkScript`, not `claimDelay`), and `RfqSwap`'s own doc tells callers to
915
- *rebuild* the script on restart from the quote's binding fields — which re-derives the delays
916
- under whatever ladder is compiled in. So a trader who funded on `0.0.4`, upgraded, and restarted
917
- rebuilds a **new** address, and `refundIfUnresolved` finds no VTXOs there and returns
918
- `nothing_to_refund` — a terminal-sounding answer for money still locked at the old script, with
919
- `refundLocktime` still ticking. Until the delays are persisted and rebuilt from the stored value,
920
- drain in-flight lockups before upgrading, or rebuild the old script from the pre-upgrade delays
921
- by hand. This is a pre-existing gap that any address-moving change hits, not one this change
922
- introduces.
923
-
924
- `unilateralClaimDelay`'s BIP68 ceiling tightened to reserve the full headroom rather than two
925
- steps. Note this guard alone is **not** mirrored in the reference solver, which still rejects
926
- only above `0xffff * 512`: for an operator `unilateralExitDelay` in `(33549824, 33553920]`
927
- seconds the trader throws here while the solver quotes and then fails deeper in its own script
928
- build. Both refuse, at different seams with different messages, so it is a diagnosability wart
929
- rather than a fund risk — and the window is unreachable in practice (~388 days).
930
-
931
- - **`secrets.ts` is gone; key provisioning moved into `@arkade-os/sdk`.** This package no longer
932
- derives, mints, or names keys. It asks the SDK for what the leg needs — `provisionRefundKey(wallet)`
933
- for a leg it funds, `provisionClaimSecret(wallet, { preimage? })` for one it claims — and
934
- recovers with `contractSigner(wallet, descriptor)` / `contractPreimage(wallet, descriptor,
935
- stored?)`. The returned `ProvisionedKey` / `ProvisionedClaimSecret` replace `SwapSecrets`, and
936
- `descriptor` replaces `signingDescriptor` on them. Removed from this package with no
937
- replacement here: `deriveSwapSecrets`, `randomSwapSecrets`, `senderPubkeyForRfqSecrets`,
938
- `preimageForRfqSecrets`, `senderIdentityForRfqSecrets`, `isPerSwapDescriptor`, `derivePreimage`,
939
- `buildPreimageMessage`, `RFQ_PREIMAGE_TAG`, `isDeterministicSigner`, `adoptSwapDescriptor` (now
940
- `adoptContractDescriptor` in the SDK), `SwapSecrets` / `DerivedSwapSecrets` /
941
- `StoredSwapSecrets`, and `rfqSecretsToRecord` / `rfqSecretsOfRecord` — persist a provisioned
942
- secret with **`swapSecretsToRecord`** from `store` instead, and read P back with
943
- `contractPreimage`. `RefundNotLocallyPossibleError` and `senderIdentityForSwapRecord` stay here
944
- (now in `refundBlocked.ts`): they are swap lifecycle, not key provisioning.
945
- - **No swap record can carry a private key.** `AssetSwap.fallbackSecrets` and the
946
- `AssetSwapFallbackSecrets` types are deleted rather than kept readable, and `preimageHex` — set
947
- only when the wallet reports `mustPersistPreimage` — is the record's one secret field. A record
948
- written by 0.0.1–0.0.3 carries no `signingDescriptor`, so `senderIdentityForSwapRecord` refuses
949
- it with `no-secrets` rather than silently mis-signing; those versions shipped before any
950
- consumer, which is the window for doing this without a secret migration.
951
- - **`requestLightningSend` / `requestOnchainSend` return `secrets`, not top-level raw key material.**
952
- `senderPrivateKey` is gone from both return types; caller-owned onchain preimages live inside
953
- `secrets` and must be persisted with the record. `pushRefundWithoutReceiver` /
954
- `refundIfUnresolved` take `sender: Identity` instead of `senderPrivateKey: Uint8Array` — build
955
- it from the record with `senderIdentityForSwapRecord`, which is what keeps a wallet that cannot
956
- sign reporting `RefundNotLocallyPossibleError` rather than a `TypeError` at the push site.
957
- `AssetSwap` gains `signingDescriptor?` and `preimageHex?`.
958
- - **Every derived address changed, in both corridors.** The lightning-send lockup moved from the
959
- 3-leaf program-artifact VHTLC to the 8-leaf `VHTLC.ScriptV2` (non-interactive claim and refund
960
- leaves), and the L1 HTLC's claim leaf gained a `SIZE 32 EQUALVERIFY` preimage-length guard. Both
961
- are pinned by golden tests (`test/rfq.test.ts`, `test/onchainHtlc.test.ts`). **Deployment must be
962
- coordinated:** trader and solver derive the lockup independently and compare (`lockup_address` /
963
- `htlc_address` are compare-only), so a version mismatch does not lose funds — it refuses every
964
- quote at `verifyLockupAddress`. Upgrade both sides before expecting fills.
965
- - **`cancelOffer` and `restoreAssetSwaps` take an options object.** `cancelOffer(wallet, url,
966
- offerHex, { repository, fundingTxid?, swapAddress? })` — the repository is required because the
967
- call now records its own outcome. `restoreAssetSwaps(indexer, txs, existingIds, { serverPubkey,
968
- scanned? })` — the server key is required because a spend is classified by rebuilding the
969
- covenant and matching the leaf it took.
970
- - **`isCancelSpend` is gone**, replaced by `classifySpend`, and `Tx.assets` with it. The old test
971
- read what a transaction moved, which a wallet reports as a _net_ delta: once the deposit is a
972
- registered contract, an asset offer's cancel moves the asset out and back, nets to zero, and is
973
- indistinguishable from a fill. Leaves have no such failure mode.
974
- - **A spend that cannot be classified is no longer restored as `fulfilled`.** It leaves the funding
975
- txid unanswered so a later scan decides it. Records are never written on a guess.
976
- - **`AssetSwap` gained `signingDescriptor?`**, and `preimageHex` now means "P that cannot be
977
- re-derived" — caller-supplied, or minted for a static descriptor. A field-mapped backend must
978
- persist the record whole: silently dropping `preimageHex` leaves a static swap permanently
979
- unclaimable.
980
- - **The repository interface is at version `3`.** It gained `saveRfqSwap` / `getAllRfqSwaps` /
981
- `removeRfqSwap` for monitored RFQ swaps, and the IndexedDB backend a matching `rfqSwaps` object
982
- store at `DB_VERSION` 2. Version `2` was the shape 0.0.5 released — swaps, scan cursor, markets,
983
- with `preimageSaltHex` on the swap record — and `DB_VERSION` was 1 there, so this is the database's
984
- first version increase. The bump is deliberate: an implementor must acknowledge the new methods
985
- rather than silently satisfy an older shape. Existing databases upgrade in place: the new store is
986
- added and the three original ones are untouched. **`DB_VERSION` 2 is a one-way door** — a browser
987
- whose database has upgraded cannot be rolled back to 0.0.5, which opens it at version 1 and fails
988
- `VersionError` across the whole swap store, not just the RFQ half. Store RFQ records whole for the
989
- same reason as above: what is in one is what nothing else can recover — the manager's own state,
990
- and, inside the corridor's `profile`, its keys and its gates.
991
- - **An RFQ record's keys live in its corridor's `profile`, under two keys.** `profile.signer` holds
992
- `signingDescriptor` — which wallet key signs this leg, on any corridor. `profile.hashlock` holds
993
- `paymentHash` (the covenant binds `hash160` of it, which is one-way) plus, **only on legs we
994
- claim**, `preimageHex` or `preimageSaltHex`. The record's own half — `kind`, `lockupAddress`,
995
- `amount`, the manager's state — recovers nothing on its own, so a backend that drops either nested
996
- object loses the signer or the claim secret exactly as one dropping `preimageHex` used to. Two keys
997
- rather than one because a hashlock belongs to a corridor and a signer does not: a corridor that
998
- settles without a preimage still has a leg to sign and refund.
999
-
1000
- ```ts
1001
- // In. One call per leg, whatever that leg's provisioning produced — never
1002
- // hand-mapped: copying `signingDescriptor` and `preimageHex` across by hand
1003
- // drops the salt a static wallet's P derives from, and the swap is
1004
- // unclaimable with nothing to say so until claim time.
1005
- const record = createRfqSwapRecord(
1006
- {
1007
- kind: "lightning_receive",
1008
- lockupAddress: result.address,
1009
- profile: {
1010
- ...rfqSecretsProfile(result.secrets, result.treeParams.paymentHash),
1011
- expectedAmount: result.expectedAmount,
1012
- payoutAddress: result.payoutAddress,
1013
- },
1014
- },
1015
- swap,
1016
- );
1017
-
1018
- // Out, and WHICH reader depends on the leg. The refund signer, on any leg:
1019
- const sender = await senderIdentityForSwapRecord(wallet, rfqSignerOf(record)!);
1020
- // P, only where we claim — `lightning_receive`, `onchain_send`:
1021
- const claim = rfqClaimSecretOf(record);
1022
- if (claim) await preimageForSwapRecord(wallet, claim); // hash-checked
1023
- ```
1024
-
1025
- - **`lightning_send` has a payment hash and no preimage**, so `rfqClaimSecretOf` answers `undefined`
1026
- for it. P belongs to the payee and its descriptor is a *refund* key from `provisionRefundKey`.
1027
- Wiring the claim helper to all three legs does not degrade gracefully: the salted arm derives
1028
- *some* P off the refund descriptor and the payment-hash check rejects it, so a correct record reads
1029
- as corrupt. That leg's reader is `rfqSignerOf`.
1030
- - **Non-hashlock corridors carry no `profile.hashlock` at all** — no `paymentHash`, no preimage
1031
- material, no placeholder; the key is simply absent, which is why `rfqSecretsProfile` takes the
1032
- payment hash as an optional second argument. They still write `profile.signer` if their leg is one
1033
- this wallet signs. The three corridors shipping today all lock to a preimage, but that is a fact
1034
- about them and not about RFQ. A corridor needing more than one descriptor — a co-signed leg, a
1035
- second key for an L1 half — extends `profile.signer` rather than fabricating a hashlock.
1036
- - **Both readers answer `undefined` only for "this corridor has no such half", and throw on a half
1037
- that is there and unusable.** Neither ever hands back a partial projection:
1038
- `preimageForSwapRecord` verifies only when the projection carries a `paymentHash`, so one missing
1039
- its hash would claim with an *unverified* preimage instead of failing. A thrown
1040
- `PreimageNotRecoverableError("malformed-record")` is a storage bug, not a protocol state — treating
1041
- it as "no preimage available" and falling back to a refund reads the two as the same thing.
1042
- - **An RFQ record stores no covenant.** The tree lives in the lockup's contract row, written before
1043
- the address could be funded and keyed by the script its params derive — a key `createContract`
1044
- refuses to write unless they reproduce it. So the rebuild takes the params from the caller:
1045
-
1046
- ```ts
1047
- const params = await lockupContractParams(
1048
- await wallet.getContractManager(),
1049
- record.lockupAddress,
1050
- );
1051
- const swap = rebuildRfqSwap(record, params);
1052
- ```
1053
-
1054
- `lockupContractParams` throws `LockupContractMissing` when this wallet has no row for the lockup —
1055
- a cleared contract store, or a record from elsewhere. A consumer that would rather not depend on
1056
- the contract store can keep its own copy of
1057
- `VHTLCV2ContractHandler.serializeParams(script.options)` and pass that instead; either way the
1058
- params are checked against the record's `lockupAddress` before a swap is handed back, so the wrong
1059
- row fails at restore rather than at refund time. **Superseded** for a consumer that wires
1060
- `RfqSwapManagerDeps.repository`: `restoreFromRepository()` is this loop, over every stored
1061
- record, with retention in front of it.
1062
-
1063
- - **Pruning is the consumer's unless the manager holds the repository.** `shouldRetainRfqSwap(record,
1064
- now)` answers whether a record is still worth keeping — live swaps and `needs_counterparty`
1065
- always, terminal ones for `RFQ_SWAP_RETENTION_SECONDS` (30 days) after `updatedAt`. Sweep with it
1066
- at boot and pass the rejects to `removeRfqSwap`; skip it and a hot wallet's `rfqSwaps` store grows
1067
- without bound. `now` is **unix seconds**, the unit `RfqSwap.updatedAt` carries — `Date.now()` would
1068
- retire every terminal record after ~43 minutes. **Superseded** for a consumer that wires
1069
- `RfqSwapManagerDeps.repository`: `pruneRetiredSwaps()` is that sweep, and
1070
- `restoreFromRepository()` runs it first.
1071
- - **A write that gates something irreversible throws; one that follows it does not.**
1072
- `addAssetSwap` and `updateAssetSwap` throw on a failed read or write — nothing irreversible may
1073
- happen until the record is durable, which is why `cancelOffer` writes its `cancelling` marker
1074
- before broadcasting. `updateAssetSwapBestEffort` is the other half: it records transitions that
1075
- follow an irreversible action (a broadcast claim, a spent lockup), so it cannot fail the caller,
1076
- and returns `{ swaps, persisted }` instead. `watchOfferSwaps` uses it and fires `onUpdate` only
1077
- when `persisted` is true — the callback is documented as following a persisted change, and a
1078
- consumer caching from it must not run ahead of the store.
1079
- - **`lightningSendProgram` and `htlcSendProgram` are gone** along with the program-artifact layer
1080
- they compiled. Derive scripts through `lightningSendVtxoScript` / `onchainHtlcScript`.
1081
- - **The receive corridors are wired, and the wire shape settled.** `lightningReceiveRequest` is
1082
- new; `onchainReceiveRequest`'s profile now matches the shipped solver schema (`payment_hash`,
1083
- `claim_packet`, `refund_pubkey`, `payout_address`, `payout_pubkey` — the earlier
1084
- `destination_address` / object-shaped `claim_packet` never interoperated). `sealClaimPacket`
1085
- drops the vestigial `arkadeScript` input: the packet was never cryptographically bound to it,
1086
- and the solver recomputes the script from its own row, so the wire carries only the ciphertext.
1087
- `requestLightningSend` now returns `fundAmount = quote.from_amount` — the invoice plus the
1088
- corridor's fee — and refuses quotes whose `to_amount` reprices the invoice; solvers charge
1089
- per-corridor fees on all four pairs, and funding the bare invoice amount underfunds by exactly
1090
- the fee.
1091
- - **`lightningSendVtxoScript` takes two new required fields**: `senderPubkey` (the trader's VHTLC
1092
- sender key — generate, persist, see `requestLightningSend`) and `receiverPkScript` (the solver's
1093
- claim destination, from `profile.receiver_pk_script`). Callers that built the lockup directly
1094
- must supply both; callers going through `requestLightningSend` are unaffected.
1095
- - **`RfqSwapManagerCallbacks` gained a required `claimLockup`**, and `RfqSwap` a third member,
1096
- `LightningReceiveSwap`. Required rather than optional for the same reason `claimOnchain` is: a
1097
- receive swap monitored with nothing wired to claim it expires quietly, and a compile error is the
1098
- right way to learn a corridor was added. A caller with only send swaps can satisfy it with a stub
1099
- that throws. `RfqSwapActionName` gains `"claimLockup"`, so an exhaustive `switch` over it needs a
1100
- new arm. **Superseded:** such a caller now installs `AvailableRfqSwapManagerCallbacks` and omits
1101
- both — see above.
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 uses no DOM and no
6
+ Node-specific APIs, so it runs in Node, the browser, and React Native alike. Four storage backends
7
+ ship — in-memory (anywhere, nothing outlives the process), IndexedDB (browser), SQLite and Realm
8
+ (React Native, on subpath entry points) — see "Storage backends" below.
9
+
10
+ The one global the core API requires is `crypto.getRandomValues`. Node and browsers have it;
11
+ React Native does not, so install `react-native-get-random-values` (or `expo-crypto`) and import
12
+ it before this package. `crypto.subtle` is not used. `EventSource` and `WebSocket` are needed only
13
+ by the watch and relay transports, both of which take an injected implementation.
14
+
15
+ ## Roles
16
+
17
+ Arkade Intents names two participants:
18
+
19
+ - **user** — states an intent and, through a wallet or application, approves and funds it. That is
20
+ the consumer of this package: it prices a swap against the registry's markets, funds the derived
21
+ contract, and tracks it to a fill or a cancellation.
22
+ - **solver** — supplies inventory and pricing, and fills the funded contract by delivering
23
+ `wantAmount` to the user's script over the covenant's `fulfill` path. Some specifications and
24
+ repositories use _provider_ or _market maker_ as synonyms.
25
+
26
+ **`maker` and `taker` in this package name contract positions, not product roles.** The covenant
27
+ programs bind `makerWP`, and the `Offer` type carries `makerPkScript` and `makerPublicKey`; those
28
+ identify the side that funds the swap and receives `wantAmount`. Read them as script field names.
29
+
30
+ Arkade Intents documentation deliberately avoids maker and taker for the participants themselves.
31
+ A resting maker order is firm once taken, and nothing here is: the user funds first, and if no
32
+ solver fills, the deposit comes back through `cancelOffer` rather than through an executed trade.
33
+ Naming the sides _user_ and _solver_ says who does what without borrowing a guarantee the contract
34
+ does not make.
35
+
36
+ ## Request for quote
37
+
38
+ Every Arkade Intents route is request-for-quote: the user states an intent, receives the solver's
39
+ terms as a quote, funds the contract it derives from those terms, and a solver fills it. This
40
+ route is no exception — what is specific to it is _where the quote is resolved_. `quoteOffer`
41
+ prices the swap client-side from the market card the solver publishes: its price feed and its fee,
42
+ the same two inputs a relay quote would carry. Same protocol, one fewer network hop, and a quote
43
+ that is ready before the user finishes typing an amount.
44
+
45
+ The card commits a solver to a price; a fill commits it to your swap. Nothing is signed and no
46
+ inventory is reserved until a solver lands on the funded contract, so treat the quote as terms to
47
+ show and validate — which is what `validatePlan` is for — rather than as a reservation.
48
+
49
+ Relay-negotiated quotes are where this is going, and not only here: every corridor — Lightning,
50
+ onchain, and intra-Arkade alike — converges on asking solvers for quotes over the relay, under one
51
+ message family. What stays specific to this route is the settlement script, not the negotiation:
52
+ both legs live in the same ledger, so a non-interactive swap covenant replaces the HTLC that
53
+ cross-ledger corridors need. The quote you resolve locally today is the quote a solver will answer
54
+ with then.
55
+
56
+ ## Funding, then fill or cancel
57
+
58
+ Every swap has the same two beats, on this route and on the cross-ledger corridors:
59
+
60
+ 1. **Funding** — the user funds the contract it derived from the quote. Funding _is_ acceptance;
61
+ there is no accept message to send, here or anywhere in Arkade Intents.
62
+ 2. **Fill, or cancel** — a solver fills by delivering the other side, or the user takes the
63
+ deposit back.
64
+
65
+ Cancel is this route's refund path. Where an HTLC corridor refunds through a timelocked leaf, this
66
+ covenant refunds through `cancelOffer` — a 2-of-2 with the Arkade server, **no solver signature
67
+ involved**. Same job, same guarantee that the money comes home, reached by a script that fits a
68
+ single-ledger swap.
69
+
70
+ The one thing to design for: the covenant carries no timelock, so an offer keeps its place until
71
+ it is filled or cancelled. There is no window to miss, no deadline to race, and no expired state
72
+ to recover from — the trade-off is that the deposit comes back when you ask for it, so a UI that
73
+ funds an offer should keep cancelling within reach.
74
+
75
+ ## The seven layers
76
+
77
+ 1. **`offer`** — the swap covenant itself. Two program JSONs (want-BTC / want-asset), the
78
+ `Offer` type, the TLV wire codec (`encodeOffer`/`decodeOffer`, `OFFER_PACKET_TYPE`), address
79
+ derivation (`offerVtxoScript`), and the user-side operations `createOffer`/`cancelOffer`. Identical
80
+ offers always derive identical swap addresses — the program JSONs are hashed into the address,
81
+ so their bytes are frozen (guarded by a golden test).
82
+ 2. **`markets`** — solver discovery and pricing guardrails: `discoverMarkets` (1-hour cached
83
+ registry fetch with stale-cache fallback; it follows the network's published index unless
84
+ `registryUrl` overrides it), `findMarket`, `validatePlan` (balance, both-side
85
+ limits, BTC-leg dust), `QUOTE_OPTIONS`, and `makeCachedFeedFetch` for rate-limited price feeds.
86
+ 3. **`store`** — the persisted `AssetSwap` records (`getAssetSwaps`/`addAssetSwap`/
87
+ `updateAssetSwap`), thin helpers over an `AssetSwapRepository`. Read failures degrade to an
88
+ empty list; write failures throw so pre-funding records can be retried before money is sent.
89
+ 4. **`restore`** — `registerAssetSwapRestore` attaches durable swap recovery to an explicit
90
+ `wallet.restore()`. The underlying `restoreAssetSwapRepository` / `restoreAssetSwaps` scan sent
91
+ virtual txs for offer packets and bind each funding vtxo to its spend. The scan remains
92
+ available directly for ordinary startup and later reconciliation; answered txids are remembered
93
+ in the repository (`getScannedTxids`/`markTxidsScanned`) so nothing is fetched twice.
94
+ 5. **`watch`** — `watchOfferSwaps` drives swap status from the wallet's own contract events, so a
95
+ fill shows up without re-running a scan. Registration is what makes it possible: only a
96
+ registered covenant is watched. See "Live status" below.
97
+ 6. **`rfq`** — the user side of quoted swaps: RFQ negotiation over HTTP or a
98
+ relay, then non-interactive filling (see below). All four reference-solver corridors:
99
+ `arkade:BTC -> lightning:BTC` and `arkade:BTC -> onchain:BTC` (send), `lightning:BTC ->
100
+ arkade:BTC` and `onchain:BTC -> arkade:BTC` (receive), plus `arkade:BTC|asset ->
101
+ arkade:BTC|asset` (quote, then take by funding an offer from layer 1).
102
+ 7. **`onchainHtlc`** — the Bitcoin-L1 side of `arkade:BTC <-> onchain:BTC`: a NUMS-keyed taproot
103
+ HTLC as pure local derivation (golden-pinned), claim/refund spend builders with signing as a
104
+ callback, the injected `ChainSource` seam (the package holds no L1 backend and no keys),
105
+ preimage extraction from a spend's witness, and crash-recovery classification.
106
+ `claimPacket` seals P to covclaimd for the receive directions.
107
+
108
+ Everything the package persists — swap records, the restore-scan cursor, and the markets cache —
109
+ goes through a single `AssetSwapRepository`, following the Arkade repository convention
110
+ (versioned interface, `AsyncDisposable`, one backend per platform). Construct one and pass it
111
+ wherever the package asks for a repository; `discoverMarkets` also accepts none, for a one-shot
112
+ uncached discovery.
113
+
114
+ ## Storage backends
115
+
116
+ | Backend | Import from | For |
117
+ | ------------------------------ | ------------------------------------- | ----------------------------------------------- |
118
+ | `InMemoryAssetSwapRepository` | `@arkade-os/swap` | tests, one-shot scripts — nothing survives exit |
119
+ | `IndexedDbAssetSwapRepository` | `@arkade-os/swap` | the browser (or a polyfilled IndexedDB) |
120
+ | `SQLiteAssetSwapRepository` | `@arkade-os/swap/repositories/sqlite` | React Native, over your SQLite driver |
121
+ | `RealmAssetSwapRepository` | `@arkade-os/swap/repositories/realm` | React Native, over your Realm instance |
122
+
123
+ Neither subpath adds a dependency: they take the SDK's structural `SQLExecutor` / `RealmLike`
124
+ handles, so you pass the database you already opened.
125
+
126
+ ### Restore an imported wallet
127
+
128
+ Register swap recovery before the application calls the core wallet's explicit `restore()`:
129
+
130
+ ```ts
131
+ import {
132
+ IndexedDbAssetSwapRepository,
133
+ registerAssetSwapRestore,
134
+ } from "@arkade-os/swap";
135
+
136
+ const repository = new IndexedDbAssetSwapRepository();
137
+ const unregisterSwapRestore = registerAssetSwapRestore(wallet, {
138
+ arkServerUrl,
139
+ repository,
140
+ onResult: ({ changes, coverageError }) => {
141
+ if (coverageError) console.warn("Swap coverage was incomplete", coverageError);
142
+ console.info(`Restored or updated ${changes.length} swaps`);
143
+ },
144
+ });
145
+
146
+ await wallet.restore();
147
+ ```
148
+
149
+ Core address, contract, history, and balance recovery finishes before the swap scan starts. The
150
+ helper reads the recovered wallet history, normalizes it for the scan, rebuilds durable records,
151
+ and repairs covenant coverage. Registering it again on the same wallet replaces the prior
152
+ registration through the stable `arkade-os:asset-swap` hook ID, so setup is idempotent. Call
153
+ `unregisterSwapRestore()` when that integration no longer owns the wallet.
154
+
155
+ A direct `Wallet` exposes the indexer and current Ark server key the helper needs. A proxy or
156
+ custom `IWallet` that does not expose them must pass both explicitly:
157
+
158
+ ```ts
159
+ registerAssetSwapRestore(serviceWorkerWallet, {
160
+ arkServerUrl,
161
+ repository,
162
+ indexer,
163
+ serverPubkey,
164
+ });
165
+ ```
166
+
167
+ `coverageError` is result-level: records were already persisted, so the helper still delivers the
168
+ complete result to `onResult` and a later restore can retry coverage safely. Scan, persistence, or
169
+ `onResult` failures reject the hook; `wallet.restore()` reports hook failures through its
170
+ `AggregateError` after attempting the other registered hooks.
171
+
172
+ Keep calling `restoreAssetSwapRepository` directly during ordinary startup or when history may
173
+ arrive later. Hooks run only during an explicit `wallet.restore()`, and the repository cursor makes
174
+ the manual reconciliation idempotent against records the hook already rebuilt.
175
+
176
+ All four carry both record types: asset swaps and the monitored RFQ swaps
177
+ (`saveRfqSwap` / `getRfqSwap` / `getAllRfqSwaps` / `removeRfqSwap`). Each keeps them in a store of their own — a
178
+ second object store on IndexedDB, an `…rfq_swaps` table on SQLite, the `ArkadeRfqSwap` class on
179
+ Realm — since the two record types have different keys and no consumer wants them interleaved.
180
+
181
+ **Records are stored whole.** The SQLite and Realm backends serialize each record to **JSON** in a
182
+ `data` column, with only `status` / `createdAt` (and an RFQ record's `state` / `updatedAt`) mapped
183
+ out for querying — so a field they do not know about survives, which is what the `quote`-shaped
184
+ extension in `MIGRATION.md` relies on. It is also what keeps an RFQ record's corridor `profile`
185
+ intact: `profile.hashlock` is a nested object holding the payment hash and any preimage material, and
186
+ a field-mapped backend is exactly what would lose it. JSON is
187
+ the boundary, though, and it is narrower than IndexedDB's structured clone: a `Date` in a
188
+ consumer-added field comes back an ISO **string**, a `Set` or `Map` comes back empty, and a `bigint`
189
+ makes `saveSwap` **throw**. `AssetSwap` and `RfqSwapRecord` are both JSON-safe by design (amounts are
190
+ strings, binary is hex), and a corridor `profile` is plain JSON by the handler contract; keep your own
191
+ added fields — and any corridor profile you write — that way too.
192
+
193
+ ### SQLite
194
+
195
+ ```ts
196
+ import { SQLiteAssetSwapRepository } from "@arkade-os/swap/repositories/sqlite";
197
+ import { SQLiteWalletRepository, type SQLExecutor } from "@arkade-os/sdk/repositories/sqlite";
198
+
199
+ const db = await SQLite.openDatabaseAsync("wallet.db"); // expo-sqlite
200
+ // Build the executor ONCE and hand this same instance to every repository on
201
+ // the database: the SDK serializes transactions in a chain keyed by this
202
+ // object, so a per-repository literal splits the chain and two BEGIN
203
+ // IMMEDIATEs can interleave.
204
+ const executor: SQLExecutor = {
205
+ run: (sql, params) => db.runAsync(sql, params ?? []),
206
+ get: (sql, params) => db.getFirstAsync(sql, params ?? []),
207
+ all: (sql, params) => db.getAllAsync(sql, params ?? []),
208
+ };
209
+
210
+ const swaps = new SQLiteAssetSwapRepository(executor);
211
+ const wallet = new SQLiteWalletRepository(executor); // same instance
212
+ ```
213
+
214
+ Sharing the executor is **necessary** for that serialization, not sufficient for atomicity across
215
+ all wallet storage: it disciplines the repositories that enter the chain — this one,
216
+ `SQLiteIntentRepository`, `SQLiteVirtualTxRepository`, and the wallet repository's migration path —
217
+ and nothing else. `SQLiteWalletRepository` and `SQLiteContractRepository` still write raw, so their
218
+ writes can land inside whatever transaction happens to be open.
219
+
220
+ Three tables land in your database, prefixed `arkade_`: `arkade_asset_swaps`,
221
+ `arkade_asset_swap_scanned_txids`, `arkade_asset_swap_markets`. Pass `{ prefix: "myapp_" }` if your
222
+ app already owns those names.
223
+
224
+ ### Realm
225
+
226
+ ```ts
227
+ import Realm from "realm";
228
+ import { AssetSwapRealmSchemas, RealmAssetSwapRepository } from "@arkade-os/swap/repositories/realm";
229
+ import { ArkRealmSchemas } from "@arkade-os/sdk/repositories/realm";
230
+
231
+ const realm = await Realm.open({
232
+ schema: [...ArkRealmSchemas, ...AssetSwapRealmSchemas, ...yourOwnSchemas],
233
+ schemaVersion: YOUR_VERSION, // these schemas are new: bump yours when adding them
234
+ });
235
+ const swaps = new RealmAssetSwapRepository(realm);
236
+ ```
237
+
238
+ Four classes land in your Realm namespace: `ArkadeAssetSwap`, `ArkadeRfqSwap`,
239
+ `ArkadeAssetSwapScannedTxid`, `ArkadeAssetSwapMarketsCache`. Unlike SQLite there is no prefix option
240
+ — a Realm schema name is baked into the schema objects you register — so reconcile against your own
241
+ models by name.
242
+
243
+ `ArkadeRfqSwap` arrived after the other three. **If you already shipped them, add it and bump
244
+ `schemaVersion` again**: Realm creates schemas at open, so a config still listing three fails on the
245
+ first RFQ read rather than at open. SQLite needs nothing — its DDL runs `CREATE TABLE IF NOT EXISTS`
246
+ on every init, so the table appears on the next operation.
247
+
248
+ ## Creating an offer
249
+
250
+ Fund the returned address with the side you deposit, embedding the payload, and the solver does
251
+ the rest:
252
+
253
+ ```ts
254
+ // BTC -> asset
255
+ const o = await createOffer(wallet, ARK, { wantAmount: 1000n, wantAsset });
256
+ await wallet.send({ address: o.address, amount: 1000, extensions: [o.extension] });
257
+
258
+ // asset -> BTC (the sats are the VTXO carrier for the asset)
259
+ const o = await createOffer(wallet, ARK, { wantAmount: 1000n, offerAsset });
260
+ await wallet.send({
261
+ address: o.address,
262
+ amount: 500,
263
+ assets: [{ assetId, amount: 1000n }],
264
+ extensions: [o.extension],
265
+ });
266
+ ```
267
+
268
+ The covenant co-signer ("emulator") key defaults to the SDK's per-network pin, resolved from the
269
+ network the Ark server reports — never fetched from the emulator itself. Pass
270
+ `params.emulatorPubkey` (33-byte compressed hex, the same contract as `Arkade.connect`'s option)
271
+ to override it for a self-hosted emulator, an unpinned network (signet, testnet), or a key
272
+ rotation the SDK hasn't shipped yet.
273
+
274
+ ### What `createOffer` gives you back
275
+
276
+ `createOffer` is pure derivation — it broadcasts nothing. The offer only becomes real when the
277
+ deposit lands at `address`.
278
+
279
+ | Field | What it is |
280
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
281
+ | `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. |
282
+ | `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. |
283
+ | `offerHex` | The encoded offer. **Persist this** — it is the only input `cancelOffer` needs to rebuild the covenant. |
284
+ | `swapPkScript` | The covenant's scriptPubKey: the key an indexer watches to spot the deposit and its later spend. |
285
+
286
+ The minimum you must keep to stay in control of a swap is `offerHex` plus the funding txid.
287
+ Everything else — status, amounts, timestamps — `restoreAssetSwaps` rebuilds from chain, and the
288
+ offer bytes themselves are recoverable from the funding tx if the record is lost.
289
+
290
+ ## Live status
291
+
292
+ ```ts
293
+ const watcher = await watchOfferSwaps({ wallet, arkServerUrl: ARK, repository, onUpdate: render });
294
+ // later
295
+ watcher.stop();
296
+ ```
297
+
298
+ Because `createOffer` registers the covenant, the wallet already watches that script and emits a
299
+ spend event when the deposit moves — so a **fill** reaches you without re-running a scan, which is
300
+ what `restoreAssetSwaps` alone could never do.
301
+
302
+ How a spend is classified, cheapest answer first: a cancel this device made is already recorded by
303
+ `cancelOffer`, so nothing needs deciding; anything else is read off the spending transaction's
304
+ covenant leaf (`cancel` vs `fulfill`), which is exact and stays exact when one transaction fills
305
+ several offers at once. A spend that cannot be classified — the indexer has not caught up, say —
306
+ **leaves the record untouched** for the restore scan to decide later. Nothing is written on a
307
+ guess: a stored swap is skipped by every later scan, so a guess here would be permanent.
308
+
309
+ `onUpdate` is a notification for UI reactivity, not a second store; every write goes through the
310
+ repository.
311
+
312
+ ## Cancelling: the refund path
313
+
314
+ ```ts
315
+ const txid = await cancelOffer(wallet, ARK, swap.offerHex, {
316
+ repository,
317
+ fundingTxid: swap.fundingTxid,
318
+ swapAddress: swap.swapAddress,
319
+ });
320
+ ```
321
+
322
+ The call records its own outcome — `cancelling` before submitting, `cancelled` plus the spend txid
323
+ after — so a cancel needs no follow-up write from the caller, and the live watcher above finds a
324
+ record already resolved rather than re-deriving it.
325
+
326
+ **An unfilled offer never expires.** Neither program carries a timelock, so a deposit no solver
327
+ picked up keeps its place at the swap address — the terms stay open as long as you want them to,
328
+ with no deadline to miss and no "expired" state to unwind. Getting the deposit back is
329
+ `cancelOffer`, available from the moment funding lands and settling as soon as you ask.
330
+
331
+ The two ways out of the covenant are deliberately asymmetric:
332
+
333
+ - **`fulfill`** is signed by the **server alone**, but the covenant constrains it to pay output 0
334
+ to your payout script for at least `wantAmount`. A solver cannot take the deposit without
335
+ delivering the other side.
336
+ - **`cancel`** is a **2-of-2 of you and the server** — no solver signature. Your refund never
337
+ depends on the counterparty being reachable or willing, which is the same property the HTLC
338
+ corridors buy with a timelock, bought here with a cooperative path that needs no waiting.
339
+
340
+ So cancel _races_ a fill rather than pre-empting it. If the solver fills in the same moment,
341
+ `cancelOffer` throws `no spendable VTXO at the swap address` — that means the swap **completed**,
342
+ not that anything went wrong. Re-read the swap's state before treating it as an error;
343
+ `restoreAssetSwaps` tells the two spends apart afterwards and marks the record `fulfilled` rather
344
+ than `cancelled`.
345
+
346
+ Pass `fundingTxid` whenever you have it. Identical offers share an address; when several deposits
347
+ sit there, `cancelOffer` refuses to guess and throws unless `fundingTxid` selects one. Every
348
+ `AssetSwap` carries the txid, so the call above is the shape to prefer. `swapAddress` pins the
349
+ server key the covenant was built with, keeping cancel working across a server signer rotation —
350
+ without it, a rotated key is detected and reported explicitly rather than surfacing as a missing
351
+ VTXO.
352
+
353
+ ## RFQ: quote first, then fill without talking
354
+
355
+ `rfq` is the trader's side of a quoted swap. The negotiation is the ONLY interactive part —
356
+ after the quote, both corridors fill non-interactively, and there is deliberately no accept
357
+ message anywhere: **acceptance is funding**.
358
+
359
+ - **Arkade → Lightning** (`arkade:BTC->lightning:BTC`, implemented): the trader derives the
360
+ lightning-send covenant LOCALLY from the quote's binding fields plus its own data, refuses to
361
+ fund on any address mismatch, funds its own derivation before `valid_until`, and may go
362
+ offline. The solver observes the funding on-chain, pays the invoice, and claims with the
363
+ preimage — which lands publicly in the claim witness as the receipt. A failed swap refunds by
364
+ covenant to the trader's address, pushable by anyone, no trader keys or state.
365
+ - **Arkade ↔ arkade** (BTC↔asset, asset↔asset): an arkade asset leg names the asset id itself —
366
+ `arkade:<68-hex>`, built with `arkadeAssetLeg` (the deprecated coarse `ARKADE_ASSET` is served by
367
+ no solver). The trader accepts a quote by creating and funding
368
+ an **offer** (layer 1) bound to the quoted terms before `valid_until`. The offer covenant only
369
+ releases the deposit to a fill that delivers the quoted amount, so the solver fills or nothing
370
+ moves; an unfilled offer is cancelled cooperatively. The quote wire shape ships here; the
371
+ reference solver serves the Lightning pair today.
372
+
373
+ ```ts
374
+ import { httpTransport, requestLightningSend, SwapRefusal } from "@arkade-os/swap";
375
+
376
+ // invoice facts from YOUR OWN decoder — the module takes facts, not a decoder
377
+ const swap = await requestLightningSend(wallet, arkServerUrl, httpTransport(solverUrl), {
378
+ invoice: { raw: bolt11, paymentHash, amountSats, expiresAt },
379
+ });
380
+ // quote verified against the LOCAL derivation and gated; now fund and go offline:
381
+ await wallet.send({ address: swap.address, amount: swap.fundAmount });
382
+ ```
383
+
384
+ Both `request*` functions register the lockup with the wallet's contract manager before returning
385
+ an address, the way `createOffer` registers its covenant: the lockup is watched from the moment it
386
+ lands and stays out of generic coin selection, so nothing can spend a live swap out from under
387
+ itself. A write failure throws `LockupRegistrationFailed` — the one throw here that does not mean
388
+ "walk away from this quote", since nothing is funded yet and the quote is still good. Keep
389
+ `swap.script`: it is the covenant object `RfqSwapManager` takes as a record's `lockup`, without
390
+ which the manager can only poll and cannot retire the row when the swap ends.
391
+
392
+ The trust model is the offer side's, applied to quotes: only `solver_pubkey`,
393
+ `refund_locktime`, `valid_until` and the amounts are used from a quote; every other contract
394
+ parameter is the trader's own data, and anything address-shaped from the solver is compare-only
395
+ (`AddressMismatch` means refuse-to-fund). The emulator key is neither: as above, it is a
396
+ per-network pin inside the SDK, not solver data.
397
+ Refusals carry a closed reason set (`SwapRefusal`). A solver may also return a recognised
398
+ `error_code` with client-safe context. The error exposes these as `errorCode`, `field`, `actual`,
399
+ `expected`, `limit`, and `unit`, and includes useful numeric context in its message. Match
400
+ `errorCode` for a specific remedy while treating `reason` as the compatible fallback:
401
+
402
+ ```ts
403
+ if (error instanceof SwapRefusal && error.errorCode === "invoice_cltv_too_large") {
404
+ console.error(`Invoice CLTV is ${error.actual} blocks; solver limit is ${error.limit}`);
405
+ }
406
+ ```
407
+
408
+ Unknown diagnostic codes and fields stay generic. `RFQ_REFUSAL_ERROR_CODES` and
409
+ `isRfqRefusalErrorCode` expose the accepted vocabulary. The `swap-lightning-send.program.json`
410
+ bytes are frozen the same way the offer programs are — a golden test pins the compiled leaves and
411
+ scriptPubKey to the reference solver's exact script.
412
+
413
+ Transports are symmetric-outbound: `httpTransport` (POST `/v1/swap`, GET `/v1/rfq/<rfq_id>`),
414
+ `relayTransport` (the dev broker framing), and `nostrRfqTransport` — the production one a
415
+ deployed solver actually listens on. Status by `rfq_id` reaches terminal states
416
+ `settled / refused / expired / refunded / stuck`; receipts (the preimage) appear only in
417
+ `settled`, and the chain itself is always the fallback nobody can withhold.
418
+
419
+ ### The Nostr transport is a separate entry point
420
+
421
+ ```ts
422
+ import { nostrRfqTransport } from "@arkade-os/swap/nostr";
423
+
424
+ const transport = nostrRfqTransport({
425
+ relays: card.transports.nostr.relays,
426
+ solverPubkey: card.discovery_pubkey,
427
+ });
428
+ ```
429
+
430
+ `nostr-tools` is an **optional peer dependency**, so it is only required if you import this
431
+ subpath — a consumer doing HTTP-only swaps never resolves it, and the package root pulls in
432
+ nothing Nostr-related. The trade is that importing `@arkade-os/swap/nostr` without `nostr-tools`
433
+ installed fails at resolution, which is the intended loud failure rather than a transport that
434
+ silently degrades.
435
+
436
+ Directed traffic rides kind `24859`, which is **ephemeral** (NIP-01's 20000–29999): a conforming
437
+ relay does not retain it. This must match the solver's `NOSTR_KIND_DIRECTED` — the two sides
438
+ subscribe by `kinds`, so a mismatch is not an error either can report. They simply never see each
439
+ other, and every request times out appearing to blame the solver.
440
+
441
+ ## Onchain corridor: `arkade:BTC -> onchain:BTC` (and back)
442
+
443
+ The off-board direction is implemented end to end on the user side. The user generates `P`
444
+ itself — `sha256(P)` is the wire `payment_hash`, and the script commitment is
445
+ `ripemd160(sha256(P))` in BOTH contracts, so one preimage unlocks the Arkade leaf and the L1 leaf.
446
+ The Arkade lockup is byte-identical to the lightning-send program (`htlcSendProgram` is an alias —
447
+ one artifact, one golden test); the L1 side is a two-leaf taproot HTLC with the BIP-341 NUMS
448
+ internal key (no key-path spend, ever): claim = `HASH160 <h160> EQUALVERIFY <claimKey> CHECKSIG`,
449
+ refund = `<locktime> CLTV DROP <refundKey> CHECKSIG`.
450
+
451
+ ```ts
452
+ import { hex } from "@scure/base";
453
+ import {
454
+ httpTransport,
455
+ requestOnchainSend,
456
+ awaitOnchainFill,
457
+ claimOnchainFill,
458
+ addAssetSwap,
459
+ swapSecretsToRecord,
460
+ } from "@arkade-os/swap";
461
+
462
+ const swap = await requestOnchainSend(wallet, arkServerUrl, httpTransport(solverUrl), {
463
+ amount: 100_000,
464
+ amountSide: "to",
465
+ payoutPubkey,
466
+ });
467
+ // Persist the record, including secrets, BEFORE funding. This must succeed:
468
+ // if addAssetSwap throws, do not call wallet.send.
469
+ await addAssetSwap(repository, {
470
+ ...record,
471
+ id: swap.rfqId,
472
+ pair: "arkade:BTC->onchain:BTC",
473
+ paymentHash: swap.htlc.paymentHash,
474
+ swapAddress: swap.address,
475
+ swapPkScript: hex.encode(swap.swapPkScript),
476
+ htlcPkScriptHex: hex.encode(swap.htlc.pkScript),
477
+ htlcLocktime: swap.htlc.refundLocktime,
478
+ ...swapSecretsToRecord(swap.secrets),
479
+ });
480
+ await wallet.send({ address: swap.address, amount: swap.fundAmount });
481
+
482
+ // Unlike lightning-send the user must STAY CLAIM-CAPABLE: watch for the fill
483
+ // and claim before the HTLC's refund leaf opens. chain is YOUR ChainSource.
484
+ const utxo = await awaitOnchainFill(chain, swap.htlc, minConfirmations);
485
+ await claimOnchainFill(chain, {
486
+ htlc: swap.htlc,
487
+ utxo,
488
+ preimage: swap.secrets.preimage,
489
+ payoutPkScript,
490
+ feeRateSatVb,
491
+ sign,
492
+ });
493
+ ```
494
+
495
+ `requestOnchainSend` derives BOTH contracts locally from the quote's binding fields
496
+ (`solver_pubkey`, `refund_locktime`, `htlc_pubkey`, `htlc_locktime`, `min_confirmations`) and
497
+ refuses on any mismatch — `lockup_address` and `htlc_address` are compare-only. `assertFundable`
498
+ adds three onchain gates, run immediately before funding: `timelock_order` (the L1 locktime plus a
499
+ 2 h reorg margin must fall before the Arkade refund, so the user's escape hatch opens LAST),
500
+ `claim_window_too_short`, and `confirmations_out_of_range`. `claimOnchainFill` refuses to
501
+ broadcast — publishing `P` — with less than 90 minutes before the refund leaf opens: past that
502
+ point the safe move is to let the swap die and take the Arkade covenant refund rather than race
503
+ the solver's refund with `P` exposed. If the solver never fills, there is nothing to do: the
504
+ covenant refund pays the user's address after `refund_locktime`, pushable by anyone.
505
+
506
+ Crash recovery is record-driven, not chain-driven: `classifyOnchainHtlc` re-derives the HTLC's
507
+ state (unfunded / awaiting confirmations / claimable / refundable / claimed-with-P / swept) from
508
+ `ChainSource` plus the stored outpoint — without the stored record a spent HTLC is
509
+ indistinguishable from an unfunded one, which is why persisting before funding is mandatory. The
510
+ `AssetSwap` record carries the onchain fields (`paymentHash`, `signingDescriptor`, `preimageHex`
511
+ for a P that cannot be re-derived, `htlcPkScriptHex`, `htlcLocktime`, `l1Txid`) and the statuses
512
+ `awaiting_fill / claimable / claimed / refunded_l1`.
513
+
514
+ **On-board corridors are covered.** `requestLightningReceive` (`lightning:BTC -> arkade:BTC`) and
515
+ `requestOnchainReceive` (`onchain:BTC -> arkade:BTC`) mirror the send-side flows: quote → derive
516
+ BOTH contracts locally (the role-inverted VHTLC, and the L1 HTLC for the onchain leg) → verify
517
+ against the quote's compare-only addresses → gate. `requestLightningReceive` returns the solver's
518
+ hold invoice to pay; `requestOnchainReceive` returns the L1 HTLC to fund — the payment/broadcast
519
+ itself is the trader's own wallet's job, exactly as on the send corridors.
520
+
521
+ The invoice on the lightning-receive leg is the _solver's_, so the SDK owns the comparison rather
522
+ than taking the caller's facts about it: `requestLightningReceive` requires a `decodeInvoice`
523
+ callback (no BOLT11 dependency is added) and `verifyReceiveInvoice` binds the decoded invoice to
524
+ this swap's `H` and to `quote.from_amount` — an invoice on another payment hash is the one attack
525
+ here with no on-chain trace, since the payer pays it in full and no lockup on `H` is ever funded.
526
+ `assertReceivable` replaces `assertFundable` on this leg: the refund CLTV is the solver's, so the
527
+ window that can run out is the hold invoice's, and the claim window is measured from
528
+ `payDeadline = min(invoice expiry, valid_until)` — returned as the absolute `invoiceExpiresAt`,
529
+ which is the deadline to show a payer, not `valid_until`. The optional `maxPayAmount` caps `from_amount`
530
+ (`price_too_high`). The trader-side
531
+ completion lands in `claim.ts`: `claimReceiveLockup` waits for the solver's funding and pushes the
532
+ collaborative claim with the swap's own `P` and receiver key (covclaimd optional). Both request
533
+ flows return `expectedAmount` (the quote's `to_amount`) — persist it: `pushClaim` requires it and
534
+ refuses, with `LockupAmountMismatchError`, to publish `P` for a lockup funded below it. Matching the
535
+ `pkScript` is not enough on this leg, since a solver that funds the correctly derived script with
536
+ dust still settles the payer's HTLC in full once `P` is out. The gate sums every live output and
537
+ runs before signing — `P` reaches the Ark server at submit — and is skipped only for a lockup we
538
+ have already partially claimed (`partiallyClaimed`), where `P` is public anyway. The push itself is
539
+ core's `signAndSubmitOffchainTx` plus `claimWithPreimageIdentity`, with `verifyServerSignatures`
540
+ on: the server's countersignature is checked per input, against the leaf the local build spends,
541
+ before finalizing. The `sealClaimPacket` vector is cross-checked against
542
+ covclaimd's own `preimage.Decrypt`, and the TLV framing against its `DeserializeClaim`, so both
543
+ are pinned by the reference implementation rather than by this one.
544
+
545
+ `RfqSwapManager` drives the lightning-receive leg too, as `kind: "lightning_receive"` records
546
+ carrying `expectedAmount` and wired to a `claimLockup` callback (`pushClaim`, with `expectedAmount`
547
+ and `partiallyClaimed` passed through — the manager's value check decides _when_ to act, the inner
548
+ one decides whether `P` is published). Nothing is asked of the solver: the reference solver's
549
+ `rfq_status_request` consults neither receive store, so a status poll answers `unknown` for every
550
+ one of these swaps and chain observation is the only workable design. States mean what they do on
551
+ the send legs with the roles swapped — `settled` is _our own_ claim landing, matched by a
552
+ hash-verified preimage spend rather than by the txid we submitted, so a claim that lands without us
553
+ still counts; `claimed` is a local belief and not terminal; and **`refunded` is a loss**, the solver
554
+ having taken back a lockup we failed to claim. A lockup funded below `expectedAmount` is reported
555
+ `needs_counterparty` and never claimed, which is non-terminal — a solver that tops it up before
556
+ the window shuts makes it claimable again, and a lockup funded piecemeal moves `claimed` →
557
+ `claimable` → `claimed` again, so `onSwapUpdate` states say what to do next rather than track
558
+ progress in one direction. A `refunded` outcome from `waitForSwapCompletion` reports no `txid` even
559
+ when a claim was submitted and recorded: the chain never took it, and the record still carries
560
+ `claimArkTxid` for anyone diagnosing the loss.
561
+
562
+ **There is no client-side refund on this leg, and that is the whole answer to "what if I cannot
563
+ claim in time".** Every non-claim leaf of the covenant is the solver's, so `refundArkade` is never
564
+ called for a receive record and no amount of waiting produces one. The deadline is the quote's
565
+ `refund_locktime`: the manager claims right up to it and stops there, because publishing `P` into
566
+ the solver's live refund window risks losing the race and giving away the preimage anyway. Wall
567
+ clock with no margin is already conservative — the solver's leaf is a CLTV maturing against
568
+ median-time-past, which trails, so the real window runs past that instant rather than ending before
569
+ it. Past it the outcome is not symmetric with a send: the solver reclaims the lockup, the held
570
+ Lightning HTLC lapses, and **the payer is refunded** — the trader loses the incoming payment, not
571
+ funds it was holding. Which is why staying online to claim is an obligation and not a preference:
572
+ covclaimd cannot claim this covenant today, so the claim packet's offline path does not yet run.
573
+
574
+ ## Swap secrets come from the wallet, not from this package
575
+
576
+ This package holds no key logic at all. It names the leg it is building and the SDK answers:
577
+
578
+ ```ts
579
+ // a leg we fund — all it needs is the key that refunds it
580
+ const { pubkey: refundPubkey, descriptor: refundDescriptor } = await provisionRefundKey(wallet);
581
+ // a leg we claim — the key that receives it, and the P that unlocks it
582
+ const { pubkey, descriptor, preimage, paymentHash, mustPersistPreimage } =
583
+ await provisionClaimSecret(wallet);
584
+ ```
585
+
586
+ Where the key comes from is the wallet's decision, invisible here: an HD wallet allocates a fresh
587
+ descriptor per swap, a static wallet answers with its one `tr(pubkey)`. The record keeps the
588
+ descriptor, which is public, and `contractSigner(wallet, descriptor)` recovers the signer.
589
+
590
+ What each swap stores, and what is recoverable:
591
+
592
+ | Wallet answers with | Spending key | Preimage (when the leg needs one) | Secret at rest |
593
+ | ------------------------- | --------------------- | ------------------------------------- | ----------------- |
594
+ | fresh HD descriptor | re-derives from seed | derives deterministically | none |
595
+ | static `tr(pubkey)` | the wallet's identity | derives from a public per-swap salt | none |
596
+ | a signer that cannot sign | | | |
597
+ | deterministically | the wallet's identity | random, stored on the record | the preimage only |
598
+
599
+ The preimage split follows the **descriptor's shape**, not the wallet's type. An HD child
600
+ descriptor is unique to its swap, so `sha256(sign_det(...))` over the key alone is safe. A static
601
+ descriptor is the same key for every swap, so that derivation would repeat across swaps — one
602
+ solver learning its own preimage would learn every other swap's — and the uniqueness has to come
603
+ from the message instead: the SDK mints 32 random bytes per swap, signs a **salted** message, and
604
+ stores the salt in the clear.
605
+
606
+ **The salt is not a secret.** Knowing it yields nothing without the seed, which is the whole
607
+ difference from the preimage it replaces: the record goes from carrying a per-swap _secret_ to a
608
+ per-swap _public_ value, exactly what `signingDescriptor` already is. Recoverability is unchanged
609
+ in shape — keep the record and the swap recovers from the seed.
610
+
611
+ Only a signer that cannot sign deterministically at all — an external or extension signer — still
612
+ gets a random stored preimage. `mustPersistPreimage` says which you got, and it is the only thing
613
+ to branch on. A stored preimage remains the one secret at rest in the design, and it is never a
614
+ private key.
615
+
616
+ ```ts
617
+ const swap = await requestOnchainSend(/* … */);
618
+ // `swapSecretsToRecord` stores the public descriptor always, then whichever of
619
+ // `preimageSaltHex` (derivable) or `preimageHex` (not) the wallet produced.
620
+ await saveSwap({ ...record, ...swapSecretsToRecord(swap.secrets) });
621
+
622
+ // Later, from the seed plus the record's public fields. Only ask for a
623
+ // preimage the corridor gave us one for: a lightning send's P belongs to the
624
+ // payee, so this throws on those records rather than inventing something the
625
+ // chain will never match. `LIGHTNING_SEND_PAIR` is exported from this package.
626
+ if (record.pair !== LIGHTNING_SEND_PAIR) {
627
+ const preimage = await preimageForSwapRecord(wallet, record);
628
+ }
629
+
630
+ // For a refund, take the composition instead of the guard: it turns all three
631
+ // ways a wallet can fail to produce the sender key — the record names no
632
+ // descriptor, the descriptor is another seed's, the wallet holds the key but
633
+ // cannot sign — into one typed `RefundNotLocallyPossibleError` carrying which,
634
+ // and lets a signer outage stay retryable. Wire `refundArkade` to this.
635
+ const sender = await senderIdentityForSwapRecord(wallet, record);
636
+ ```
637
+
638
+ `RfqSwapManager` catches that error and reports `needs_counterparty` with a `blockedReason`,
639
+ instead of retrying a push that cannot work until the refund window closes. The state is **not**
640
+ terminal: the lockup stays funded and watched, a solver claim still ends the swap `settled`, and a
641
+ `canRefundArkade` probe answering `ok` — after the right wallet is restored — returns it to
642
+ `pending`. The manager reports the same state when nothing is wired to act (`enableAutoActions:
643
+ false`, or no callbacks) and the window has passed.
644
+
645
+ **The two claim callbacks may be omitted.** `setCallbacks` accepts
646
+ `AvailableRfqSwapManagerCallbacks` — the full contract with `claimOnchain` and `claimLockup`
647
+ optional — so a consumer driving only lightning sends installs neither instead of stubbing them to
648
+ throw. Dispatch is already kind-gated, so neither is reachable there. `saveSwap` is optional too
649
+ (see below); `refundArkade` stays required.
650
+
651
+ `RfqSwapManagerCallbacks` itself is unchanged and still means "fully wired", so a helper taking one
652
+ and calling `claimOnchain` keeps its guarantee; only the parameter widens, which every existing
653
+ caller satisfies. What moves from compile time to runtime is bought back as a **block**: a kind
654
+ whose claim is missing reports `needs_counterparty` naming the gap, non-terminal and re-evaluated
655
+ every pass, lifted the moment `setCallbacks` supplies it. Not `failed` — `setCallbacks` is
656
+ installable late by design, and a terminal state would foreclose the late wiring this exists for.
657
+ A manager with *no* callbacks at all keeps today's manual mode on the L1 half: it reports
658
+ `claimable` and you act by hand.
659
+
660
+ **Take `arkadeRefunder` rather than assembling `refundArkade` by hand.** It composes the atomic
661
+ push and keeps the three rules the manager relies on structural instead of documented — an empty
662
+ lockup returns `null`, and both `RefundNotLocallyPossibleError` and `LockupNeedsRecoveryError`
663
+ propagate untouched.
664
+
665
+ ```ts
666
+ manager.setCallbacks({
667
+ // `repository` is how it reaches `profile.signer`: the live swap the manager
668
+ // passes carries no descriptor, so the refund key is resolved by `rfqId`.
669
+ refundArkade: arkadeRefunder({ ark, indexer, wallet, repository }),
670
+ saveSwap,
671
+ });
672
+ ```
673
+
674
+ Keep the covenant on the swap (`request*`'s `script`, as a record's `lockup`): the refund is built
675
+ from it, and a swap carrying only `lockupPkScript` is refused rather than pushed.
676
+
677
+ ### Let the manager own the records
678
+
679
+ Give `RfqSwapManager` a `repository` and it persists RFQ swaps itself — the restore loop, the
680
+ retention pass and every write, none of which a consumer has to compose:
681
+
682
+ ```ts
683
+ const manager = new RfqSwapManager({
684
+ indexer,
685
+ contracts: await wallet.getContractManager(),
686
+ repository, // any AssetSwapRepository
687
+ });
688
+ manager.setCallbacks({ refundArkade, claimLockup });
689
+
690
+ // Rebuild what was stored: retention first, then each record's covenant from
691
+ // its own contract row, then `rebuildRfqSwap`. No caller input at all.
692
+ const { restored, failed, pruned } = await manager.restoreFromRepository();
693
+ await manager.start();
694
+
695
+ // A NEW swap arrives with the request-time half a live record cannot carry.
696
+ await manager.addSwap(swap, {
697
+ kind: "lightning_send",
698
+ lockupAddress: request.lockupAddress,
699
+ profile: rfqSecretsProfile(secrets, paymentHash),
700
+ fundingArkTxid,
701
+ amount,
702
+ });
703
+ ```
704
+
705
+ That second argument is the whole point. Composing the write by hand runs into an **origin trap**:
706
+ `updateRfqSwapRecord(record, swap)` needs the record that does not exist yet, and
707
+ `createRfqSwapRecord(origin, swap)` needs request-time facts the live swap never carried — so a
708
+ swap's *first* record cannot be built from the swap alone. `addSwap`'s `origin` is where those
709
+ facts arrive, and the manager keeps them for the swap's life. Omit it and one of two things
710
+ happens: the store already holds a record, which *is* the origin, and it is read back; or it does
711
+ not, and you get `RfqSwapOriginRequired` at the door rather than an unwritable record a pass later.
712
+ An origin whose `kind` or `lockupAddress` is not this swap's is refused at that same door, for the
713
+ same reason: the write that would catch it happens a pass later, with the funding broadcast.
714
+ `start(swaps)` applies the same rule and is otherwise unchanged. Restored swaps carry their own.
715
+
716
+ `restoreFromRepository` returns three disjoint lists, and every stored record is in exactly one.
717
+ A record that cannot be rebuilt — no contract row (`LockupContractMissing`), covenant params that
718
+ do not derive the funded address, a corridor with no handler — lands in `failed` with its error and
719
+ stays in the store; it never strands the others and it is never silently dropped. `pruned` names
720
+ what retention removed: terminal and more than `RFQ_SWAP_RETENTION_SECONDS` past `updatedAt`, never
721
+ `needs_counterparty`. Retention runs first, so a retired record costs no contract lookup on its way
722
+ out; `pruneRetiredSwaps()` is public for a process that wants it on its own cadence. Pass
723
+ `{ params }` to take covenants from somewhere other than the contract store.
724
+
725
+ **Two sinks, and both gate.** With a repository wired the canonical `RfqSwapRecord` is written
726
+ first, then `saveSwap` if one is installed, and the pass counts as persisted only when both
727
+ succeeded — which is exactly today's rule for `saveSwap`, applied to whichever sinks exist. A
728
+ rejection from either leaves the record dirty and monitored, so waiters stay unsettled and a
729
+ terminal swap is not finalized until the write it claims lands. A failed canonical write skips
730
+ `saveSwap` entirely: projecting a state the record of record has just refused would put the
731
+ secondary sink ahead of the primary. If your `saveSwap` writes that same repository by hand, delete
732
+ the duplicate when you wire the dep — otherwise every pass writes twice — and keep the callback for
733
+ genuinely secondary sinks. With neither wired, state stays in memory and dies with the process.
734
+
735
+ **Terminal records name the transaction that ended them.** `RfqSwap.lockupSpendArkTxids` is
736
+ stamped from the chain read that resolved the swap — the solver's claim on a send leg, its reclaim
737
+ on a receive one, the trader's own claim when a receive settles. Nothing local produces those
738
+ transactions, so no other field can name them, and without the stamp the only way to find them is
739
+ another lockup read per terminal swap. Absent when the indexer named the checkpoint but not the ark
740
+ transaction: fewer txids beats a wrong one.
741
+
742
+ `preimageForSwapRecord` is the read path to wire, not a hand-rolled `contractPreimage` call: it
743
+ knows which of the record's fields are derivation inputs, and it verifies the result against
744
+ `paymentHash`. A caller that forgets to pass the salt gets a _wrong_ preimage from a wallet that can
745
+ derive, not an error — and that surfaces as an opaque script failure at claim time.
746
+
747
+ Every refusal is a `PreimageNotRecoverableError` carrying a `reason`: `no-secrets` (the record
748
+ predates the descriptor), `malformed-record`, `not-derivable` (nothing to derive from, or a key this
749
+ wallet does not hold), or `hash-mismatch` (derived, but wrong — a tampered salt or the wrong seed).
750
+ Branch on `reason`, never on message text. It is deliberately **not**
751
+ `RefundNotLocallyPossibleError`: that one means no local refund is possible and `RfqSwapManager`
752
+ reports `needs_counterparty` for it, which is a different verdict from a claim-path read failing.
753
+
754
+ A caller-supplied preimage keeps `signingDescriptor` for the sender key and stores only
755
+ `preimageHex` as secret material.
756
+
757
+ On an HD wallet each swap **allocates** its own descriptor rather than peeking at the current one:
758
+ two swaps sharing a descriptor derive the _identical_ preimage, so one solver learning its own
759
+ preimage would learn the other swap's. (Static wallets share their one descriptor by design — the
760
+ per-swap salt is what separates their preimages instead.) On restore, `adoptContractDescriptor`
761
+ (from `@arkade-os/sdk`) moves the wallet's watermark past a restored record's index so it cannot be
762
+ handed out twice; a static descriptor names no index and adopts as a no-op.
763
+
764
+ Two derivations, picked by the descriptor's shape:
765
+
766
+ ```
767
+ HD child sha256(sign_det(sha256("Arkade-RFQ-Preimage-v1" ‖ xonly(32) ‖ u32le(0))))
768
+ static/salted sha256(sign_det(sha256("Arkade-Contract-Preimage-Salted-v1" ‖ xonly(32) ‖ salt(32))))
769
+ ```
770
+
771
+ The first mirrors NArk's Boltz scheme (`SwapsManagementService.cs:128-160`) with an RFQ-scoped tag.
772
+ NArk has no RFQ corridor yet, so this tag defines the scheme rather than matching one; it is
773
+ deliberately distinct from the Boltz tag so one wallet key cannot derive the same preimage for both
774
+ corridors.
775
+
776
+ The salted tag is corridor-generic where the first is not, and that asymmetry is deliberate: the v1
777
+ tags must be per-corridor because v1 pins its message index, leaving the tag as the only separation
778
+ between two corridors reaching the same key. The salted form mints a fresh salt per swap, so no two
779
+ swaps share a message within a corridor or across two — the salt carries the separation, and the tag
780
+ names the layer rather than the corridor.
781
+
782
+ **Not covered:** seed-only discovery after the swap repository is wiped. An unspent L1 HTLC reveals
783
+ too little public quote data to rediscover, so the record remains required.
784
+
785
+ **Gap-limit interaction:** every swap request — including one whose quote is refused — consumes one
786
+ index from the wallet's receive stream, and a swap index never becomes a funded receive contract,
787
+ so it looks _unused_ to a seed-only `restore()` gap scan. Many consecutive swap allocations between
788
+ two funded receive indices can therefore exceed the scan's `gapLimit` (default 20) and stop it
789
+ before later-funded addresses are found. Keep the swap repository in backups (restore then adopts
790
+ each record's descriptor via `adoptContractDescriptor`), or raise `gapLimit` on seed-only restores
791
+ after heavy swap use.
792
+
793
+ ## Upgrading from 0.0.3
794
+
795
+ 0.0.1–0.0.3 are published. Under npm's 0.0.x rules `^0.0.3` resolves to exactly 0.0.3, so nothing
796
+ auto-upgrades into the changes below — but a consumer that does upgrade meets them all in one jump,
797
+ so they are written as one migration rather than per-release fragments.
798
+
799
+ **Key provisioning moved into the SDK.** `packages/swap/src/secrets.ts` is gone. `deriveSwapSecrets`,
800
+ `randomSwapSecrets`, `preimageForRfqSecrets`, `senderIdentityForRfqSecrets`, `rfqSecretsToRecord`,
801
+ `rfqSecretsOfRecord`, `isPerSwapDescriptor`, `RFQ_PREIMAGE_TAG` and `SwapSecrets` no longer exist.
802
+ Import `provisionRefundKey`, `provisionClaimSecret`, `contractSigner`, `contractPreimage`,
803
+ `isPerArtifactDescriptor` and `ARKADE_SWAP_PREIMAGE_TAG` from `@arkade-os/sdk` instead;
804
+ `swapSecretsToRecord` and `senderIdentityForSwapRecord` stay in this package. No consumer branches
805
+ on wallet type any more, and no swap record can carry a private key.
806
+
807
+ **`contractPreimage` takes an options object.** `contractPreimage(wallet, descriptor, stored?)`
808
+ became `contractPreimage(wallet, descriptor, { stored?, salt? })`. Prefer `preimageForSwapRecord`,
809
+ which reads both fields off the record and verifies against `paymentHash`.
810
+
811
+ **Static wallets derive their preimage instead of storing it.** New records from such wallets carry
812
+ `preimageSaltHex` and no `preimageHex`; `mustPersistPreimage` is now `false` for them, so the
813
+ "persist the preimage" warning stops firing. Nothing at rest is secret unless the signer cannot sign
814
+ deterministically at all.
815
+
816
+ **`AssetSwap` gains `preimageSaltHex?`, and `AssetSwapRepository.version` is `2`.** External
817
+ repository implementations must recompile — deliberately, because a field-mapped backend that drops
818
+ `preimageSaltHex` leaves the swap unclaimable exactly as one dropping `preimageHex` does. Records
819
+ written by 0.0.1–0.0.3 need no rewrite and no migration: the field is optional, older rows resolve
820
+ through their stored `preimageHex` or their HD descriptor, and `DB_VERSION` is unchanged.
821
+
822
+ ## Breaking changes on this branch (pre-release migration notes)
823
+
824
+ Notes from before 0.0.1, kept for consumers who tracked the branch.
825
+
826
+ - **`refundIfUnresolved` reports an exited lockup, and its input gained `paymentHash`.**
827
+ `RefundOutcome` has a new `{ outcome: "exited"; outpoints; status }` variant: a lockup whose
828
+ outputs were unilaterally exited lives onchain under the VHTLC script, where no offchain refund
829
+ reaches it. It used to come back as `nothing_to_refund`, which reads as "already resolved" over
830
+ money still sitting at the script — the swept case gets `needs_recovery` for the same reason, and
831
+ this is deliberately **not** that variant: recovery into a fresh batch is a spend no batch can
832
+ make for an onchain output. Complete the unroll and spend the outputs onchain instead.
833
+
834
+ Two required changes for direct callers. The input gains **`paymentHash`** (`sha256(P)` hex — the
835
+ quote's `payment_hash`, which callers already hold): the exit is read through `readLockupFate`,
836
+ and the VHTLC script cannot supply it, since its `preimageHash` is a `hash160` of the same secret.
837
+ And the `indexer` parameter widened from `RefundIndexer` to **`LockupSpendIndexer`**, so it must
838
+ now carry `getVirtualTxs` as well as `getVtxos` — a real `RestIndexerProvider` already does.
839
+
840
+ A lockup funded in two sends of which only one exited reports `exited` for the whole thing and
841
+ leaves the live half unrefunded. That matches `RfqSwapManager`, which reports the same lockup
842
+ `exited` on the same any-output rule; the two must not disagree.
843
+ - **`RfqSwapManager` can own its own persistence.** New optional
844
+ `RfqSwapManagerDeps.repository`, new `restoreFromRepository()` and `pruneRetiredSwaps()`, and
845
+ `addSwap(swap, origin?)` gains an optional second argument. Nothing narrows and nothing is
846
+ removed, so no existing caller changes: without a repository the manager persists through
847
+ `saveSwap` exactly as before. Two things to know if you wire it. `saveSwap` becomes a **second**
848
+ sink rather than the only one — it still gates waiters and finalization, so its semantics are
849
+ unchanged, but a callback that writes the same repository by hand now double-writes and should
850
+ drop the duplicate. And `addSwap` for a swap the store has never seen throws
851
+ `RfqSwapOriginRequired` unless you pass its origin, which is the only way its first record can be
852
+ written at all.
853
+ - **`saveSwap` is optional at installation**, alongside the two claims — the same
854
+ `AvailableRfqSwapManagerCallbacks` relaxation extended one field. Omit it with a repository wired
855
+ and the record store is the only sink; omit both and state is process-local, which is what a
856
+ manager with no callbacks already did.
857
+ - **`RfqSwap` and `RfqSwapRecord` gained `lockupSpendArkTxids?: string[]`** — the ark transactions
858
+ that spent the lockup, stamped by the manager from the chain read that ended the swap. Optional
859
+ and additive: no repository version bump, and a backend storing records whole already carries it.
860
+ - **`RfqSwapState`, `RFQ_SWAP_TERMINAL_STATES` and `isRfqSwapTerminal` moved to
861
+ `src/rfqSwapState.ts`** so the record layer can read them without importing the manager at
862
+ runtime. `swapManager.ts` re-exports all three and the package entry point is unchanged, so no
863
+ import path breaks.
864
+ - **`rfqSwapOriginOf(record)` is new** — a record's immutable half on its own. A record *is* an
865
+ origin plus manager state, so passing one where an origin is wanted type-checks and quietly
866
+ carries the old `failure`, `blockedReason` and `refundArkTxid` past `managerState`, which can
867
+ only set those fields and never clear them. Use this instead of spreading the record.
868
+ - **`arkadeRefunder({ ark, indexer, wallet, repository })` ships the `refundArkade` wiring** that
869
+ was prose in two places. New export, nothing removed.
870
+ - **`rfqSwapActivityInputs({ repository, indexer })` derives `SwapActivityInput[]` from the record
871
+ store** — the correlation helper `activity.ts` promised. `SwapActivityInput["kind"]` is now
872
+ `RfqSwapRecord["kind"]` rather than a literal union repeating it; source-compatible. Corridor
873
+ handlers gained an optional `activityTxids(profile)` so a leg's own claim txid comes from the
874
+ handler instead of a kind switch. The `indexer` is optional and consulted only for what a record
875
+ cannot answer: a record predating `fundingArkTxid`, and the counterparty's spend on a swap no
876
+ refund of ours ended. An unreachable indexer costs that record its extra txids, never a throw.
877
+ - **`setCallbacks` takes `AvailableRfqSwapManagerCallbacks`** — `RfqSwapManagerCallbacks` with the
878
+ two kind-gated claims optional. Nothing breaks: the strict interface is untouched and the widened
879
+ parameter accepts every existing caller. A consumer driving one kind stops stubbing the claims it
880
+ cannot reach; in exchange, a missing claim blocks at runtime (`needs_counterparty`, non-terminal)
881
+ instead of being unrepresentable.
882
+ - **The repository interface is at version `4`.** It gained
883
+ `getRfqSwap(rfqId): Promise<RfqSwapRecord | undefined>` — every backend is already keyed by
884
+ `rfqId`, so a consumer updating one record no longer scans them all. A miss returns `undefined`;
885
+ retention prunes terminal records, so absence is ordinary. All four in-tree backends implement it
886
+ and `DB_VERSION` is unchanged; a custom implementor adds the two-line read and bumps its own
887
+ `version` to `4`.
888
+ - **`RfqSwapOrigin` gained `fundingArkTxid?`** — the ark transaction that funded the lockup. It is
889
+ origin, not manager state: the caller broadcasts the funding and knows the txid, while the manager
890
+ watches the lockup by script and never learns it. Optional and stored whole, so no migration.
891
+ Consumers stashing it in `profile` should move it: `profile` is merged as
892
+ `{ ...profile, ...handler.project(swap) }` on every write, so a key a corridor also projects is
893
+ silently overwritten.
894
+ - **`readLockupFate` names the spends it observed.** `claimed` and `returned` now carry
895
+ `spends: readonly LockupSpend[]`, one per spent lockup output, with the `checkpointTxid` that
896
+ `spentBy` names and the `arkTxid` that rode it. History correlation wants `arkTxid`; the
897
+ checkpoint txid is the wrong value to correlate on alone. `unknown` and `open` claim no spend.
898
+
899
+ - **Every derived address changed again, in both corridors — the unilateral ladder was re-spaced.**
900
+ `unilateralRefundDelay` now sits **level with** `claimDelay` instead of one 512s step above it,
901
+ and `unilateralRefundWithoutReceiverDelay` sits `SOLO_REFUND_HEADROOM_SECONDS` (4096s, newly
902
+ exported) above it instead of two steps. The old ladder spaced all three leaves one step apart as
903
+ though they were interchangeable rungs; they are not. Only `unilateralRefundWithoutReceiver` is a
904
+ solo path for the funder, so it is the only one whose timing can steal, and one 512s tick was
905
+ never enough for a claimant to complete a unilateral exit in. The two-signature refund needs no
906
+ separation at all, since neither party can spend that leaf alone. This tracks the reference
907
+ solver's [lightning-swap-service#81](https://github.com/arkade-os/lightning-swap-service/pull/81);
908
+ the two derivations must produce **the same three delay values** for the same operator, which is
909
+ what keeps the derived addresses identical. **Deployment must be coordinated** on the same terms
910
+ as the entry below: for a quote not yet funded, a mismatch refuses it at `verifyLockupAddress`
911
+ rather than losing funds.
912
+
913
+ **An in-flight lockup funded before the upgrade needs care, and the entry below understates
914
+ this.** The delays are not quote fields and are not persisted on the swap record
915
+ (`AssetSwap` keeps `swapPkScript`, not `claimDelay`), and `RfqSwap`'s own doc tells callers to
916
+ *rebuild* the script on restart from the quote's binding fields — which re-derives the delays
917
+ under whatever ladder is compiled in. So a trader who funded on `0.0.4`, upgraded, and restarted
918
+ rebuilds a **new** address, and `refundIfUnresolved` finds no VTXOs there and returns
919
+ `nothing_to_refund` — a terminal-sounding answer for money still locked at the old script, with
920
+ `refundLocktime` still ticking. Until the delays are persisted and rebuilt from the stored value,
921
+ drain in-flight lockups before upgrading, or rebuild the old script from the pre-upgrade delays
922
+ by hand. This is a pre-existing gap that any address-moving change hits, not one this change
923
+ introduces.
924
+
925
+ `unilateralClaimDelay`'s BIP68 ceiling tightened to reserve the full headroom rather than two
926
+ steps. Note this guard alone is **not** mirrored in the reference solver, which still rejects
927
+ only above `0xffff * 512`: for an operator `unilateralExitDelay` in `(33549824, 33553920]`
928
+ seconds the trader throws here while the solver quotes and then fails deeper in its own script
929
+ build. Both refuse, at different seams with different messages, so it is a diagnosability wart
930
+ rather than a fund risk — and the window is unreachable in practice (~388 days).
931
+
932
+ - **`secrets.ts` is gone; key provisioning moved into `@arkade-os/sdk`.** This package no longer
933
+ derives, mints, or names keys. It asks the SDK for what the leg needs — `provisionRefundKey(wallet)`
934
+ for a leg it funds, `provisionClaimSecret(wallet, { preimage? })` for one it claims — and
935
+ recovers with `contractSigner(wallet, descriptor)` / `contractPreimage(wallet, descriptor,
936
+ stored?)`. The returned `ProvisionedKey` / `ProvisionedClaimSecret` replace `SwapSecrets`, and
937
+ `descriptor` replaces `signingDescriptor` on them. Removed from this package with no
938
+ replacement here: `deriveSwapSecrets`, `randomSwapSecrets`, `senderPubkeyForRfqSecrets`,
939
+ `preimageForRfqSecrets`, `senderIdentityForRfqSecrets`, `isPerSwapDescriptor`, `derivePreimage`,
940
+ `buildPreimageMessage`, `RFQ_PREIMAGE_TAG`, `isDeterministicSigner`, `adoptSwapDescriptor` (now
941
+ `adoptContractDescriptor` in the SDK), `SwapSecrets` / `DerivedSwapSecrets` /
942
+ `StoredSwapSecrets`, and `rfqSecretsToRecord` / `rfqSecretsOfRecord` — persist a provisioned
943
+ secret with **`swapSecretsToRecord`** from `store` instead, and read P back with
944
+ `contractPreimage`. `RefundNotLocallyPossibleError` and `senderIdentityForSwapRecord` stay here
945
+ (now in `refundBlocked.ts`): they are swap lifecycle, not key provisioning.
946
+ - **No swap record can carry a private key.** `AssetSwap.fallbackSecrets` and the
947
+ `AssetSwapFallbackSecrets` types are deleted rather than kept readable, and `preimageHex` — set
948
+ only when the wallet reports `mustPersistPreimage` — is the record's one secret field. A record
949
+ written by 0.0.1–0.0.3 carries no `signingDescriptor`, so `senderIdentityForSwapRecord` refuses
950
+ it with `no-secrets` rather than silently mis-signing; those versions shipped before any
951
+ consumer, which is the window for doing this without a secret migration.
952
+ - **`requestLightningSend` / `requestOnchainSend` return `secrets`, not top-level raw key material.**
953
+ `senderPrivateKey` is gone from both return types; caller-owned onchain preimages live inside
954
+ `secrets` and must be persisted with the record. `pushRefundWithoutReceiver` /
955
+ `refundIfUnresolved` take `sender: Identity` instead of `senderPrivateKey: Uint8Array` — build
956
+ it from the record with `senderIdentityForSwapRecord`, which is what keeps a wallet that cannot
957
+ sign reporting `RefundNotLocallyPossibleError` rather than a `TypeError` at the push site.
958
+ `AssetSwap` gains `signingDescriptor?` and `preimageHex?`.
959
+ - **Every derived address changed, in both corridors.** The lightning-send lockup moved from the
960
+ 3-leaf program-artifact VHTLC to the 8-leaf `VHTLC.ScriptV2` (non-interactive claim and refund
961
+ leaves), and the L1 HTLC's claim leaf gained a `SIZE 32 EQUALVERIFY` preimage-length guard. Both
962
+ are pinned by golden tests (`test/rfq.test.ts`, `test/onchainHtlc.test.ts`). **Deployment must be
963
+ coordinated:** trader and solver derive the lockup independently and compare (`lockup_address` /
964
+ `htlc_address` are compare-only), so a version mismatch does not lose funds — it refuses every
965
+ quote at `verifyLockupAddress`. Upgrade both sides before expecting fills.
966
+ - **`cancelOffer` and `restoreAssetSwaps` take an options object.** `cancelOffer(wallet, url,
967
+ offerHex, { repository, fundingTxid?, swapAddress? })` — the repository is required because the
968
+ call now records its own outcome. `restoreAssetSwaps(indexer, txs, existingIds, { serverPubkey,
969
+ scanned? })` — the server key is required because a spend is classified by rebuilding the
970
+ covenant and matching the leaf it took.
971
+ - **`isCancelSpend` is gone**, replaced by `classifySpend`, and `Tx.assets` with it. The old test
972
+ read what a transaction moved, which a wallet reports as a _net_ delta: once the deposit is a
973
+ registered contract, an asset offer's cancel moves the asset out and back, nets to zero, and is
974
+ indistinguishable from a fill. Leaves have no such failure mode.
975
+ - **A spend that cannot be classified is no longer restored as `fulfilled`.** It leaves the funding
976
+ txid unanswered so a later scan decides it. Records are never written on a guess.
977
+ - **`AssetSwap` gained `signingDescriptor?`**, and `preimageHex` now means "P that cannot be
978
+ re-derived" — caller-supplied, or minted for a static descriptor. A field-mapped backend must
979
+ persist the record whole: silently dropping `preimageHex` leaves a static swap permanently
980
+ unclaimable.
981
+ - **The repository interface is at version `3`.** It gained `saveRfqSwap` / `getAllRfqSwaps` /
982
+ `removeRfqSwap` for monitored RFQ swaps, and the IndexedDB backend a matching `rfqSwaps` object
983
+ store at `DB_VERSION` 2. Version `2` was the shape 0.0.5 released — swaps, scan cursor, markets,
984
+ with `preimageSaltHex` on the swap record — and `DB_VERSION` was 1 there, so this is the database's
985
+ first version increase. The bump is deliberate: an implementor must acknowledge the new methods
986
+ rather than silently satisfy an older shape. Existing databases upgrade in place: the new store is
987
+ added and the three original ones are untouched. **`DB_VERSION` 2 is a one-way door** — a browser
988
+ whose database has upgraded cannot be rolled back to 0.0.5, which opens it at version 1 and fails
989
+ `VersionError` across the whole swap store, not just the RFQ half. Store RFQ records whole for the
990
+ same reason as above: what is in one is what nothing else can recover — the manager's own state,
991
+ and, inside the corridor's `profile`, its keys and its gates.
992
+ - **An RFQ record's keys live in its corridor's `profile`, under two keys.** `profile.signer` holds
993
+ `signingDescriptor` — which wallet key signs this leg, on any corridor. `profile.hashlock` holds
994
+ `paymentHash` (the covenant binds `hash160` of it, which is one-way) plus, **only on legs we
995
+ claim**, `preimageHex` or `preimageSaltHex`. The record's own half — `kind`, `lockupAddress`,
996
+ `amount`, the manager's state — recovers nothing on its own, so a backend that drops either nested
997
+ object loses the signer or the claim secret exactly as one dropping `preimageHex` used to. Two keys
998
+ rather than one because a hashlock belongs to a corridor and a signer does not: a corridor that
999
+ settles without a preimage still has a leg to sign and refund.
1000
+
1001
+ ```ts
1002
+ // In. One call per leg, whatever that leg's provisioning produced — never
1003
+ // hand-mapped: copying `signingDescriptor` and `preimageHex` across by hand
1004
+ // drops the salt a static wallet's P derives from, and the swap is
1005
+ // unclaimable with nothing to say so until claim time.
1006
+ const record = createRfqSwapRecord(
1007
+ {
1008
+ kind: "lightning_receive",
1009
+ lockupAddress: result.address,
1010
+ profile: {
1011
+ ...rfqSecretsProfile(result.secrets, result.treeParams.paymentHash),
1012
+ expectedAmount: result.expectedAmount,
1013
+ payoutAddress: result.payoutAddress,
1014
+ },
1015
+ },
1016
+ swap,
1017
+ );
1018
+
1019
+ // Out, and WHICH reader depends on the leg. The refund signer, on any leg:
1020
+ const sender = await senderIdentityForSwapRecord(wallet, rfqSignerOf(record)!);
1021
+ // P, only where we claim — `lightning_receive`, `onchain_send`:
1022
+ const claim = rfqClaimSecretOf(record);
1023
+ if (claim) await preimageForSwapRecord(wallet, claim); // hash-checked
1024
+ ```
1025
+
1026
+ - **`lightning_send` has a payment hash and no preimage**, so `rfqClaimSecretOf` answers `undefined`
1027
+ for it. P belongs to the payee and its descriptor is a *refund* key from `provisionRefundKey`.
1028
+ Wiring the claim helper to all three legs does not degrade gracefully: the salted arm derives
1029
+ *some* P off the refund descriptor and the payment-hash check rejects it, so a correct record reads
1030
+ as corrupt. That leg's reader is `rfqSignerOf`.
1031
+ - **Non-hashlock corridors carry no `profile.hashlock` at all** — no `paymentHash`, no preimage
1032
+ material, no placeholder; the key is simply absent, which is why `rfqSecretsProfile` takes the
1033
+ payment hash as an optional second argument. They still write `profile.signer` if their leg is one
1034
+ this wallet signs. The three corridors shipping today all lock to a preimage, but that is a fact
1035
+ about them and not about RFQ. A corridor needing more than one descriptor — a co-signed leg, a
1036
+ second key for an L1 half — extends `profile.signer` rather than fabricating a hashlock.
1037
+ - **Both readers answer `undefined` only for "this corridor has no such half", and throw on a half
1038
+ that is there and unusable.** Neither ever hands back a partial projection:
1039
+ `preimageForSwapRecord` verifies only when the projection carries a `paymentHash`, so one missing
1040
+ its hash would claim with an *unverified* preimage instead of failing. A thrown
1041
+ `PreimageNotRecoverableError("malformed-record")` is a storage bug, not a protocol state — treating
1042
+ it as "no preimage available" and falling back to a refund reads the two as the same thing.
1043
+ - **An RFQ record stores no covenant.** The tree lives in the lockup's contract row, written before
1044
+ the address could be funded and keyed by the script its params derive — a key `createContract`
1045
+ refuses to write unless they reproduce it. So the rebuild takes the params from the caller:
1046
+
1047
+ ```ts
1048
+ const params = await lockupContractParams(
1049
+ await wallet.getContractManager(),
1050
+ record.lockupAddress,
1051
+ );
1052
+ const swap = rebuildRfqSwap(record, params);
1053
+ ```
1054
+
1055
+ `lockupContractParams` throws `LockupContractMissing` when this wallet has no row for the lockup —
1056
+ a cleared contract store, or a record from elsewhere. A consumer that would rather not depend on
1057
+ the contract store can keep its own copy of
1058
+ `VHTLCV2ContractHandler.serializeParams(script.options)` and pass that instead; either way the
1059
+ params are checked against the record's `lockupAddress` before a swap is handed back, so the wrong
1060
+ row fails at restore rather than at refund time. **Superseded** for a consumer that wires
1061
+ `RfqSwapManagerDeps.repository`: `restoreFromRepository()` is this loop, over every stored
1062
+ record, with retention in front of it.
1063
+
1064
+ - **Pruning is the consumer's unless the manager holds the repository.** `shouldRetainRfqSwap(record,
1065
+ now)` answers whether a record is still worth keeping — live swaps and `needs_counterparty`
1066
+ always, terminal ones for `RFQ_SWAP_RETENTION_SECONDS` (30 days) after `updatedAt`. Sweep with it
1067
+ at boot and pass the rejects to `removeRfqSwap`; skip it and a hot wallet's `rfqSwaps` store grows
1068
+ without bound. `now` is **unix seconds**, the unit `RfqSwap.updatedAt` carries — `Date.now()` would
1069
+ retire every terminal record after ~43 minutes. **Superseded** for a consumer that wires
1070
+ `RfqSwapManagerDeps.repository`: `pruneRetiredSwaps()` is that sweep, and
1071
+ `restoreFromRepository()` runs it first.
1072
+ - **A write that gates something irreversible throws; one that follows it does not.**
1073
+ `addAssetSwap` and `updateAssetSwap` throw on a failed read or write — nothing irreversible may
1074
+ happen until the record is durable, which is why `cancelOffer` writes its `cancelling` marker
1075
+ before broadcasting. `updateAssetSwapBestEffort` is the other half: it records transitions that
1076
+ follow an irreversible action (a broadcast claim, a spent lockup), so it cannot fail the caller,
1077
+ and returns `{ swaps, persisted }` instead. `watchOfferSwaps` uses it and fires `onUpdate` only
1078
+ when `persisted` is true — the callback is documented as following a persisted change, and a
1079
+ consumer caching from it must not run ahead of the store.
1080
+ - **`lightningSendProgram` and `htlcSendProgram` are gone** along with the program-artifact layer
1081
+ they compiled. Derive scripts through `lightningSendVtxoScript` / `onchainHtlcScript`.
1082
+ - **The receive corridors are wired, and the wire shape settled.** `lightningReceiveRequest` is
1083
+ new; `onchainReceiveRequest`'s profile now matches the shipped solver schema (`payment_hash`,
1084
+ `claim_packet`, `refund_pubkey`, `payout_address`, `payout_pubkey` — the earlier
1085
+ `destination_address` / object-shaped `claim_packet` never interoperated). `sealClaimPacket`
1086
+ drops the vestigial `arkadeScript` input: the packet was never cryptographically bound to it,
1087
+ and the solver recomputes the script from its own row, so the wire carries only the ciphertext.
1088
+ `requestLightningSend` now returns `fundAmount = quote.from_amount` — the invoice plus the
1089
+ corridor's fee — and refuses quotes whose `to_amount` reprices the invoice; solvers charge
1090
+ per-corridor fees on all four pairs, and funding the bare invoice amount underfunds by exactly
1091
+ the fee.
1092
+ - **`lightningSendVtxoScript` takes two new required fields**: `senderPubkey` (the trader's VHTLC
1093
+ sender key — generate, persist, see `requestLightningSend`) and `receiverPkScript` (the solver's
1094
+ claim destination, from `profile.receiver_pk_script`). Callers that built the lockup directly
1095
+ must supply both; callers going through `requestLightningSend` are unaffected.
1096
+ - **`RfqSwapManagerCallbacks` gained a required `claimLockup`**, and `RfqSwap` a third member,
1097
+ `LightningReceiveSwap`. Required rather than optional for the same reason `claimOnchain` is: a
1098
+ receive swap monitored with nothing wired to claim it expires quietly, and a compile error is the
1099
+ right way to learn a corridor was added. A caller with only send swaps can satisfy it with a stub
1100
+ that throws. `RfqSwapActionName` gains `"claimLockup"`, so an exhaustive `switch` over it needs a
1101
+ new arm. **Superseded:** such a caller now installs `AvailableRfqSwapManagerCallbacks` and omits
1102
+ both — see above.