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