@arkade-os/swap 0.0.7 → 0.0.9

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
@@ -7,6 +7,11 @@ Node-specific APIs, so it runs in Node, the browser, and React Native alike. Fou
7
7
  ship — in-memory (anywhere, nothing outlives the process), IndexedDB (browser), SQLite and Realm
8
8
  (React Native, on subpath entry points) — see "Storage backends" below.
9
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
+
10
15
  ## Roles
11
16
 
12
17
  Arkade Intents names two participants:
@@ -116,13 +121,22 @@ uncached discovery.
116
121
  Neither subpath adds a dependency: they take the SDK's structural `SQLExecutor` / `RealmLike`
117
122
  handles, so you pass the database you already opened.
118
123
 
124
+ All four carry both record types: asset swaps and the monitored RFQ swaps
125
+ (`saveRfqSwap` / `getRfqSwap` / `getAllRfqSwaps` / `removeRfqSwap`). Each keeps them in a store of their own — a
126
+ second object store on IndexedDB, an `…rfq_swaps` table on SQLite, the `ArkadeRfqSwap` class on
127
+ Realm — since the two record types have different keys and no consumer wants them interleaved.
128
+
119
129
  **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
130
+ `data` column, with only `status` / `createdAt` (and an RFQ record's `state` / `updatedAt`) mapped
131
+ out for querying — so a field they do not know about survives, which is what the `quote`-shaped
132
+ extension in `MIGRATION.md` relies on. It is also what keeps an RFQ record's corridor `profile`
133
+ intact: `profile.hashlock` is a nested object holding the payment hash and any preimage material, and
134
+ a field-mapped backend is exactly what would lose it. JSON is
122
135
  the boundary, though, and it is narrower than IndexedDB's structured clone: a `Date` in a
123
136
  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.
137
+ makes `saveSwap` **throw**. `AssetSwap` and `RfqSwapRecord` are both JSON-safe by design (amounts are
138
+ strings, binary is hex), and a corridor `profile` is plain JSON by the handler contract; keep your own
139
+ added fields — and any corridor profile you write — that way too.
126
140
 
127
141
  ### SQLite
128
142
 
@@ -169,9 +183,15 @@ const realm = await Realm.open({
169
183
  const swaps = new RealmAssetSwapRepository(realm);
170
184
  ```
171
185
 
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.
186
+ Four classes land in your Realm namespace: `ArkadeAssetSwap`, `ArkadeRfqSwap`,
187
+ `ArkadeAssetSwapScannedTxid`, `ArkadeAssetSwapMarketsCache`. Unlike SQLite there is no prefix option
188
+ — a Realm schema name is baked into the schema objects you register — so reconcile against your own
189
+ models by name.
190
+
191
+ `ArkadeRfqSwap` arrived after the other three. **If you already shipped them, add it and bump
192
+ `schemaVersion` again**: Realm creates schemas at open, so a config still listing three fails on the
193
+ first RFQ read rather than at open. SQLite needs nothing — its DDL runs `CREATE TABLE IF NOT EXISTS`
194
+ on every init, so the table appears on the next operation.
175
195
 
176
196
  ## Creating an offer
177
197
 
@@ -558,6 +578,103 @@ terminal: the lockup stays funded and watched, a solver claim still ends the swa
558
578
  `pending`. The manager reports the same state when nothing is wired to act (`enableAutoActions:
559
579
  false`, or no callbacks) and the window has passed.
560
580
 
581
+ **The two claim callbacks may be omitted.** `setCallbacks` accepts
582
+ `AvailableRfqSwapManagerCallbacks` — the full contract with `claimOnchain` and `claimLockup`
583
+ optional — so a consumer driving only lightning sends installs neither instead of stubbing them to
584
+ throw. Dispatch is already kind-gated, so neither is reachable there. `saveSwap` is optional too
585
+ (see below); `refundArkade` stays required.
586
+
587
+ `RfqSwapManagerCallbacks` itself is unchanged and still means "fully wired", so a helper taking one
588
+ and calling `claimOnchain` keeps its guarantee; only the parameter widens, which every existing
589
+ caller satisfies. What moves from compile time to runtime is bought back as a **block**: a kind
590
+ whose claim is missing reports `needs_counterparty` naming the gap, non-terminal and re-evaluated
591
+ every pass, lifted the moment `setCallbacks` supplies it. Not `failed` — `setCallbacks` is
592
+ installable late by design, and a terminal state would foreclose the late wiring this exists for.
593
+ A manager with *no* callbacks at all keeps today's manual mode on the L1 half: it reports
594
+ `claimable` and you act by hand.
595
+
596
+ **Take `arkadeRefunder` rather than assembling `refundArkade` by hand.** It composes the atomic
597
+ push and keeps the three rules the manager relies on structural instead of documented — an empty
598
+ lockup returns `null`, and both `RefundNotLocallyPossibleError` and `LockupNeedsRecoveryError`
599
+ propagate untouched.
600
+
601
+ ```ts
602
+ manager.setCallbacks({
603
+ // `repository` is how it reaches `profile.signer`: the live swap the manager
604
+ // passes carries no descriptor, so the refund key is resolved by `rfqId`.
605
+ refundArkade: arkadeRefunder({ ark, indexer, wallet, repository }),
606
+ saveSwap,
607
+ });
608
+ ```
609
+
610
+ Keep the covenant on the swap (`request*`'s `script`, as a record's `lockup`): the refund is built
611
+ from it, and a swap carrying only `lockupPkScript` is refused rather than pushed.
612
+
613
+ ### Let the manager own the records
614
+
615
+ Give `RfqSwapManager` a `repository` and it persists RFQ swaps itself — the restore loop, the
616
+ retention pass and every write, none of which a consumer has to compose:
617
+
618
+ ```ts
619
+ const manager = new RfqSwapManager({
620
+ indexer,
621
+ contracts: await wallet.getContractManager(),
622
+ repository, // any AssetSwapRepository
623
+ });
624
+ manager.setCallbacks({ refundArkade, claimLockup });
625
+
626
+ // Rebuild what was stored: retention first, then each record's covenant from
627
+ // its own contract row, then `rebuildRfqSwap`. No caller input at all.
628
+ const { restored, failed, pruned } = await manager.restoreFromRepository();
629
+ await manager.start();
630
+
631
+ // A NEW swap arrives with the request-time half a live record cannot carry.
632
+ await manager.addSwap(swap, {
633
+ kind: "lightning_send",
634
+ lockupAddress: request.lockupAddress,
635
+ profile: rfqSecretsProfile(secrets, paymentHash),
636
+ fundingArkTxid,
637
+ amount,
638
+ });
639
+ ```
640
+
641
+ That second argument is the whole point. Composing the write by hand runs into an **origin trap**:
642
+ `updateRfqSwapRecord(record, swap)` needs the record that does not exist yet, and
643
+ `createRfqSwapRecord(origin, swap)` needs request-time facts the live swap never carried — so a
644
+ swap's *first* record cannot be built from the swap alone. `addSwap`'s `origin` is where those
645
+ facts arrive, and the manager keeps them for the swap's life. Omit it and one of two things
646
+ happens: the store already holds a record, which *is* the origin, and it is read back; or it does
647
+ not, and you get `RfqSwapOriginRequired` at the door rather than an unwritable record a pass later.
648
+ An origin whose `kind` or `lockupAddress` is not this swap's is refused at that same door, for the
649
+ same reason: the write that would catch it happens a pass later, with the funding broadcast.
650
+ `start(swaps)` applies the same rule and is otherwise unchanged. Restored swaps carry their own.
651
+
652
+ `restoreFromRepository` returns three disjoint lists, and every stored record is in exactly one.
653
+ A record that cannot be rebuilt — no contract row (`LockupContractMissing`), covenant params that
654
+ do not derive the funded address, a corridor with no handler — lands in `failed` with its error and
655
+ stays in the store; it never strands the others and it is never silently dropped. `pruned` names
656
+ what retention removed: terminal and more than `RFQ_SWAP_RETENTION_SECONDS` past `updatedAt`, never
657
+ `needs_counterparty`. Retention runs first, so a retired record costs no contract lookup on its way
658
+ out; `pruneRetiredSwaps()` is public for a process that wants it on its own cadence. Pass
659
+ `{ params }` to take covenants from somewhere other than the contract store.
660
+
661
+ **Two sinks, and both gate.** With a repository wired the canonical `RfqSwapRecord` is written
662
+ first, then `saveSwap` if one is installed, and the pass counts as persisted only when both
663
+ succeeded — which is exactly today's rule for `saveSwap`, applied to whichever sinks exist. A
664
+ rejection from either leaves the record dirty and monitored, so waiters stay unsettled and a
665
+ terminal swap is not finalized until the write it claims lands. A failed canonical write skips
666
+ `saveSwap` entirely: projecting a state the record of record has just refused would put the
667
+ secondary sink ahead of the primary. If your `saveSwap` writes that same repository by hand, delete
668
+ the duplicate when you wire the dep — otherwise every pass writes twice — and keep the callback for
669
+ genuinely secondary sinks. With neither wired, state stays in memory and dies with the process.
670
+
671
+ **Terminal records name the transaction that ended them.** `RfqSwap.lockupSpendArkTxids` is
672
+ stamped from the chain read that resolved the swap — the solver's claim on a send leg, its reclaim
673
+ on a receive one, the trader's own claim when a receive settles. Nothing local produces those
674
+ transactions, so no other field can name them, and without the stamp the only way to find them is
675
+ another lockup read per terminal swap. Absent when the indexer named the checkpoint but not the ark
676
+ transaction: fewer txids beats a wrong one.
677
+
561
678
  `preimageForSwapRecord` is the read path to wire, not a hand-rolled `contractPreimage` call: it
562
679
  knows which of the record's fields are derivation inputs, and it verifies the result against
563
680
  `paymentHash`. A caller that forgets to pass the salt gets a _wrong_ preimage from a wallet that can
@@ -642,6 +759,62 @@ through their stored `preimageHex` or their HD descriptor, and `DB_VERSION` is u
642
759
 
643
760
  Notes from before 0.0.1, kept for consumers who tracked the branch.
644
761
 
762
+ - **`RfqSwapManager` can own its own persistence.** New optional
763
+ `RfqSwapManagerDeps.repository`, new `restoreFromRepository()` and `pruneRetiredSwaps()`, and
764
+ `addSwap(swap, origin?)` gains an optional second argument. Nothing narrows and nothing is
765
+ removed, so no existing caller changes: without a repository the manager persists through
766
+ `saveSwap` exactly as before. Two things to know if you wire it. `saveSwap` becomes a **second**
767
+ sink rather than the only one — it still gates waiters and finalization, so its semantics are
768
+ unchanged, but a callback that writes the same repository by hand now double-writes and should
769
+ drop the duplicate. And `addSwap` for a swap the store has never seen throws
770
+ `RfqSwapOriginRequired` unless you pass its origin, which is the only way its first record can be
771
+ written at all.
772
+ - **`saveSwap` is optional at installation**, alongside the two claims — the same
773
+ `AvailableRfqSwapManagerCallbacks` relaxation extended one field. Omit it with a repository wired
774
+ and the record store is the only sink; omit both and state is process-local, which is what a
775
+ manager with no callbacks already did.
776
+ - **`RfqSwap` and `RfqSwapRecord` gained `lockupSpendArkTxids?: string[]`** — the ark transactions
777
+ that spent the lockup, stamped by the manager from the chain read that ended the swap. Optional
778
+ and additive: no repository version bump, and a backend storing records whole already carries it.
779
+ - **`RfqSwapState`, `RFQ_SWAP_TERMINAL_STATES` and `isRfqSwapTerminal` moved to
780
+ `src/rfqSwapState.ts`** so the record layer can read them without importing the manager at
781
+ runtime. `swapManager.ts` re-exports all three and the package entry point is unchanged, so no
782
+ import path breaks.
783
+ - **`rfqSwapOriginOf(record)` is new** — a record's immutable half on its own. A record *is* an
784
+ origin plus manager state, so passing one where an origin is wanted type-checks and quietly
785
+ carries the old `failure`, `blockedReason` and `refundArkTxid` past `managerState`, which can
786
+ only set those fields and never clear them. Use this instead of spreading the record.
787
+ - **`arkadeRefunder({ ark, indexer, wallet, repository })` ships the `refundArkade` wiring** that
788
+ was prose in two places. New export, nothing removed.
789
+ - **`rfqSwapActivityInputs({ repository, indexer })` derives `SwapActivityInput[]` from the record
790
+ store** — the correlation helper `activity.ts` promised. `SwapActivityInput["kind"]` is now
791
+ `RfqSwapRecord["kind"]` rather than a literal union repeating it; source-compatible. Corridor
792
+ handlers gained an optional `activityTxids(profile)` so a leg's own claim txid comes from the
793
+ handler instead of a kind switch. The `indexer` is optional and consulted only for what a record
794
+ cannot answer: a record predating `fundingArkTxid`, and the counterparty's spend on a swap no
795
+ refund of ours ended. An unreachable indexer costs that record its extra txids, never a throw.
796
+ - **`setCallbacks` takes `AvailableRfqSwapManagerCallbacks`** — `RfqSwapManagerCallbacks` with the
797
+ two kind-gated claims optional. Nothing breaks: the strict interface is untouched and the widened
798
+ parameter accepts every existing caller. A consumer driving one kind stops stubbing the claims it
799
+ cannot reach; in exchange, a missing claim blocks at runtime (`needs_counterparty`, non-terminal)
800
+ instead of being unrepresentable.
801
+ - **The repository interface is at version `4`.** It gained
802
+ `getRfqSwap(rfqId): Promise<RfqSwapRecord | undefined>` — every backend is already keyed by
803
+ `rfqId`, so a consumer updating one record no longer scans them all. A miss returns `undefined`;
804
+ retention prunes terminal records, so absence is ordinary. All four in-tree backends implement it
805
+ and `DB_VERSION` is unchanged; a custom implementor adds the two-line read and bumps its own
806
+ `version` to `4`.
807
+ - **`RfqSwapOrigin` gained `fundingArkTxid?`** — the ark transaction that funded the lockup. It is
808
+ origin, not manager state: the caller broadcasts the funding and knows the txid, while the manager
809
+ watches the lockup by script and never learns it. Optional and stored whole, so no migration.
810
+ Consumers stashing it in `profile` should move it: `profile` is merged as
811
+ `{ ...profile, ...handler.project(swap) }` on every write, so a key a corridor also projects is
812
+ silently overwritten.
813
+ - **`readLockupFate` names the spends it observed.** `claimed` and `returned` now carry
814
+ `spends: readonly LockupSpend[]`, one per spent lockup output, with the `checkpointTxid` that
815
+ `spentBy` names and the `arkTxid` that rode it. History correlation wants `arkTxid`; the
816
+ checkpoint txid is the wrong value to correlate on alone. `unknown` and `open` claim no spend.
817
+
645
818
  - **Every derived address changed again, in both corridors — the unilateral ladder was re-spaced.**
646
819
  `unilateralRefundDelay` now sits **level with** `claimDelay` instead of one 512s step above it,
647
820
  and `unilateralRefundWithoutReceiverDelay` sits `SOLO_REFUND_HEADROOM_SECONDS` (4096s, newly
@@ -721,10 +894,100 @@ scanned? })` — the server key is required because a spend is classified by reb
721
894
  - **A spend that cannot be classified is no longer restored as `fulfilled`.** It leaves the funding
722
895
  txid unanswered so a later scan decides it. Records are never written on a guess.
723
896
  - **`AssetSwap` gained `signingDescriptor?`**, and `preimageHex` now means "P that cannot be
724
- re-derived" — caller-supplied, or minted for a static descriptor. The repository version stays
725
- `1` the package is unreleased, so there is no stored record to migrate — but a field-mapped
726
- backend must persist the record whole: silently dropping `preimageHex` leaves a static swap
727
- permanently unclaimable.
897
+ re-derived" — caller-supplied, or minted for a static descriptor. A field-mapped backend must
898
+ persist the record whole: silently dropping `preimageHex` leaves a static swap permanently
899
+ unclaimable.
900
+ - **The repository interface is at version `3`.** It gained `saveRfqSwap` / `getAllRfqSwaps` /
901
+ `removeRfqSwap` for monitored RFQ swaps, and the IndexedDB backend a matching `rfqSwaps` object
902
+ store at `DB_VERSION` 2. Version `2` was the shape 0.0.5 released — swaps, scan cursor, markets,
903
+ with `preimageSaltHex` on the swap record — and `DB_VERSION` was 1 there, so this is the database's
904
+ first version increase. The bump is deliberate: an implementor must acknowledge the new methods
905
+ rather than silently satisfy an older shape. Existing databases upgrade in place: the new store is
906
+ added and the three original ones are untouched. **`DB_VERSION` 2 is a one-way door** — a browser
907
+ whose database has upgraded cannot be rolled back to 0.0.5, which opens it at version 1 and fails
908
+ `VersionError` across the whole swap store, not just the RFQ half. Store RFQ records whole for the
909
+ same reason as above: what is in one is what nothing else can recover — the manager's own state,
910
+ and, inside the corridor's `profile`, its keys and its gates.
911
+ - **An RFQ record's keys live in its corridor's `profile`, under two keys.** `profile.signer` holds
912
+ `signingDescriptor` — which wallet key signs this leg, on any corridor. `profile.hashlock` holds
913
+ `paymentHash` (the covenant binds `hash160` of it, which is one-way) plus, **only on legs we
914
+ claim**, `preimageHex` or `preimageSaltHex`. The record's own half — `kind`, `lockupAddress`,
915
+ `amount`, the manager's state — recovers nothing on its own, so a backend that drops either nested
916
+ object loses the signer or the claim secret exactly as one dropping `preimageHex` used to. Two keys
917
+ rather than one because a hashlock belongs to a corridor and a signer does not: a corridor that
918
+ settles without a preimage still has a leg to sign and refund.
919
+
920
+ ```ts
921
+ // In. One call per leg, whatever that leg's provisioning produced — never
922
+ // hand-mapped: copying `signingDescriptor` and `preimageHex` across by hand
923
+ // drops the salt a static wallet's P derives from, and the swap is
924
+ // unclaimable with nothing to say so until claim time.
925
+ const record = createRfqSwapRecord(
926
+ {
927
+ kind: "lightning_receive",
928
+ lockupAddress: result.address,
929
+ profile: {
930
+ ...rfqSecretsProfile(result.secrets, result.treeParams.paymentHash),
931
+ expectedAmount: result.expectedAmount,
932
+ payoutAddress: result.payoutAddress,
933
+ },
934
+ },
935
+ swap,
936
+ );
937
+
938
+ // Out, and WHICH reader depends on the leg. The refund signer, on any leg:
939
+ const sender = await senderIdentityForSwapRecord(wallet, rfqSignerOf(record)!);
940
+ // P, only where we claim — `lightning_receive`, `onchain_send`:
941
+ const claim = rfqClaimSecretOf(record);
942
+ if (claim) await preimageForSwapRecord(wallet, claim); // hash-checked
943
+ ```
944
+
945
+ - **`lightning_send` has a payment hash and no preimage**, so `rfqClaimSecretOf` answers `undefined`
946
+ for it. P belongs to the payee and its descriptor is a *refund* key from `provisionRefundKey`.
947
+ Wiring the claim helper to all three legs does not degrade gracefully: the salted arm derives
948
+ *some* P off the refund descriptor and the payment-hash check rejects it, so a correct record reads
949
+ as corrupt. That leg's reader is `rfqSignerOf`.
950
+ - **Non-hashlock corridors carry no `profile.hashlock` at all** — no `paymentHash`, no preimage
951
+ material, no placeholder; the key is simply absent, which is why `rfqSecretsProfile` takes the
952
+ payment hash as an optional second argument. They still write `profile.signer` if their leg is one
953
+ this wallet signs. The three corridors shipping today all lock to a preimage, but that is a fact
954
+ about them and not about RFQ. A corridor needing more than one descriptor — a co-signed leg, a
955
+ second key for an L1 half — extends `profile.signer` rather than fabricating a hashlock.
956
+ - **Both readers answer `undefined` only for "this corridor has no such half", and throw on a half
957
+ that is there and unusable.** Neither ever hands back a partial projection:
958
+ `preimageForSwapRecord` verifies only when the projection carries a `paymentHash`, so one missing
959
+ its hash would claim with an *unverified* preimage instead of failing. A thrown
960
+ `PreimageNotRecoverableError("malformed-record")` is a storage bug, not a protocol state — treating
961
+ it as "no preimage available" and falling back to a refund reads the two as the same thing.
962
+ - **An RFQ record stores no covenant.** The tree lives in the lockup's contract row, written before
963
+ the address could be funded and keyed by the script its params derive — a key `createContract`
964
+ refuses to write unless they reproduce it. So the rebuild takes the params from the caller:
965
+
966
+ ```ts
967
+ const params = await lockupContractParams(
968
+ await wallet.getContractManager(),
969
+ record.lockupAddress,
970
+ );
971
+ const swap = rebuildRfqSwap(record, params);
972
+ ```
973
+
974
+ `lockupContractParams` throws `LockupContractMissing` when this wallet has no row for the lockup —
975
+ a cleared contract store, or a record from elsewhere. A consumer that would rather not depend on
976
+ the contract store can keep its own copy of
977
+ `VHTLCV2ContractHandler.serializeParams(script.options)` and pass that instead; either way the
978
+ params are checked against the record's `lockupAddress` before a swap is handed back, so the wrong
979
+ row fails at restore rather than at refund time. **Superseded** for a consumer that wires
980
+ `RfqSwapManagerDeps.repository`: `restoreFromRepository()` is this loop, over every stored
981
+ record, with retention in front of it.
982
+
983
+ - **Pruning is the consumer's unless the manager holds the repository.** `shouldRetainRfqSwap(record,
984
+ now)` answers whether a record is still worth keeping — live swaps and `needs_counterparty`
985
+ always, terminal ones for `RFQ_SWAP_RETENTION_SECONDS` (30 days) after `updatedAt`. Sweep with it
986
+ at boot and pass the rejects to `removeRfqSwap`; skip it and a hot wallet's `rfqSwaps` store grows
987
+ without bound. `now` is **unix seconds**, the unit `RfqSwap.updatedAt` carries — `Date.now()` would
988
+ retire every terminal record after ~43 minutes. **Superseded** for a consumer that wires
989
+ `RfqSwapManagerDeps.repository`: `pruneRetiredSwaps()` is that sweep, and
990
+ `restoreFromRepository()` runs it first.
728
991
  - **A write that gates something irreversible throws; one that follows it does not.**
729
992
  `addAssetSwap` and `updateAssetSwap` throw on a failed read or write — nothing irreversible may
730
993
  happen until the record is durable, which is why `cancelOffer` writes its `cancelling` marker
@@ -754,4 +1017,5 @@ scanned? })` — the server key is required because a spend is classified by reb
754
1017
  receive swap monitored with nothing wired to claim it expires quietly, and a compile error is the
755
1018
  right way to learn a corridor was added. A caller with only send swaps can satisfy it with a stub
756
1019
  that throws. `RfqSwapActionName` gains `"claimLockup"`, so an exhaustive `switch` over it needs a
757
- new arm.
1020
+ new arm. **Superseded:** such a caller now installs `AvailableRfqSwapManagerCallbacks` and omits
1021
+ both — see above.
@@ -6,6 +6,7 @@ import * as btc from "@scure/btc-signer";
6
6
  var ONCHAIN_ORDER_MARGIN_SECONDS = 2 * 60 * 60;
7
7
  var ONCHAIN_CLAIM_MARGIN_SECONDS = 90 * 60;
8
8
  var MAX_MIN_CONFIRMATIONS = 6;
9
+ var LOCKTIME_THRESHOLD = 5e8;
9
10
  var ONCHAIN_SECONDS_PER_BLOCK = 600;
10
11
  var ONCHAIN_DUST_SATS = BigInt(330);
11
12
  var newPreimage = () => crypto.getRandomValues(new Uint8Array(32));
@@ -25,6 +26,16 @@ function onchainHtlcScript(params, network) {
25
26
  `refundLocktime must be a positive unix timestamp, got ${params.refundLocktime}`
26
27
  );
27
28
  }
29
+ if (params.refundLocktime < LOCKTIME_THRESHOLD) {
30
+ throw new Error(
31
+ `refundLocktime ${params.refundLocktime} is below LOCKTIME_THRESHOLD (${LOCKTIME_THRESHOLD}) and would be interpreted as a block height`
32
+ );
33
+ }
34
+ if (!Object.hasOwn(L1_NETWORKS, network)) {
35
+ throw new Error(
36
+ `unknown L1 network '${String(network)}' \u2014 expected one of ${Object.keys(L1_NETWORKS).join(", ")}`
37
+ );
38
+ }
28
39
  const h160 = h160FromPaymentHash(params.paymentHash);
29
40
  const claim = btc.Script.encode([
30
41
  "SIZE",
@@ -206,6 +217,7 @@ async function classifyOnchainHtlc(chain, input) {
206
217
 
207
218
  // src/claimPacket.ts
208
219
  import { base64 } from "@scure/base";
220
+ import { gcm } from "@noble/ciphers/aes.js";
209
221
  import { secp256k1 } from "@noble/curves/secp256k1.js";
210
222
  import { hkdf } from "@noble/hashes/hkdf.js";
211
223
  import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
@@ -226,20 +238,7 @@ async function sealWithEntropy(input, ephemeralKey, nonce) {
226
238
  const sharedX = secp256k1.getSharedSecret(ephemeralKey, input.covclaimdPubkey, true).subarray(1);
227
239
  const key = hkdf(sha2562, sharedX, ephemeralPub, HKDF_INFO, 32);
228
240
  if (nonce.length !== 12) throw new Error("nonce must be 12 bytes");
229
- const aesKey = await crypto.subtle.importKey("raw", key, "AES-GCM", false, [
230
- "encrypt"
231
- ]);
232
- const sealed = new Uint8Array(
233
- await crypto.subtle.encrypt(
234
- {
235
- name: "AES-GCM",
236
- iv: nonce,
237
- additionalData: ephemeralPub
238
- },
239
- aesKey,
240
- input.preimage
241
- )
242
- );
241
+ const sealed = gcm(key, nonce, ephemeralPub).encrypt(input.preimage);
243
242
  const packet = new Uint8Array(33 + 12 + sealed.length);
244
243
  packet.set(ephemeralPub, 0);
245
244
  packet.set(nonce, 33);
@@ -249,10 +248,27 @@ async function sealWithEntropy(input, ephemeralKey, nonce) {
249
248
 
250
249
  // src/lockupContract.ts
251
250
  import { hex as hex2 } from "@scure/base";
252
- import { VHTLCV2ContractHandler } from "@arkade-os/sdk";
251
+ import {
252
+ ArkAddress,
253
+ VHTLCV2ContractHandler
254
+ } from "@arkade-os/sdk";
253
255
  var SWAP_LOCKUP_CONTRACT_TYPE = "vhtlc-v2";
254
256
  var SWAP_LOCKUP_CONTRACT_LABEL = "Arkade RFQ swap lockup";
255
257
  var SWAP_LOCKUP_CONTRACT_KIND = "rfq-swap-lockup";
258
+ var LockupContractMissing = class extends Error {
259
+ /** The lockup whose row is absent. */
260
+ address;
261
+ /** Its pkScript hex — the key the row would have been under. */
262
+ script;
263
+ constructor(address, script) {
264
+ super(
265
+ `no contract row for lockup ${address} (script ${script}); its covenant cannot be rebuilt from this wallet's contract store`
266
+ );
267
+ this.name = "LockupContractMissing";
268
+ this.address = address;
269
+ this.script = script;
270
+ }
271
+ };
256
272
  var LockupRegistrationFailed = class extends Error {
257
273
  /** The lockup address that was never registered — never fund it: nothing
258
274
  * is watching it. */
@@ -281,28 +297,28 @@ async function registerLockupContract(contracts, script, address) {
281
297
  throw new LockupRegistrationFailed(script, address, error);
282
298
  }
283
299
  }
300
+ async function lockupContractParams(contracts, lockupAddress) {
301
+ const script = hex2.encode(ArkAddress.decode(lockupAddress).pkScript);
302
+ const [row] = await contracts.getContracts({ script });
303
+ if (!row) throw new LockupContractMissing(lockupAddress, script);
304
+ return row.params;
305
+ }
284
306
 
285
307
  // src/rfq.ts
286
308
  import { hex as hex3 } from "@scure/base";
287
309
  import { ripemd160 as ripemd1602 } from "@noble/hashes/legacy.js";
288
310
  import {
289
- ArkAddress,
311
+ ArkAddress as ArkAddress2,
290
312
  RestArkProvider,
291
313
  VHTLC,
292
314
  getNetwork,
293
- resolveEmulatorPubkey
315
+ resolveEmulatorPubkey,
316
+ toXOnly
294
317
  } from "@arkade-os/sdk";
295
318
  import {
296
319
  provisionClaimSecret,
297
320
  provisionRefundKey
298
321
  } from "@arkade-os/sdk";
299
- var xOnly = (key, label) => {
300
- if (key.length === 32) return key;
301
- if (key.length !== 33 || key[0] !== 2 && key[0] !== 3) {
302
- throw new Error(`${label} is not a compressed or x-only public key`);
303
- }
304
- return key.slice(1);
305
- };
306
322
  var solverHex = (value, field) => {
307
323
  try {
308
324
  return hex3.decode(value);
@@ -647,22 +663,23 @@ async function requestLightningSend(wallet, arkServerUrl, transport, params) {
647
663
  `quote from_amount ${quote.from_amount} is below the invoice amount \u2014 a negative spread is not a quote`
648
664
  );
649
665
  }
650
- const serverPubkey = xOnly(hex3.decode(info.signerPubkey), "ark signer key");
666
+ const serverPubkey = toXOnly(hex3.decode(info.signerPubkey), "ark signer key");
651
667
  const network = getNetwork(info.network);
652
- const script = lightningSendVtxoScript({
653
- solverPubkey: xOnly(hex3.decode(quote.solver_pubkey), "solver key"),
668
+ const treeParams = {
669
+ solverPubkey: toXOnly(hex3.decode(quote.solver_pubkey), "solver key"),
654
670
  refundLocktime: quote.refund_locktime,
655
671
  serverPubkey,
656
672
  paymentHash: params.invoice.paymentHash,
657
673
  claimDelay: unilateralClaimDelay(Number(info.unilateralExitDelay)),
658
- emulatorPubkey: xOnly(
674
+ emulatorPubkey: toXOnly(
659
675
  hex3.decode(resolveEmulatorPubkey(network, params.emulatorPubkey)),
660
676
  "emulator signer key"
661
677
  ),
662
678
  senderPubkey,
663
679
  receiverPkScript: solverHex(receiverPkScriptHex, "profile.receiver_pk_script"),
664
- refundPkScript: ArkAddress.decode(refundAddress).pkScript
665
- });
680
+ refundPkScript: ArkAddress2.decode(refundAddress).pkScript
681
+ };
682
+ const script = lightningSendVtxoScript(treeParams);
666
683
  const address = script.address(network.hrp, serverPubkey).encode();
667
684
  verifyLockupAddress(quote, address);
668
685
  assertFundable({
@@ -682,7 +699,8 @@ async function requestLightningSend(wallet, arkServerUrl, transport, params) {
682
699
  script,
683
700
  refundAddress,
684
701
  senderPubkey,
685
- secrets
702
+ secrets,
703
+ treeParams
686
704
  };
687
705
  }
688
706
  var offerTermsFromQuote = (quote, assets) => {
@@ -748,7 +766,7 @@ function deriveOnchainSend(input) {
748
766
  throw new Error("onchain-send quote is missing a binding field");
749
767
  }
750
768
  const script = lightningSendVtxoScript({
751
- solverPubkey: xOnly(hex3.decode(quote.solver_pubkey), "solver key"),
769
+ solverPubkey: toXOnly(hex3.decode(quote.solver_pubkey), "solver key"),
752
770
  refundLocktime,
753
771
  serverPubkey: input.serverPubkey,
754
772
  paymentHash: input.paymentHash,
@@ -756,25 +774,25 @@ function deriveOnchainSend(input) {
756
774
  emulatorPubkey: input.emulatorPubkey,
757
775
  senderPubkey: input.senderPubkey,
758
776
  receiverPkScript: solverHex(receiverPkScriptHex, "profile.receiver_pk_script"),
759
- refundPkScript: ArkAddress.decode(input.refundAddress).pkScript
777
+ refundPkScript: ArkAddress2.decode(input.refundAddress).pkScript
760
778
  });
761
779
  const address = script.address(input.hrp, input.serverPubkey).encode();
762
780
  verifyLockupAddress(quote, address);
763
- const htlc = onchainHtlcScript(
764
- {
765
- paymentHash: input.paymentHash,
766
- claimKey: input.payoutPubkey,
767
- refundKey: xOnly(hex3.decode(htlcPubkey), "solver L1 htlc key"),
768
- refundLocktime: htlcLocktime
769
- },
770
- input.l1Network
771
- );
781
+ const htlcParams = {
782
+ paymentHash: input.paymentHash,
783
+ claimKey: input.payoutPubkey,
784
+ refundKey: toXOnly(hex3.decode(htlcPubkey), "solver L1 htlc key"),
785
+ refundLocktime: htlcLocktime
786
+ };
787
+ const htlc = onchainHtlcScript(htlcParams, input.l1Network);
772
788
  if (htlc.address !== htlcAddress) throw new AddressMismatch(htlc.address, htlcAddress);
773
789
  return {
774
790
  address,
775
791
  swapPkScript: script.pkScript,
776
792
  script,
777
793
  htlc,
794
+ htlcParams,
795
+ l1Network: input.l1Network,
778
796
  refundLocktime,
779
797
  htlcLocktime,
780
798
  minConfirmations
@@ -810,8 +828,8 @@ async function requestOnchainSend(wallet, arkServerUrl, transport, params) {
810
828
  quote,
811
829
  paymentHash,
812
830
  payoutPubkey: params.payoutPubkey,
813
- serverPubkey: xOnly(hex3.decode(info.signerPubkey), "ark signer key"),
814
- emulatorPubkey: xOnly(
831
+ serverPubkey: toXOnly(hex3.decode(info.signerPubkey), "ark signer key"),
832
+ emulatorPubkey: toXOnly(
815
833
  hex3.decode(resolveEmulatorPubkey(network, params.emulatorPubkey)),
816
834
  "emulator signer key"
817
835
  ),
@@ -844,6 +862,9 @@ async function requestOnchainSend(wallet, arkServerUrl, transport, params) {
844
862
  script: derived.script,
845
863
  refundAddress,
846
864
  htlc: derived.htlc,
865
+ htlcParams: derived.htlcParams,
866
+ l1Network: derived.l1Network,
867
+ minConfirmations: derived.minConfirmations,
847
868
  senderPubkey,
848
869
  secrets
849
870
  };
@@ -950,8 +971,8 @@ function deriveLightningReceive(input) {
950
971
  if (refundLocktime === void 0 || invoice === void 0 || solverRefundPkScriptHex === void 0) {
951
972
  throw new Error("lightning-receive quote is missing a binding field");
952
973
  }
953
- const script = receiveVtxoScript({
954
- solverPubkey: xOnly(hex3.decode(quote.solver_pubkey), "solver key"),
974
+ const treeParams = {
975
+ solverPubkey: toXOnly(hex3.decode(quote.solver_pubkey), "solver key"),
955
976
  refundLocktime,
956
977
  serverPubkey: input.serverPubkey,
957
978
  paymentHash: input.paymentHash,
@@ -959,11 +980,12 @@ function deriveLightningReceive(input) {
959
980
  emulatorPubkey: input.emulatorPubkey,
960
981
  solverRefundPkScript: solverHex(solverRefundPkScriptHex, "profile.solver_refund_pk_script"),
961
982
  payoutPubkey: input.payoutPubkey,
962
- payoutPkScript: ArkAddress.decode(input.payoutAddress).pkScript
963
- });
983
+ payoutPkScript: ArkAddress2.decode(input.payoutAddress).pkScript
984
+ };
985
+ const script = receiveVtxoScript(treeParams);
964
986
  const address = script.address(input.hrp, input.serverPubkey).encode();
965
987
  verifyLockupAddress(quote, address);
966
- return { address, swapPkScript: script.pkScript, script, invoice, refundLocktime };
988
+ return { address, swapPkScript: script.pkScript, script, invoice, refundLocktime, treeParams };
967
989
  }
968
990
  async function requestLightningReceive(wallet, arkServerUrl, transport, params) {
969
991
  const rfqId = params.rfqId ?? newRfqId();
@@ -1002,8 +1024,8 @@ async function requestLightningReceive(wallet, arkServerUrl, transport, params)
1002
1024
  paymentHash,
1003
1025
  payoutPubkey,
1004
1026
  payoutAddress,
1005
- serverPubkey: xOnly(hex3.decode(info.signerPubkey), "ark signer key"),
1006
- emulatorPubkey: xOnly(
1027
+ serverPubkey: toXOnly(hex3.decode(info.signerPubkey), "ark signer key"),
1028
+ emulatorPubkey: toXOnly(
1007
1029
  hex3.decode(resolveEmulatorPubkey(network, params.emulatorPubkey)),
1008
1030
  "emulator signer key"
1009
1031
  ),
@@ -1035,7 +1057,8 @@ async function requestLightningReceive(wallet, arkServerUrl, transport, params)
1035
1057
  script: derived.script,
1036
1058
  payoutAddress,
1037
1059
  payoutPubkey,
1038
- secrets
1060
+ secrets,
1061
+ treeParams: derived.treeParams
1039
1062
  };
1040
1063
  }
1041
1064
  function deriveOnchainReceive(input) {
@@ -1051,7 +1074,7 @@ function deriveOnchainReceive(input) {
1051
1074
  throw new Error("onchain-receive quote is missing a binding field");
1052
1075
  }
1053
1076
  const script = receiveVtxoScript({
1054
- solverPubkey: xOnly(hex3.decode(quote.solver_pubkey), "solver key"),
1077
+ solverPubkey: toXOnly(hex3.decode(quote.solver_pubkey), "solver key"),
1055
1078
  refundLocktime,
1056
1079
  serverPubkey: input.serverPubkey,
1057
1080
  paymentHash: input.paymentHash,
@@ -1059,14 +1082,14 @@ function deriveOnchainReceive(input) {
1059
1082
  emulatorPubkey: input.emulatorPubkey,
1060
1083
  solverRefundPkScript: solverHex(solverRefundPkScriptHex, "profile.solver_refund_pk_script"),
1061
1084
  payoutPubkey: input.payoutPubkey,
1062
- payoutPkScript: ArkAddress.decode(input.payoutAddress).pkScript
1085
+ payoutPkScript: ArkAddress2.decode(input.payoutAddress).pkScript
1063
1086
  });
1064
1087
  const address = script.address(input.hrp, input.serverPubkey).encode();
1065
1088
  verifyLockupAddress(quote, address);
1066
1089
  const htlc = onchainHtlcScript(
1067
1090
  {
1068
1091
  paymentHash: input.paymentHash,
1069
- claimKey: xOnly(hex3.decode(claimPubkey), "solver L1 claim key"),
1092
+ claimKey: toXOnly(hex3.decode(claimPubkey), "solver L1 claim key"),
1070
1093
  refundKey: input.refundPubkey,
1071
1094
  refundLocktime: htlcLocktime
1072
1095
  },
@@ -1122,8 +1145,8 @@ async function requestOnchainReceive(wallet, arkServerUrl, transport, params) {
1122
1145
  payoutPubkey,
1123
1146
  payoutAddress,
1124
1147
  refundPubkey: params.refundPubkey,
1125
- serverPubkey: xOnly(hex3.decode(info.signerPubkey), "ark signer key"),
1126
- emulatorPubkey: xOnly(
1148
+ serverPubkey: toXOnly(hex3.decode(info.signerPubkey), "ark signer key"),
1149
+ emulatorPubkey: toXOnly(
1127
1150
  hex3.decode(resolveEmulatorPubkey(network, params.emulatorPubkey)),
1128
1151
  "emulator signer key"
1129
1152
  ),
@@ -1164,6 +1187,7 @@ export {
1164
1187
  ONCHAIN_ORDER_MARGIN_SECONDS,
1165
1188
  ONCHAIN_CLAIM_MARGIN_SECONDS,
1166
1189
  MAX_MIN_CONFIRMATIONS,
1190
+ LOCKTIME_THRESHOLD,
1167
1191
  ONCHAIN_SECONDS_PER_BLOCK,
1168
1192
  ONCHAIN_DUST_SATS,
1169
1193
  newPreimage,
@@ -1179,8 +1203,10 @@ export {
1179
1203
  SWAP_LOCKUP_CONTRACT_TYPE,
1180
1204
  SWAP_LOCKUP_CONTRACT_LABEL,
1181
1205
  SWAP_LOCKUP_CONTRACT_KIND,
1206
+ LockupContractMissing,
1182
1207
  LockupRegistrationFailed,
1183
1208
  registerLockupContract,
1209
+ lockupContractParams,
1184
1210
  ARKADE_BTC,
1185
1211
  LIGHTNING_BTC,
1186
1212
  ONCHAIN_BTC,