@arkade-os/swap 0.0.4 → 0.0.6

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
@@ -2,10 +2,10 @@
2
2
 
3
3
  Client-side [Arkade Intents](https://arkade.money) asset swaps: discover markets, quote and
4
4
  validate, create offers, track them, cancel them, and rebuild the whole record set from chain after
5
- a wallet restore. Framework-free TypeScript over `@arkade-os/sdk`: the core API and
6
- `InMemoryAssetSwapRepository` use no DOM and no Node-specific APIs, so they run in Node, the
7
- browser, and React Native alike. `IndexedDbAssetSwapRepository` is the one exception it needs a
8
- platform-provided or polyfilled IndexedDB.
5
+ a wallet restore. Framework-free TypeScript over `@arkade-os/sdk`: the core API uses no DOM and no
6
+ Node-specific APIs, so it runs in Node, the browser, and React Native alike. Four storage backends
7
+ ship in-memory (anywhere, nothing outlives the process), IndexedDB (browser), SQLite and Realm
8
+ (React Native, on subpath entry points) — see "Storage backends" below.
9
9
 
10
10
  ## Roles
11
11
 
@@ -100,11 +100,78 @@ arkade:BTC|asset` (quote, then take by funding an offer from layer 1).
100
100
 
101
101
  Everything the package persists — swap records, the restore-scan cursor, and the markets cache —
102
102
  goes through a single `AssetSwapRepository`, following the Arkade repository convention
103
- (versioned interface, `AsyncDisposable`, one backend per platform). Two backends ship here:
104
- `InMemoryAssetSwapRepository` and `IndexedDbAssetSwapRepository` (built on the SDK's shared
105
- IndexedDB manager, like the Boltz plugin's repositories). Construct one and pass it wherever the
106
- package asks for a repository; `discoverMarkets` also accepts none, for a one-shot uncached
107
- discovery.
103
+ (versioned interface, `AsyncDisposable`, one backend per platform). Construct one and pass it
104
+ wherever the package asks for a repository; `discoverMarkets` also accepts none, for a one-shot
105
+ uncached discovery.
106
+
107
+ ## Storage backends
108
+
109
+ | Backend | Import from | For |
110
+ | ------------------------------ | ------------------------------------- | ----------------------------------------------- |
111
+ | `InMemoryAssetSwapRepository` | `@arkade-os/swap` | tests, one-shot scripts — nothing survives exit |
112
+ | `IndexedDbAssetSwapRepository` | `@arkade-os/swap` | the browser (or a polyfilled IndexedDB) |
113
+ | `SQLiteAssetSwapRepository` | `@arkade-os/swap/repositories/sqlite` | React Native, over your SQLite driver |
114
+ | `RealmAssetSwapRepository` | `@arkade-os/swap/repositories/realm` | React Native, over your Realm instance |
115
+
116
+ Neither subpath adds a dependency: they take the SDK's structural `SQLExecutor` / `RealmLike`
117
+ handles, so you pass the database you already opened.
118
+
119
+ **Records are stored whole.** The SQLite and Realm backends serialize each record to **JSON** in a
120
+ `data` column, with only `status` / `createdAt` mapped out for querying — so a field they do not
121
+ know about survives, which is what the `quote`-shaped extension in `MIGRATION.md` relies on. JSON is
122
+ the boundary, though, and it is narrower than IndexedDB's structured clone: a `Date` in a
123
+ consumer-added field comes back an ISO **string**, a `Set` or `Map` comes back empty, and a `bigint`
124
+ makes `saveSwap` **throw**. `AssetSwap` itself is JSON-safe by design (amounts are strings); keep
125
+ your own added fields that way too.
126
+
127
+ ### SQLite
128
+
129
+ ```ts
130
+ import { SQLiteAssetSwapRepository } from "@arkade-os/swap/repositories/sqlite";
131
+ import { SQLiteWalletRepository, type SQLExecutor } from "@arkade-os/sdk/repositories/sqlite";
132
+
133
+ const db = await SQLite.openDatabaseAsync("wallet.db"); // expo-sqlite
134
+ // Build the executor ONCE and hand this same instance to every repository on
135
+ // the database: the SDK serializes transactions in a chain keyed by this
136
+ // object, so a per-repository literal splits the chain and two BEGIN
137
+ // IMMEDIATEs can interleave.
138
+ const executor: SQLExecutor = {
139
+ run: (sql, params) => db.runAsync(sql, params ?? []),
140
+ get: (sql, params) => db.getFirstAsync(sql, params ?? []),
141
+ all: (sql, params) => db.getAllAsync(sql, params ?? []),
142
+ };
143
+
144
+ const swaps = new SQLiteAssetSwapRepository(executor);
145
+ const wallet = new SQLiteWalletRepository(executor); // same instance
146
+ ```
147
+
148
+ Sharing the executor is **necessary** for that serialization, not sufficient for atomicity across
149
+ all wallet storage: it disciplines the repositories that enter the chain — this one,
150
+ `SQLiteIntentRepository`, `SQLiteVirtualTxRepository`, and the wallet repository's migration path —
151
+ and nothing else. `SQLiteWalletRepository` and `SQLiteContractRepository` still write raw, so their
152
+ writes can land inside whatever transaction happens to be open.
153
+
154
+ Three tables land in your database, prefixed `arkade_`: `arkade_asset_swaps`,
155
+ `arkade_asset_swap_scanned_txids`, `arkade_asset_swap_markets`. Pass `{ prefix: "myapp_" }` if your
156
+ app already owns those names.
157
+
158
+ ### Realm
159
+
160
+ ```ts
161
+ import Realm from "realm";
162
+ import { AssetSwapRealmSchemas, RealmAssetSwapRepository } from "@arkade-os/swap/repositories/realm";
163
+ import { ArkRealmSchemas } from "@arkade-os/sdk/repositories/realm";
164
+
165
+ const realm = await Realm.open({
166
+ schema: [...ArkRealmSchemas, ...AssetSwapRealmSchemas, ...yourOwnSchemas],
167
+ schemaVersion: YOUR_VERSION, // these schemas are new: bump yours when adding them
168
+ });
169
+ const swaps = new RealmAssetSwapRepository(realm);
170
+ ```
171
+
172
+ Three classes land in your Realm namespace: `ArkadeAssetSwap`, `ArkadeAssetSwapScannedTxid`,
173
+ `ArkadeAssetSwapMarketsCache`. Unlike SQLite there is no prefix option — a Realm schema name is
174
+ baked into the schema objects you register — so reconcile against your own models by name.
108
175
 
109
176
  ## Creating an offer
110
177
 
@@ -436,34 +503,42 @@ descriptor, which is public, and `contractSigner(wallet, descriptor)` recovers t
436
503
 
437
504
  What each swap stores, and what is recoverable:
438
505
 
439
- | Wallet answers with | Spending key | Preimage (when the leg needs one) | Secret at rest |
440
- | ------------------- | --------------------- | --------------------------------- | ----------------- |
441
- | fresh HD descriptor | re-derives from seed | derives deterministically | none |
442
- | static `tr(pubkey)` | the wallet's identity | random, stored on the record | the preimage only |
443
-
444
- The preimage split follows the **descriptor's shape**, not the wallet's type: an HD child
445
- descriptor is unique to its swap, so `sha256(sign_det(...))` is safe; a static descriptor is the
446
- same key for every swap, so a derived preimage would repeat across swaps — one solver learning its
447
- own preimage would learn every other swap's and a per-swap random preimage is stored instead.
448
- `mustPersistPreimage` says which you got. A stored preimage is the one secret at rest in the
449
- design, and it is never a private key.
506
+ | Wallet answers with | Spending key | Preimage (when the leg needs one) | Secret at rest |
507
+ | ------------------------- | --------------------- | ------------------------------------- | ----------------- |
508
+ | fresh HD descriptor | re-derives from seed | derives deterministically | none |
509
+ | static `tr(pubkey)` | the wallet's identity | derives from a public per-swap salt | none |
510
+ | a signer that cannot sign | | | |
511
+ | deterministically | the wallet's identity | random, stored on the record | the preimage only |
512
+
513
+ The preimage split follows the **descriptor's shape**, not the wallet's type. An HD child
514
+ descriptor is unique to its swap, so `sha256(sign_det(...))` over the key alone is safe. A static
515
+ descriptor is the same key for every swap, so that derivation would repeat across swaps — one
516
+ solver learning its own preimage would learn every other swap's — and the uniqueness has to come
517
+ from the message instead: the SDK mints 32 random bytes per swap, signs a **salted** message, and
518
+ stores the salt in the clear.
519
+
520
+ **The salt is not a secret.** Knowing it yields nothing without the seed, which is the whole
521
+ difference from the preimage it replaces: the record goes from carrying a per-swap _secret_ to a
522
+ per-swap _public_ value, exactly what `signingDescriptor` already is. Recoverability is unchanged
523
+ in shape — keep the record and the swap recovers from the seed.
524
+
525
+ Only a signer that cannot sign deterministically at all — an external or extension signer — still
526
+ gets a random stored preimage. `mustPersistPreimage` says which you got, and it is the only thing
527
+ to branch on. A stored preimage remains the one secret at rest in the design, and it is never a
528
+ private key.
450
529
 
451
530
  ```ts
452
531
  const swap = await requestOnchainSend(/* … */);
453
- // `swapSecretsToRecord` stores the public descriptor always, and `preimageHex`
454
- // only when the wallet said it cannot re-derive P.
532
+ // `swapSecretsToRecord` stores the public descriptor always, then whichever of
533
+ // `preimageSaltHex` (derivable) or `preimageHex` (not) the wallet produced.
455
534
  await saveSwap({ ...record, ...swapSecretsToRecord(swap.secrets) });
456
535
 
457
- // Later, from the seed plus that descriptor. Only ask for a preimage the
458
- // corridor gave us one for: a lightning send's P belongs to the payee, so
459
- // this throws on those records rather than inventing something the chain will
460
- // never match. `LIGHTNING_SEND_PAIR` is exported from this package.
461
- if (record.signingDescriptor && record.pair !== LIGHTNING_SEND_PAIR) {
462
- const preimage = await contractPreimage(
463
- wallet,
464
- record.signingDescriptor,
465
- record.preimageHex ? hex.decode(record.preimageHex) : undefined,
466
- );
536
+ // Later, from the seed plus the record's public fields. Only ask for a
537
+ // preimage the corridor gave us one for: a lightning send's P belongs to the
538
+ // payee, so this throws on those records rather than inventing something the
539
+ // chain will never match. `LIGHTNING_SEND_PAIR` is exported from this package.
540
+ if (record.pair !== LIGHTNING_SEND_PAIR) {
541
+ const preimage = await preimageForSwapRecord(wallet, record);
467
542
  }
468
543
 
469
544
  // For a refund, take the composition instead of the guard: it turns all three
@@ -481,21 +556,45 @@ terminal: the lockup stays funded and watched, a solver claim still ends the swa
481
556
  `pending`. The manager reports the same state when nothing is wired to act (`enableAutoActions:
482
557
  false`, or no callbacks) and the window has passed.
483
558
 
559
+ `preimageForSwapRecord` is the read path to wire, not a hand-rolled `contractPreimage` call: it
560
+ knows which of the record's fields are derivation inputs, and it verifies the result against
561
+ `paymentHash`. A caller that forgets to pass the salt gets a _wrong_ preimage from a wallet that can
562
+ derive, not an error — and that surfaces as an opaque script failure at claim time.
563
+
564
+ Every refusal is a `PreimageNotRecoverableError` carrying a `reason`: `no-secrets` (the record
565
+ predates the descriptor), `malformed-record`, `not-derivable` (nothing to derive from, or a key this
566
+ wallet does not hold), or `hash-mismatch` (derived, but wrong — a tampered salt or the wrong seed).
567
+ Branch on `reason`, never on message text. It is deliberately **not**
568
+ `RefundNotLocallyPossibleError`: that one means no local refund is possible and `RfqSwapManager`
569
+ reports `needs_counterparty` for it, which is a different verdict from a claim-path read failing.
570
+
484
571
  A caller-supplied preimage keeps `signingDescriptor` for the sender key and stores only
485
572
  `preimageHex` as secret material.
486
573
 
487
574
  On an HD wallet each swap **allocates** its own descriptor rather than peeking at the current one:
488
575
  two swaps sharing a descriptor derive the _identical_ preimage, so one solver learning its own
489
- preimage would learn the other swap's. (Static wallets share their one descriptor by design — that
490
- is why their preimages are stored per swap, never derived.) On restore, `adoptContractDescriptor`
576
+ preimage would learn the other swap's. (Static wallets share their one descriptor by design — the
577
+ per-swap salt is what separates their preimages instead.) On restore, `adoptContractDescriptor`
491
578
  (from `@arkade-os/sdk`) moves the wallet's watermark past a restored record's index so it cannot be
