@arkade-os/swap 0.1.0-rc.0 → 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -144,9 +144,25 @@ uncached discovery.
144
144
  | `IndexedDbAssetSwapRepository` | `@arkade-os/swap` | the browser (or a polyfilled IndexedDB) |
145
145
  | `SQLiteAssetSwapRepository` | `@arkade-os/swap/repositories/sqlite` | React Native, over your SQLite driver |
146
146
  | `RealmAssetSwapRepository` | `@arkade-os/swap/repositories/realm` | React Native, over your Realm instance |
147
+ | `nodeSwapRepository()` | `@arkade-os/swap/node` | Node — file-backed SQLite, opened for you |
147
148
 
148
- Neither subpath adds a dependency: they take the SDK's structural `SQLExecutor` / `RealmLike`
149
- handles, so you pass the database you already opened.
149
+ Neither React Native subpath adds a dependency: they take the SDK's structural `SQLExecutor` /
150
+ `RealmLike` handles, so you pass the database you already opened.
151
+
152
+ `@arkade-os/swap/node` is the exception, and the only entry point that imports `node:` builtins —
153
+ which is why it is a separate subpath rather than something the main entry falls back to. It opens
154
+ the database itself, under the platform config directory (XDG / `~/Library/Application Support` /
155
+ `%APPDATA%`) at `arkade/swaps/swaps-<network>.sqlite`, and it is the one backend whose disposal
156
+ closes a connection:
157
+
158
+ ```ts
159
+ import { nodeSwapRepository } from "@arkade-os/swap/node";
160
+
161
+ await using swaps = nodeSwapRepository({ network: "mainnet" }); // or { path } to choose the file
162
+ ```
163
+
164
+ Every other backend's `[Symbol.asyncDispose]` is a no-op, because you opened the handle and it is
165
+ yours to close. This one opened it, so it closes it.
150
166
 
151
167
  All four carry both record types: asset swaps and the monitored RFQ swaps
152
168
  (`saveRfqSwap` / `getRfqSwap` / `getAllRfqSwaps` / `removeRfqSwap`). Each keeps them in a store of their own — a
