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