@arkade-os/swap 0.0.13 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,10 +85,11 @@ funds an offer should keep cancelling within reach.
85
85
  3. **`store`** — the persisted `AssetSwap` records (`getAssetSwaps`/`addAssetSwap`/
86
86
  `updateAssetSwap`), thin helpers over an `AssetSwapRepository`. Read failures degrade to an
87
87
  empty list; write failures throw so pre-funding records can be retried before money is sent.
88
- 4. **`restore`** — `restoreAssetSwaps` rebuilds lost records by scanning sent virtual txs for
89
- offer packets and binding each funding vtxo to its spend. Incremental: answered txids are
90
- remembered in the repository (`getScannedTxids`/`markTxidsScanned`) so nothing is fetched
91
- twice.
88
+ 4. **`restore`** — `registerAssetSwapRestore` attaches durable swap recovery to an explicit
89
+ `wallet.restore()`. The underlying `restoreAssetSwapRepository` / `restoreAssetSwaps` scan sent
90
+ virtual txs for offer packets and bind each funding vtxo to its spend. The scan remains
91
+ available directly for ordinary startup and later reconciliation; answered txids are remembered
92
+ in the repository (`getScannedTxids`/`markTxidsScanned`) so nothing is fetched twice.
92
93
  5. **`watch`** — `watchOfferSwaps` drives swap status from the wallet's own contract events, so a
93
94
  fill shows up without re-running a scan. Registration is what makes it possible: only a
94
95
  registered covenant is watched. See "Live status" below.
@@ -121,6 +122,56 @@ uncached discovery.
121
122
  Neither subpath adds a dependency: they take the SDK's structural `SQLExecutor` / `RealmLike`
122
123
  handles, so you pass the database you already opened.
123
124
 
125
+ ### Restore an imported wallet
126
+
127
+ Register swap recovery before the application calls the core wallet's explicit `restore()`:
128
+
129
+ ```ts
130
+ import {
131
+ IndexedDbAssetSwapRepository,
132
+ registerAssetSwapRestore,
133
+ } from "@arkade-os/swap";
134
+
135
+ const repository = new IndexedDbAssetSwapRepository();
136
+ const unregisterSwapRestore = registerAssetSwapRestore(wallet, {
137
+ arkServerUrl,
138
+ repository,
139
+ onResult: ({ changes, coverageError }) => {
140
+ if (coverageError) console.warn("Swap coverage was incomplete", coverageError);
141
+ console.info(`Restored or updated ${changes.length} swaps`);
142
+ },
143
+ });
144
+
145
+ await wallet.restore();
146
+ ```
147
+
148
+ Core address, contract, history, and balance recovery finishes before the swap scan starts. The
149
+ helper reads the recovered wallet history, normalizes it for the scan, rebuilds durable records,
150
+ and repairs covenant coverage. Registering it again on the same wallet replaces the prior
151
+ registration through the stable `arkade-os:asset-swap` hook ID, so setup is idempotent. Call
152
+ `unregisterSwapRestore()` when that integration no longer owns the wallet.
153
+
154
+ A direct `Wallet` exposes the indexer and current Ark server key the helper needs. A proxy or
155
+ custom `IWallet` that does not expose them must pass both explicitly:
156
+
157
+ ```ts
158
+ registerAssetSwapRestore(serviceWorkerWallet, {
159
+ arkServerUrl,
160
+ repository,
161
+ indexer,
162
+ serverPubkey,
163
+ });
164
+ ```
165
+
166
+ `coverageError` is result-level: records were already persisted, so the helper still delivers the
167
+ complete result to `onResult` and a later restore can retry coverage safely. Scan, persistence, or
168
+ `onResult` failures reject the hook; `wallet.restore()` reports hook failures through its
169
+ `AggregateError` after attempting the other registered hooks.
170
+
171
+ Keep calling `restoreAssetSwapRepository` directly during ordinary startup or when history may
172
+ arrive later. Hooks run only during an explicit `wallet.restore()`, and the repository cursor makes
173
+ the manual reconciliation idempotent against records the hook already rebuilt.
174
+
124
175
  All four carry both record types: asset swaps and the monitored RFQ swaps
125
176
  (`saveRfqSwap` / `getRfqSwap` / `getAllRfqSwaps` / `removeRfqSwap`). Each keeps them in a store of their own — a