@@ -210,15 +226,17 @@ const realm = await Realm.open({
210
226
  const swaps = new RealmAssetSwapRepository(realm);
211
227
  ```
212
228
 
213
- Four classes land in your Realm namespace: `ArkadeAssetSwap`, `ArkadeRfqSwap`,
229
+ Five classes land in your Realm namespace: `ArkadeAssetSwap`, `ArkadeRfqSwap`, `ArkadeSwapRecord`,
214
230
  `ArkadeAssetSwapScannedTxid`, `ArkadeAssetSwapMarketsCache`. Unlike SQLite there is no prefix option
215
231
  — a Realm schema name is baked into the schema objects you register — so reconcile against your own
216
232
  models by name.
217
233
 
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.
234
+ `ArkadeRfqSwap` and `ArkadeSwapRecord` arrived after the first three. **If you already shipped an
235
+ earlier set, add the new ones and bump `schemaVersion` again**: Realm creates schemas at open, so a
236
+ config listing fewer fails on the first read of the missing one rather than at open. Spreading
237
+ `AssetSwapRealmSchemas` rather than listing names by hand is what keeps that from happening again.
238
+ SQLite needs nothing — its DDL runs `CREATE TABLE IF NOT EXISTS` on every init, so a new table
239
+ appears on the next operation.
222
240
 
223
241
  ## Creating an offer
224
242
 
@@ -9,6 +9,7 @@ var MAX_MIN_CONFIRMATIONS = 6;
9
9
  var LOCKTIME_THRESHOLD = 5e8;
10
10
  var ONCHAIN_SECONDS_PER_BLOCK = 600;
11
11
  var ONCHAIN_DUST_SATS = BigInt(330);
12
+ var ONCHAIN_CLAIM_VSIZE = 152;
12
13
  var newPreimage = () => crypto.getRandomValues(new Uint8Array(32));
13
14
  var paymentHashOf = (preimage) => hex.encode(sha256(preimage));
14
15
  var h160FromPaymentHash = (paymentHash) => ripemd160(hex.decode(paymentHash));
@@ -17,6 +18,7 @@ var L1_NETWORKS = {
17
18
  testnet: btc.TEST_NETWORK,
18
19
  regtest: { ...btc.TEST_NETWORK, bech32: "bcrt" }
19
20
  };
21
+ var l1ScriptForAddress = (address, network) => btc.OutScript.encode(btc.Address(L1_NETWORKS[network]).decode(address));
20
22
  function onchainHtlcScript(params, network) {
21
23
  if (params.claimKey.length !== 32 || params.refundKey.length !== 32) {
22
24
  throw new Error("claimKey and refundKey must be 32-byte x-only keys");
@@ -199,7 +201,11 @@ async function classifyOnchainHtlc(chain, input) {
199
201
  const best = utxos.sort((a, b) => b.amount > a.amount ? 1 : -1)[0];
200
202
  if (!best) {
201
203
  if (!input.funding) return { phase: "unfunded" };
202
- const spend = await chain.getSpendingTx(input.funding.txid, input.funding.vout);
204
+ const spend = await chain.getSpendingTx(
205
+ input.funding.txid,
206
+ input.funding.vout,
207
+ input.htlc.pkScript
208
+ );
203
209
  if (!spend) return { phase: "unfunded" };
204
210
  const preimage = extractPreimage(spend.txHex, input.htlc.paymentHash);
205
211
  const txid = btc.Transaction.fromRaw(hex.decode(spend.txHex), {
@@ -445,6 +451,10 @@ var assertFundable = (input) => {
445
451
  const fail = (reason, message) => {
446
452
  throw gateError(reason, message);
447
453
  };
454
+ if (input.quote.valid_until === void 0) {
455
+ fail("quote_malformed", "quote carries no valid_until");
456
+ }
457
+ assertFinite(input.quote.valid_until, "quote_malformed", "quote valid_until");
448
458
  if (input.invoiceExpiresAt !== void 0 && input.now >= input.invoiceExpiresAt) {
449
459
  fail("invoice_expired", "invoice expired");
450
460
  }
@@ -453,6 +463,43 @@ var assertFundable = (input) => {
453
463
  if (input.quote.refund_locktime !== void 0 && input.quote.refund_locktime - input.now < MIN_HEADROOM_SECONDS) {
454
464
  fail("insufficient_headroom", "refund deadline headroom below 90 minutes");
455
465
  }
466
+ if (input.maxFee) {
467
+ const { bps, sats, referenceRate } = input.maxFee;
468
+ if (bps === void 0 && sats === void 0) {
469
+ fail("max_fee_unbounded", "maxFee names neither bps nor sats");
470
+ }
471
+ if (bps !== void 0 && (!Number.isInteger(bps) || bps < 0 || bps > 1e4)) {
472
+ fail("max_fee_out_of_range", `maxFee.bps must be an integer in 0..10000, got ${bps}`);
473
+ }
474
+ if (sats !== void 0 && (!Number.isInteger(sats) || sats < 0)) {
475
+ fail("max_fee_out_of_range", `maxFee.sats must be a non-negative integer, got ${sats}`);
476
+ }
477
+ const legs = input.quote.pair.split("->");
478
+ const assetOf = (leg) => leg.slice(leg.indexOf(":") + 1);
479
+ const sameAsset = legs.length === 2 && assetOf(legs[0]) === assetOf(legs[1]);
480
+ if (!sameAsset && referenceRate === void 0) {
481
+ fail(
482
+ "fee_gate_unavailable",
483
+ `maxFee cannot gate ${input.quote.pair}: its legs name different assets, so from_amount - to_amount is not a fee. Supply maxFee.referenceRate (to-units per from-unit) from a source of your OWN \u2014 reading it off the solver's published feed would check the solver against its own number`
484
+ );
485
+ }
486
+ if (!sameAsset && (!Number.isFinite(referenceRate) || referenceRate <= 0)) {
487
+ fail(
488
+ "max_fee_out_of_range",
489
+ `maxFee.referenceRate must be a positive finite number, got ${referenceRate}`
490
+ );
491
+ }
492
+ const fee = sameAsset ? input.quote.from_amount - input.quote.to_amount : Math.ceil(
493
+ (input.quote.from_amount * referenceRate - input.quote.to_amount) / referenceRate
494
+ );
495
+ const allowed = Math.max(
496
+ sats ?? 0,
497
+ Math.floor(input.quote.from_amount * (bps ?? 0) / 1e4)
498
+ );
499
+ if (fee > allowed) {
500
+ fail("fee_too_high", `fee ${fee} exceeds the ${allowed} this client allows`);
501
+ }
502
+ }
456
503
  if (input.onchain) {
457
504
  const { htlcLocktime, minConfirmations, direction } = input.onchain;
458
505
  if (!Number.isInteger(minConfirmations) || minConfirmations < 1 || minConfirmations > MAX_MIN_CONFIRMATIONS) {
@@ -649,6 +696,43 @@ function lightningSendContract(params) {
649
696
  }
650
697
  });