492
579
  handed out twice; a static descriptor names no index and adopts as a no-op.
493
580
 
494
- The derivation is `sha256(signSchnorrDeterministic(sha256("Arkade-RFQ-Preimage-v1" xonly(32)
495
- u32le(0))))`, mirroring NArk's Boltz scheme (`SwapsManagementService.cs:128-160`) with an
496
- RFQ-scoped tag. NArk has no RFQ corridor yet, so this tag defines the scheme rather than matching
497
- one; it is deliberately distinct from the Boltz tag so one wallet key cannot derive the same
498
- preimage for both corridors.
581
+ Two derivations, picked by the descriptor's shape:
582
+
583
+ ```
584
+ HD child sha256(sign_det(sha256("Arkade-RFQ-Preimage-v1" ‖ xonly(32) u32le(0))))
585
+ static/salted sha256(sign_det(sha256("Arkade-Contract-Preimage-Salted-v1" xonly(32) ‖ salt(32))))
586
+ ```
587
+
588
+ The first mirrors NArk's Boltz scheme (`SwapsManagementService.cs:128-160`) with an RFQ-scoped tag.
589
+ NArk has no RFQ corridor yet, so this tag defines the scheme rather than matching one; it is
590
+ deliberately distinct from the Boltz tag so one wallet key cannot derive the same preimage for both
591
+ corridors.
592
+
593
+ The salted tag is corridor-generic where the first is not, and that asymmetry is deliberate: the v1
594
+ tags must be per-corridor because v1 pins its message index, leaving the tag as the only separation
595
+ between two corridors reaching the same key. The salted form mints a fresh salt per swap, so no two
596
+ swaps share a message within a corridor or across two — the salt carries the separation, and the tag
597
+ names the layer rather than the corridor.
499
598
 