126
177
  second object store on IndexedDB, an `…rfq_swaps` table on SQLite, the `ArkadeRfqSwap` class on
@@ -319,7 +370,7 @@ message anywhere: **acceptance is funding**.
319
370
  reference solver serves the Lightning pair today.
320
371
 
321
372
  ```ts
322
- import { httpTransport, requestLightningSend } from "@arkade-os/swap";
373
+ import { httpTransport, requestLightningSend, SwapRefusal } from "@arkade-os/swap";
323
374
 
324
375
  // invoice facts from YOUR OWN decoder — the module takes facts, not a decoder
325
376
  const swap = await requestLightningSend(wallet, arkServerUrl, httpTransport(solverUrl), {
@@ -342,9 +393,21 @@ The trust model is the offer side's, applied to quotes: only `solver_pubkey`,
342
393
  parameter is the trader's own data, and anything address-shaped from the solver is compare-only
343
394
  (`AddressMismatch` means refuse-to-fund). The emulator key is neither: as above, it is a
344
395
  per-network pin inside the SDK, not solver data.
345
- Refusals carry a closed reason set (`SwapRefusal`); unknown reasons are a generic decline. The
346
- `swap-lightning-send.program.json` bytes are frozen the same way the offer programs are — a
347
- golden test pins the compiled leaves and scriptPubKey to the reference solver's exact script.
396
+ Refusals carry a closed reason set (`SwapRefusal`). A solver may also return a recognised
397
+ `error_code` with client-safe context. The error exposes these as `errorCode`, `field`, `actual`,
398
+ `expected`, `limit`, and `unit`, and includes useful numeric context in its message. Match
399
+ `errorCode` for a specific remedy while treating `reason` as the compatible fallback:
400
+
401
+ ```ts
402
+ if (error instanceof SwapRefusal && error.errorCode === "invoice_cltv_too_large") {
403
+ console.error(`Invoice CLTV is ${error.actual} blocks; solver limit is ${error.limit}`);
404
+ }
405
+ ```
406
+
407
+ Unknown diagnostic codes and fields stay generic. `RFQ_REFUSAL_ERROR_CODES` and
408
+ `isRfqRefusalErrorCode` expose the accepted vocabulary. The `swap-lightning-send.program.json`
409
+ bytes are frozen the same way the offer programs are — a golden test pins the compiled leaves and
410
+ scriptPubKey to the reference solver's exact script.
348
411
 
349
412
  Transports are symmetric-outbound: `httpTransport` (POST `/v1/swap`, GET `/v1/rfq/<rfq_id>`),
350
413
  `relayTransport` (the dev broker framing), and `nostrRfqTransport` — the production one a
@@ -342,14 +342,78 @@ var LIGHTNING_RECEIVE_PAIR = rfqPair(LIGHTNING_BTC, ARKADE_BTC);
342
342
  var ONCHAIN_SEND_PAIR = rfqPair(ARKADE_BTC, ONCHAIN_BTC);
343
343
  var ONCHAIN_RECEIVE_PAIR = rfqPair(ONCHAIN_BTC, ARKADE_BTC);
344
344
  var RFQ_TERMINAL_STATES = ["settled", "refused", "expired", "refunded", "stuck"];
345
+ var RFQ_REFUSAL_ERROR_CODES = [
346
+ "amount_side_unsupported",
347
+ "exact_out_unsupported",
348
+ "invalid_amount",
349
+ "invalid_payout_address",
350
+ "invalid_refund_address",
351
+ "invoice_amount_mismatch",
352
+ "invoice_cltv_too_large",
353
+ "invoice_malformed",
354
+ "invoice_missing_amount",
355
+ "invoice_missing_network",
356
+ "invoice_missing_payment_hash",
357
+ "invoice_missing_timestamp",
358
+ "invoice_mixed_case",
359
+ "invoice_sub_satoshi_amount",
360
+ "invoice_too_long",
361
+ "invoice_wrong_network"
362
+ ];
363
+ var isRfqRefusalErrorCode = (value) => typeof value === "string" && RFQ_REFUSAL_ERROR_CODES.includes(value);
364
+ var REFUSAL_FIELDS = /* @__PURE__ */ new Set([
365
+ "amount",
366
+ "amount_side",
367
+ "profile.invoice",
368
+ "profile.payout_address",
369
+ "profile.refund_address"
370
+ ]);
371
+ var REFUSAL_UNITS = /* @__PURE__ */ new Set(["blocks", "characters", "sats"]);
372
+ var safeInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : void 0;
373
+ var safeRefusalDetail = (detail) => {
374
+ if (!isRfqRefusalErrorCode(detail.errorCode)) return {};
375
+ return {
376
+ errorCode: detail.errorCode,
377
+ field: typeof detail.field === "string" && REFUSAL_FIELDS.has(detail.field) ? detail.field : void 0,
378
+ actual: safeInteger(detail.actual),
379
+ expected: safeInteger(detail.expected),
380
+ limit: safeInteger(detail.limit),
381
+ unit: typeof detail.unit === "string" && REFUSAL_UNITS.has(detail.unit) ? detail.unit : void 0
382
+ };
383
+ };
384
+ var refusalMessage = (reason, detail) => {
385
+ if (!detail.errorCode) return `solver refused: ${reason}`;
386
+ const where = detail.field ? ` at ${detail.field}` : "";
387
+ const unit = detail.unit ? ` ${detail.unit}` : "";
388
+ if (detail.actual !== void 0 && detail.limit !== void 0) {
389
+ return `solver refused: ${reason} (${detail.errorCode}${where}: ${detail.actual}${unit}, limit ${detail.limit})`;
390
+ }
391
+ if (detail.actual !== void 0 && detail.expected !== void 0) {
392
+ return `solver refused: ${reason} (${detail.errorCode}${where}: ${detail.actual}${unit}, expected ${detail.expected})`;
393
+ }
394
+ return `solver refused: ${reason} (${detail.errorCode}${where})`;
395
+ };
345
396
  var SwapRefusal = class extends Error {
346
397
  reason;
347
398
  rfqId;
348
- constructor(reason, rfqId) {
349
- super(`solver refused: ${reason}`);
399
+ errorCode;
400
+ field;
401
+ actual;
402
+ expected;
403
+ limit;
404
+ unit;
405
+ constructor(reason, rfqId, detail = {}) {
406
+ const safeDetail = safeRefusalDetail(detail);
407
+ super(refusalMessage(reason, safeDetail));
350
408
  this.name = "SwapRefusal";
351
409
  this.reason = reason;
352
410
  this.rfqId = rfqId;
411
+ this.errorCode = safeDetail.errorCode;
412
+ this.field = safeDetail.field;
413
+ this.actual = safeDetail.actual;
414
+ this.expected = safeDetail.expected;
415
+ this.limit = safeDetail.limit;
416
+ this.unit = safeDetail.unit;
353
417
  }
354
418
  };
355
419
  var AddressMismatch = class extends Error {
@@ -517,7 +581,20 @@ var assertFundable = (input) => {
517
581
  };
518
582
  var expectQuote = (payload, rfqId, requestedPair) => {
519
583
  const p = payload;
520
- if (p?.type === "rfq_refusal") throw new SwapRefusal(p.reason ?? "unknown", p.rfq_id ?? rfqId);
584
+ if (p?.type === "rfq_refusal") {
585
+ throw new SwapRefusal(
586
+ p.reason ?? "unknown",
587
+ p.rfq_id ?? rfqId,
588
+ safeRefusalDetail({
589
+ errorCode: p.error_code,
590
+ field: p.field,
591
+ actual: p.actual,
592
+ expected: p.expected,
593
+ limit: p.limit,
594
+ unit: p.unit
595
+ })
596
+ );
597
+ }
521
598
  if (p?.type !== "rfq_quote" || p.rfq_id !== rfqId) {
522
599
  throw new Error(`unexpected reply: ${p?.type ?? "no payload"}`);
523
600
  }
@@ -1301,6 +1378,8 @@ export {
1301
1378
  ONCHAIN_SEND_PAIR,
1302
1379
  ONCHAIN_RECEIVE_PAIR,
1303
1380
  RFQ_TERMINAL_STATES,
1381
+ RFQ_REFUSAL_ERROR_CODES,
1382
+ isRfqRefusalErrorCode,
1304
1383
  SwapRefusal,
1305
1384
  AddressMismatch,
1306
1385
  newRfqId,