651
698
  }
699
+ function deriveLightningSend(input) {
700
+ const { quote } = input;
701
+ if (quote.refund_locktime === void 0) {
702
+ throw new Error("lightning-send quote is missing refund_locktime");
703
+ }
704
+ const receiverPkScriptHex = quote.profile?.receiver_pk_script;
705
+ if (receiverPkScriptHex === void 0) {
706
+ throw new Error("lightning-send quote is missing profile.receiver_pk_script");
707
+ }
708
+ const contractParams = {
709
+ solverPubkey: toXOnly(hex3.decode(quote.solver_pubkey), "solver key"),
710
+ refundLocktime: quote.refund_locktime,
711
+ operatorPubkey: input.operatorPubkey,
712
+ paymentHash: input.paymentHash,
713
+ claimDelay: input.claimDelay,
714
+ emulatorPubkey: input.emulatorPubkey,
715
+ senderPubkey: input.senderPubkey,
716
+ receiverPkScript: solverHex(receiverPkScriptHex, "profile.receiver_pk_script"),
717
+ refundPkScript: input.refundPkScript
718
+ };
719
+ const matched = matchQuotedLockup(
720
+ quote,
721
+ input.hrp,
722
+ input.operatorPubkey,
723
+ (legacy) => lightningSendContract({ ...contractParams, ...legacy !== void 0 && { legacy } })
724
+ );
725
+ return {
726
+ address: matched.address,
727
+ swapPkScript: matched.script.pkScript,
728
+ script: matched.script,
729
+ contractParams: {
730
+ ...contractParams,
731
+ ...matched.legacy !== void 0 && { legacy: matched.legacy }
732
+ },
733
+ refundLocktime: quote.refund_locktime
734
+ };
735
+ }
652
736
  async function requestLightningSend(wallet, transport, params) {
653
737
  const rfqId = params.rfqId ?? newRfqId();
654
738
  const secrets = await provisionRefundKey(wallet);
@@ -658,13 +742,6 @@ async function requestLightningSend(wallet, transport, params) {
658
742
  const quote = await transport.requestQuote(
659
743
  lightningSendRequest({ rfqId, invoice: params.invoice.raw, refundAddress, senderPubkey })
660
744
  );
661
- if (quote.refund_locktime === void 0) {
662
- throw new Error("lightning-send quote is missing refund_locktime");
663
- }
664
- const receiverPkScriptHex = quote.profile?.receiver_pk_script;
665
- if (receiverPkScriptHex === void 0) {
666
- throw new Error("lightning-send quote is missing profile.receiver_pk_script");
667
- }
668
745
  if (quote.to_amount !== params.invoice.amountSats) {
669
746
  throw new Error(
670
747
  `quote to_amount ${quote.to_amount} does not match the invoice's ${params.invoice.amountSats}`
@@ -677,32 +754,20 @@ async function requestLightningSend(wallet, transport, params) {
677
754
  }
678
755
  const operatorPubkey = toXOnly(hex3.decode(info.signerPubkey), "ark signer key");
679
756
  const network = networkFromArkadeInfo(info);
680
- const contractParams = {
681
- solverPubkey: toXOnly(hex3.decode(quote.solver_pubkey), "solver key"),
682
- refundLocktime: quote.refund_locktime,
683
- operatorPubkey,
757
+ const derived = deriveLightningSend({
758
+ quote,
684
759
  paymentHash: params.invoice.paymentHash,
685
- claimDelay: unilateralClaimDelay(Number(info.unilateralExitDelay)),
760
+ senderPubkey,
761
+ refundPkScript: secrets.pkScript,
762
+ operatorPubkey,
686
763
  emulatorPubkey: toXOnly(
687
764
  hex3.decode(resolveEmulatorPubkey(network, params.emulatorPubkey)),
688
765
  "emulator signer key"
689
766
  ),
690
- senderPubkey,
691
- receiverPkScript: solverHex(receiverPkScriptHex, "profile.receiver_pk_script"),
692
- refundPkScript: secrets.pkScript
693
- };
694
- const matched = matchQuotedLockup(
695
- quote,
696
- network.hrp,
697
- operatorPubkey,
698
- (legacy) => lightningSendContract({ ...contractParams, ...legacy !== void 0 && { legacy } })
699
- );
700
- const script = matched.script;
701
- const address = matched.address;
702
- const matchedContractParams = {
703
- ...contractParams,
704
- ...matched.legacy !== void 0 && { legacy: matched.legacy }
705
- };
767
+ claimDelay: unilateralClaimDelay(Number(info.unilateralExitDelay)),
768
+ hrp: network.hrp
769
+ });
770
+ const { address, script, contractParams } = derived;
706
771
  assertFundable({
707
772
  quote,
708
773
  invoiceExpiresAt: params.invoice.expiresAt,
@@ -716,12 +781,12 @@ async function requestLightningSend(wallet, transport, params) {
716
781
  // What the lockup must carry: the quote's `from_amount` — the invoice
717
782
  // PLUS the corridor's fee, never the bare invoice amount.
718
783
  fundAmount: quote.from_amount,
719
- swapPkScript: script.pkScript,
784
+ swapPkScript: derived.swapPkScript,
720
785
  script,
721
786
  refundAddress,
722
787
  senderPubkey,
723
788
  secrets,
724
- contractParams: matchedContractParams
789
+ contractParams
725
790
  };
726
791
  }
727
792
  var offerTermsFromQuote = (quote, assets) => {
@@ -848,6 +913,7 @@ async function requestOnchainSend(wallet, transport, params) {
848
913
  amountSide: params.amountSide
849
914
  })
850
915
  );
916
+ assertQuotedAmount(quote, params.amountSide, params.amount);
851
917
  const network = networkFromArkadeInfo(info);
852
918
  const derived = deriveOnchainSend({
853
919
  quote,
@@ -1234,8 +1300,11 @@ export {
1234
1300
  LOCKTIME_THRESHOLD,
1235
1301
  ONCHAIN_SECONDS_PER_BLOCK,
1236
1302
  ONCHAIN_DUST_SATS,
1303
+ ONCHAIN_CLAIM_VSIZE,
1237
1304
  newPreimage,
1238
1305
  paymentHashOf,
1306
+ L1_NETWORKS,
1307
+ l1ScriptForAddress,
1239
1308
  onchainHtlcScript,
1240
1309
  buildHtlcClaim,
1241
1310
  buildHtlcRefund,
@@ -1281,6 +1350,7 @@ export {
1281
1350
  lightningSendContract,
1282
1351
  requestLightningSend,
1283
1352
  offerTermsFromQuote,
1353
+ l1NetworkFromArk,
1284
1354
  onchainSendRequest,
1285
1355
  lightningReceiveRequest,
1286
1356
  onchainReceiveRequest,
@@ -1,8 +1,9 @@
1
1
  // src/repository.ts
2
2
  var marketsCacheKey = (network, registry) => `arkade-intents-markets-${network}-${registry}`;
3
3
  var InMemoryAssetSwapRepository = class {
4
- version = 4;
4
+ version = 5;
5
5
  swaps = /* @__PURE__ */ new Map();
6
+ records = /* @__PURE__ */ new Map();
6
7
  rfqSwaps = /* @__PURE__ */ new Map();
7
8
  scanned = /* @__PURE__ */ new Set();
8
9
  markets = /* @__PURE__ */ new Map();
@@ -24,6 +25,18 @@ var InMemoryAssetSwapRepository = class {
24
25
  async removeRfqSwap(rfqId) {
25
26
  this.rfqSwaps.delete(rfqId);
26
27
  }
28
+ async saveSwapRecord(record) {
29
+ this.records.set(record.id, record);
30
+ }
31
+ async getSwapRecord(id) {
32
+ return this.records.get(id);
33
+ }
34
+ async getAllSwapRecords() {
35
+ return [...this.records.values()];
36
+ }
37
+ async removeSwapRecord(id) {
38
+ this.records.delete(id);
39
+ }
27
40
  async getScannedTxids() {
28
41
  return new Set(this.scanned);
29
42
  }
@@ -39,6 +52,7 @@ var InMemoryAssetSwapRepository = class {
39
52
  async clear() {
40
53
  this.swaps.clear();
41
54
  this.rfqSwaps.clear();
55
+ this.records.clear();
42
56
  this.scanned.clear();
43
57
  this.markets.clear();
44
58
  }