500
599
  **Not covered:** seed-only discovery after the swap repository is wiped. An unspent L1 HTLC reveals
501
600
  too little public quote data to rediscover, so the record remains required.
@@ -508,9 +607,71 @@ before later-funded addresses are found. Keep the swap repository in backups (re
508
607
  each record's descriptor via `adoptContractDescriptor`), or raise `gapLimit` on seed-only restores
509
608
  after heavy swap use.
510
609
 
610
+ ## Upgrading from 0.0.3
611
+
612
+ 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
613
+ auto-upgrades into the changes below — but a consumer that does upgrade meets them all in one jump,
614
+ so they are written as one migration rather than per-release fragments.
615
+
616
+ **Key provisioning moved into the SDK.** `packages/swap/src/secrets.ts` is gone. `deriveSwapSecrets`,
617
+ `randomSwapSecrets`, `preimageForRfqSecrets`, `senderIdentityForRfqSecrets`, `rfqSecretsToRecord`,
618
+ `rfqSecretsOfRecord`, `isPerSwapDescriptor`, `RFQ_PREIMAGE_TAG` and `SwapSecrets` no longer exist.
619
+ Import `provisionRefundKey`, `provisionClaimSecret`, `contractSigner`, `contractPreimage`,
620
+ `isPerArtifactDescriptor` and `ARKADE_SWAP_PREIMAGE_TAG` from `@arkade-os/sdk` instead;
621
+ `swapSecretsToRecord` and `senderIdentityForSwapRecord` stay in this package. No consumer branches
622
+ on wallet type any more, and no swap record can carry a private key.
623
+
624
+ **`contractPreimage` takes an options object.** `contractPreimage(wallet, descriptor, stored?)`
625
+ became `contractPreimage(wallet, descriptor, { stored?, salt? })`. Prefer `preimageForSwapRecord`,
626
+ which reads both fields off the record and verifies against `paymentHash`.
627
+
628
+ **Static wallets derive their preimage instead of storing it.** New records from such wallets carry
629
+ `preimageSaltHex` and no `preimageHex`; `mustPersistPreimage` is now `false` for them, so the
630
+ "persist the preimage" warning stops firing. Nothing at rest is secret unless the signer cannot sign
631
+ deterministically at all.
632
+
633
+ **`AssetSwap` gains `preimageSaltHex?`, and `AssetSwapRepository.version` is `2`.** External
634
+ repository implementations must recompile — deliberately, because a field-mapped backend that drops
635
+ `preimageSaltHex` leaves the swap unclaimable exactly as one dropping `preimageHex` does. Records
636
+ written by 0.0.1–0.0.3 need no rewrite and no migration: the field is optional, older rows resolve
637
+ through their stored `preimageHex` or their HD descriptor, and `DB_VERSION` is unchanged.
638
+
511
639
  ## Breaking changes on this branch (pre-release migration notes)
512
640
 
513
- The package is pre-release; these notes replace a changelog for consumers tracking the branch.
641
+ Notes from before 0.0.1, kept for consumers who tracked the branch.
642
+
643
+ - **Every derived address changed again, in both corridors — the unilateral ladder was re-spaced.**
644
+ `unilateralRefundDelay` now sits **level with** `claimDelay` instead of one 512s step above it,
645
+ and `unilateralRefundWithoutReceiverDelay` sits `SOLO_REFUND_HEADROOM_SECONDS` (4096s, newly
646
+ exported) above it instead of two steps. The old ladder spaced all three leaves one step apart as
647
+ though they were interchangeable rungs; they are not. Only `unilateralRefundWithoutReceiver` is a
648
+ solo path for the funder, so it is the only one whose timing can steal, and one 512s tick was
649
+ never enough for a claimant to complete a unilateral exit in. The two-signature refund needs no
650
+ separation at all, since neither party can spend that leaf alone. This tracks the reference
651
+ solver's [lightning-swap-service#81](https://github.com/arkade-os/lightning-swap-service/pull/81);
652
+ the two derivations must produce **the same three delay values** for the same operator, which is
653
+ what keeps the derived addresses identical. **Deployment must be coordinated** on the same terms
654
+ as the entry below: for a quote not yet funded, a mismatch refuses it at `verifyLockupAddress`
655
+ rather than losing funds.
656
+
657
+ **An in-flight lockup funded before the upgrade needs care, and the entry below understates
658
+ this.** The delays are not quote fields and are not persisted on the swap record
659
+ (`AssetSwap` keeps `swapPkScript`, not `claimDelay`), and `RfqSwap`'s own doc tells callers to
660
+ *rebuild* the script on restart from the quote's binding fields — which re-derives the delays
661
+ under whatever ladder is compiled in. So a trader who funded on `0.0.4`, upgraded, and restarted
662
+ rebuilds a **new** address, and `refundIfUnresolved` finds no VTXOs there and returns
663
+ `nothing_to_refund` — a terminal-sounding answer for money still locked at the old script, with
664
+ `refundLocktime` still ticking. Until the delays are persisted and rebuilt from the stored value,
665
+ drain in-flight lockups before upgrading, or rebuild the old script from the pre-upgrade delays
666
+ by hand. This is a pre-existing gap that any address-moving change hits, not one this change
667
+ introduces.
668
+
669
+ `unilateralClaimDelay`'s BIP68 ceiling tightened to reserve the full headroom rather than two
670
+ steps. Note this guard alone is **not** mirrored in the reference solver, which still rejects
671
+ only above `0xffff * 512`: for an operator `unilateralExitDelay` in `(33549824, 33553920]`
672
+ seconds the trader throws here while the solver quotes and then fails deeper in its own script
673
+ build. Both refuse, at different seams with different messages, so it is a diagnosability wart
674
+ rather than a fund risk — and the window is unreachable in practice (~388 days).
514
675
 
515
676
  - **`secrets.ts` is gone; key provisioning moved into `@arkade-os/sdk`.** This package no longer
516
677
  derives, mints, or names keys. It asks the SDK for what the leg needs — `provisionRefundKey(wallet)`
@@ -546,21 +546,22 @@ var relayTransport = (relayUrl, options) => {
546
546
  };
547
547
  };
