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