548
548
  var SEQUENCE_GRANULARITY_SECONDS = 512;
549
+ var SOLO_REFUND_HEADROOM_SECONDS = 8 * SEQUENCE_GRANULARITY_SECONDS;
549
550
  var unilateralClaimDelay = (serverExitDelaySeconds) => {
550
551
  if (!Number.isFinite(serverExitDelaySeconds) || serverExitDelaySeconds < SEQUENCE_GRANULARITY_SECONDS) {
551
552
  throw new Error(
552
553
  `server exit delay must be at least ${SEQUENCE_GRANULARITY_SECONDS}s of seconds, got ${serverExitDelaySeconds}`
553
554
  );
554
555
  }
555
- if (serverExitDelaySeconds > (65535 - 2) * SEQUENCE_GRANULARITY_SECONDS) {
556
+ if (serverExitDelaySeconds > 65535 * SEQUENCE_GRANULARITY_SECONDS - SOLO_REFUND_HEADROOM_SECONDS) {
556
557
  throw new Error(
557
- `server exit delay ${serverExitDelaySeconds}s exceeds what BIP68 can encode once the two refund tiers are stacked above it`
558
+ `server exit delay ${serverExitDelaySeconds}s exceeds what BIP68 can encode once the solo refund's headroom is stacked above it`
558
559
  );
559
560
  }
560
561
  return Math.ceil(serverExitDelaySeconds / SEQUENCE_GRANULARITY_SECONDS) * SEQUENCE_GRANULARITY_SECONDS;
561
562
  };
562
- var unilateralRefundDelay = (claimDelay) => claimDelay + SEQUENCE_GRANULARITY_SECONDS;
563
- var unilateralRefundWithoutReceiverDelay = (claimDelay) => claimDelay + 2 * SEQUENCE_GRANULARITY_SECONDS;
563
+ var unilateralRefundDelay = (claimDelay) => claimDelay;
564
+ var unilateralRefundWithoutReceiverDelay = (claimDelay) => claimDelay + SOLO_REFUND_HEADROOM_SECONDS;
564
565
  function lightningSendVtxoScript(params) {
565
566
  const seconds = (value) => ({
566
567
  type: "seconds",
@@ -1169,6 +1170,7 @@ export {
1169
1170
  assertFundable,
1170
1171
  httpTransport,
1171
1172
  relayTransport,
1173
+ SOLO_REFUND_HEADROOM_SECONDS,
1172
1174
  unilateralClaimDelay,
1173
1175
  unilateralRefundDelay,
1174
1176
  unilateralRefundWithoutReceiverDelay,
@@ -0,0 +1,38 @@
1
+ // src/repository.ts
2
+ var marketsCacheKey = (network, registry) => `arkade-intents-markets-${network}-${registry}`;
3
+ var InMemoryAssetSwapRepository = class {
4
+ version = 2;
5
+ swaps = /* @__PURE__ */ new Map();
6
+ scanned = /* @__PURE__ */ new Set();
7
+ markets = /* @__PURE__ */ new Map();
8
+ async saveSwap(swap) {
9
+ this.swaps.set(swap.id, swap);
10
+ }
11
+ async getAllSwaps() {
12
+ return [...this.swaps.values()];
13
+ }
14
+ async getScannedTxids() {
15
+ return new Set(this.scanned);
16
+ }
17
+ async markTxidsScanned(txids) {
18
+ for (const txid of txids) this.scanned.add(txid);
19
+ }
20
+ async getCachedMarkets(network, registry) {
21
+ return this.markets.get(marketsCacheKey(network, registry));
22
+ }
23
+ async saveCachedMarkets(network, registry, entry) {
24
+ this.markets.set(marketsCacheKey(network, registry), entry);
25
+ }
26
+ async clear() {
27
+ this.swaps.clear();
28
+ this.scanned.clear();
29
+ this.markets.clear();
30
+ }
31
+ async [Symbol.asyncDispose]() {
32
+ }
33
+ };
34
+
35
+ export {
36
+ marketsCacheKey,
37
+ InMemoryAssetSwapRepository
38
+ };