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

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/dist/index.js CHANGED
@@ -38,9 +38,10 @@ import {
38
38
  deriveOnchainSend,
39
39
  extractPreimage,
40
40
  httpTransport,
41
+ lightningReceiveContract,
41
42
  lightningReceiveRequest,
43
+ lightningSendContract,
42
44
  lightningSendRequest,
43
- lightningSendVtxoScript,
44
45
  lockupContractParams,
45
46
  newPreimage,
46
47
  newRfqId,
@@ -49,7 +50,6 @@ import {
49
50
  onchainReceiveRequest,
50
51
  onchainSendRequest,
51
52
  paymentHashOf,
52
- receiveVtxoScript,
53
53
  registerLockupContract,
54
54
  relayTransport,
55
55
  requestLightningReceive,
@@ -63,7 +63,7 @@ import {
63
63
  unilateralRefundWithoutReceiverDelay,
64
64
  verifyLockupAddress,
65
65
  verifyReceiveInvoice
66
- } from "./chunk-5NPYNQ5V.js";
66
+ } from "./chunk-AM3NNUMR.js";
67
67
  import {
68
68
  InMemoryAssetSwapRepository,
69
69
  marketsCacheKey
@@ -74,11 +74,9 @@ import { hex as hex2 } from "@scure/base";
74
74
  import { concatBytes } from "@scure/btc-signer/utils.js";
75
75
  import {
76
76
  ArkAddress,
77
- RestArkProvider,
78
- RestIndexerProvider,
79
77
  arkade,
80
78
  asset,
81
- getNetwork,
79
+ networkFromArkadeInfo,
82
80
  resolveEmulatorPubkey,
83
81
  toXOnlySignerHex
84
82
  } from "@arkade-os/sdk";
@@ -329,32 +327,51 @@ var swapPrograms = {
329
327
  wantAsset: arkade.parseArtifact(swap_want_asset_program_default),
330
328
  wantBtc: arkade.parseArtifact(swap_want_btc_program_default)
331
329
  };
332
- function swapProgramBinding(offer, serverPubkey) {
330
+ function withExitClosure(program, exit) {
331
+ if (!exit) return program;
332
+ return {
333
+ ...program,
334
+ // typed params are authoritative: an undeclared `$exitDelay` fails
335
+ // validateProgram instead of compiling against an unbound value
336
+ params: [...program.params ?? [], { name: "exitDelay", type: "int" }],
337
+ functions: {
338
+ ...program.functions,
339
+ exit: {
340
+ tapscript: { signers: ["$user"], csv: { type: exit.type, value: "$exitDelay" } }
341
+ }
342
+ }
343
+ };
344
+ }
345
+ function swapProgramBinding(offer, operatorPubkey) {
333
346
  if (offer.makerPkScript.length !== FIELDS.makerPkScript.width) {
334
347
  throw new Error("makerPkScript is not a 34-byte taproot scriptPubKey");
335
348
  }
336
349
  return {
337
- program: offer.wantAsset ? swapPrograms.wantAsset : swapPrograms.wantBtc,
350
+ program: withExitClosure(
351
+ offer.wantAsset ? swapPrograms.wantAsset : swapPrograms.wantBtc,
352
+ offer.exitDelay
353
+ ),
338
354
  args: {
339
355
  makerWP: offer.makerPkScript.subarray(2),
340
356
  wantAmount: offer.wantAmount,
341
- server: serverPubkey,
357
+ server: operatorPubkey,
342
358
  user: offer.makerPublicKey,
343
359
  // internal byte order
344
360
  ...offer.wantAsset && {
345
361
  wantAssetTxid: offer.wantAsset.txid.slice().reverse(),
346
362
  wantAssetGroupIndex: offer.wantAsset.groupIndex
347
- }
363
+ },
364
+ ...offer.exitDelay && { exitDelay: offer.exitDelay.value }
348
365
  },
349
366
  keys: {
350
- serverKey: serverPubkey,
367
+ serverKey: operatorPubkey,
351
368
  userKey: offer.makerPublicKey,
352
369
  emulatorKey: offer.emulatorPubkey
353
370
  }
354
371
  };
355
372
  }
356
- function offerVtxoScript(offer, serverPubkey) {
357
- const { program, args, keys } = swapProgramBinding(offer, serverPubkey);
373
+ function offerContract(offer, operatorPubkey) {
374
+ const { program, args, keys } = swapProgramBinding(offer, operatorPubkey);
358
375
  return new arkade.ArkadeProgramScript(program, args, keys);
359
376
  }
360
377
  var OFFER_PACKET_TYPE = 3;
@@ -365,9 +382,27 @@ var FIELDS = {
365
382
  makerPkScript: { tag: 5, width: 34 },
366
383
  makerPublicKey: { tag: 7, width: 32 },
367
384
  emulatorPubkey: { tag: 8, width: 32 },
368
- offerAsset: { tag: 11, width: void 0 }
385
+ ratioNum: { tag: 9, width: 8 },
386
+ ratioDen: { tag: 10, width: 8 },
387
+ offerAsset: { tag: 11, width: void 0 },
388
+ exitTimelock: { tag: 12, width: 9 }
369
389
  };
390
+ var EXIT_TYPES = ["blocks", "seconds"];
370
391
  var NAMES = Object.fromEntries(Object.entries(FIELDS).map(([k, f]) => [f.tag, k]));
392
+ function u64(name, value) {
393
+ if (value < BigInt(0) || value >> BigInt(64) > BigInt(0)) {
394
+ throw new Error(`${name} does not fit the offer wire format (u64)`);
395
+ }
396
+ const out = new Uint8Array(FIELDS.wantAmount.width);
397
+ new DataView(out.buffer).setBigUint64(0, value, false);
398
+ return out;
399
+ }
400
+ var readU64 = (value) => new DataView(value.buffer, value.byteOffset).getBigUint64(0, false);
401
+ var setRatio = (name, value) => {
402
+ if (value === void 0 || value === BigInt(0)) return void 0;
403
+ if (value < BigInt(0)) throw new Error(`${name} does not fit the offer wire format (u64)`);
404
+ return value;
405
+ };
371
406
  function tlv(type, value) {
372
407
  if (value.length > 65535) throw new Error("TLV value exceeds the u16 length field");
373
408
  return concatBytes(Uint8Array.of(type, value.length >> 8 & 255, value.length & 255), value);
@@ -386,24 +421,45 @@ function encodeOffer(offer) {
386
421
  throw new Error(`${name} must be ${FIELDS[name].width} bytes`);
387
422
  }
388
423
  }
389
- if (offer.wantAmount < BigInt(0) || offer.wantAmount >> BigInt(64) > BigInt(0)) {
390
- throw new Error("wantAmount does not fit the offer wire format (u64)");
424
+ const ratioNum = setRatio("ratioNum", offer.ratioNum);
425
+ const ratioDen = setRatio("ratioDen", offer.ratioDen);
426
+ if (ratioNum === void 0 !== (ratioDen === void 0)) {
427
+ throw new Error("offer must carry both ratioNum and ratioDen, or neither");
391
428
  }
392
- const amount = new Uint8Array(FIELDS.wantAmount.width);
393
- new DataView(amount.buffer).setBigUint64(0, offer.wantAmount, false);
394
429
  const recs = [
395
430
  tlv(FIELDS.swapPkScript.tag, offer.swapPkScript),
396
- tlv(FIELDS.wantAmount.tag, amount)
431
+ tlv(FIELDS.wantAmount.tag, u64("wantAmount", offer.wantAmount))
397
432
  ];
398
433
  if (offer.wantAsset) recs.push(tlv(FIELDS.wantAsset.tag, offer.wantAsset.serialize()));
434
+ if (ratioNum !== void 0) recs.push(tlv(FIELDS.ratioNum.tag, u64("ratioNum", ratioNum)));
435
+ if (ratioDen !== void 0) recs.push(tlv(FIELDS.ratioDen.tag, u64("ratioDen", ratioDen)));
399
436
  if (offer.offerAsset) recs.push(tlv(FIELDS.offerAsset.tag, offer.offerAsset.serialize()));
400
437
  recs.push(
401
438
  tlv(FIELDS.makerPkScript.tag, offer.makerPkScript),
402
439
  tlv(FIELDS.makerPublicKey.tag, offer.makerPublicKey),
403
440
  tlv(FIELDS.emulatorPubkey.tag, offer.emulatorPubkey)
404
441
  );
442
+ if (offer.exitDelay) recs.push(tlv(FIELDS.exitTimelock.tag, encodeExitDelay(offer.exitDelay)));
405
443
  return concatBytes(...recs);
406
444
  }
445
+ function encodeExitDelay(exit) {
446
+ return concatBytes(
447
+ Uint8Array.of(EXIT_TYPES.indexOf(assertExitDelay(exit).type)),
448
+ u64("exitDelay", exit.value)
449
+ );
450
+ }
451
+ function assertExitDelay(exit) {
452
+ if (EXIT_TYPES.indexOf(exit.type) < 0) {
453
+ throw new Error(`unknown exitDelay locktime type: ${exit.type}`);
454
+ }
455
+ if (exit.value <= BigInt(0)) {
456
+ throw new Error("exitDelay must be a positive relative locktime");
457
+ }
458
+ if (exit.value >> BigInt(32) > BigInt(0)) {
459
+ throw new Error("exitDelay does not fit the locktime field (u32)");
460
+ }
461
+ return exit;
462
+ }
407
463
  function decodeOffer(data) {
408
464
  const fields = {};
409
465
  let off = 0;
@@ -423,40 +479,70 @@ function decodeOffer(data) {
423
479
  for (const name of ["wantAsset", "offerAsset"]) {
424
480
  if (fields[name]?.length === 0) throw new Error(`missing/invalid ${name}`);
425
481
  }
426
- const need = (name) => {
482
+ for (const [name, value] of Object.entries(fields)) {
483
+ const width = FIELDS[name].width;
484
+ if (width !== void 0 && value.length !== width) {
485
+ throw new Error(`missing/invalid ${name}`);
486
+ }
487
+ }
488
+ const need2 = (name) => {
427
489
  const v = fields[name];
428
490
  const len = FIELDS[name].width;
429
491
  if (!v || len !== void 0 && v.length !== len)
430
492
  throw new Error(`missing/invalid ${name}`);
431
493
  return v;
432
494
  };
433
- const amount = need("wantAmount");
495
+ const amount = need2("wantAmount");
434
496
  if (Boolean(fields.wantAsset) === Boolean(fields.offerAsset)) {
435
497
  throw new Error("offer must carry exactly one of wantAsset or offerAsset");
436
498
  }
499
+ const readRatio = (name) => {
500
+ const raw = fields[name];
501
+ if (!raw) return void 0;
502
+ const value = readU64(raw);
503
+ if (value === BigInt(0)) throw new Error(`missing/invalid ${name}`);
504
+ return value;
505
+ };
506
+ const ratioNum = readRatio("ratioNum");
507
+ const ratioDen = readRatio("ratioDen");
508
+ if (ratioNum === void 0 !== (ratioDen === void 0)) {
509
+ throw new Error("offer must carry both ratioNum and ratioDen, or neither");
510
+ }
437
511
  return {
438
- swapPkScript: need("swapPkScript"),
439
- wantAmount: new DataView(amount.buffer, amount.byteOffset).getBigUint64(0, false),
512
+ swapPkScript: need2("swapPkScript"),
513
+ wantAmount: readU64(amount),
440
514
  ...fields.wantAsset && { wantAsset: asset.AssetId.fromBytes(fields.wantAsset) },
441
515
  ...fields.offerAsset && { offerAsset: asset.AssetId.fromBytes(fields.offerAsset) },
442
- makerPkScript: need("makerPkScript"),
443
- makerPublicKey: need("makerPublicKey"),
444
- emulatorPubkey: need("emulatorPubkey")
516
+ makerPkScript: need2("makerPkScript"),
517
+ makerPublicKey: need2("makerPublicKey"),
518
+ emulatorPubkey: need2("emulatorPubkey"),
519
+ ...ratioNum !== void 0 && { ratioNum },
520
+ ...ratioDen !== void 0 && { ratioDen },
521
+ ...fields.exitTimelock && { exitDelay: decodeExitDelay(fields.exitTimelock) }
445
522
  };
446
523
  }
524
+ function decodeExitDelay(value) {
525
+ const type = EXIT_TYPES[value[0]];
526
+ if (!type) throw new Error(`unknown exitDelay locktime type: 0x${value[0].toString(16)}`);
527
+ return { type, value: readU64(value.subarray(1)) };
528
+ }
447
529
  var OFFER_CONTRACT_LABEL = "Arkade swap offer";
448
530
  var OFFER_CONTRACT_KIND = "asset-swap-offer";
449
- async function registerOfferContract(wallet, arkServerUrl, network, binding, serverPubkey, expectedPkScript) {
450
- const { program, args, keys } = swapProgramBinding(binding, serverPubkey);
531
+ async function registerOfferContract(wallet, info, binding, operatorPubkey, expectedPkScript) {
532
+ const { program, args, keys } = swapProgramBinding(binding, operatorPubkey);
451
533
  const contractManager = await wallet.getContractManager();
452
534
  const client = await arkade.Arkade.connect({
453
- arkade: new RestArkProvider(arkServerUrl),
454
- indexer: new RestIndexerProvider(arkServerUrl),
535
+ // Registration derives and persists; it never broadcasts and never
536
+ // reads UTXOs, so the only thing the client needs off the server is the
537
+ // info the caller already resolved. Handing that back — rather than a
538
+ // provider built from a URL — is what lets `createOffer` take just a
539
+ // wallet, and spares a second `/v1/info` round-trip.
540
+ arkade: { getInfo: async () => info },
455
541
  identity: wallet.identity,
456
542
  // without this the row's `address` would be derived against the SDK's
457
543
  // default network while its script is right — a row that disagrees with
458
544
  // the address the user is about to fund
459
- network: getNetwork(network),
545
+ network: networkFromArkadeInfo(info),
460
546
  contractManager
461
547
  });
462
548
  const contract = new arkade.ArkadeContract(client, program, args, keys);
@@ -469,17 +555,29 @@ async function registerOfferContract(wallet, arkServerUrl, network, binding, ser
469
555
  });
470
556
  await promoteOfferContract(contractManager, hex2.encode(expectedPkScript));
471
557
  }
472
- async function createOffer(wallet, arkServerUrl, params) {
558
+ function serverExitDelay(delay) {
559
+ if (typeof delay !== "bigint" || delay <= BigInt(0)) {
560
+ throw new Error(
561
+ "the server reports no usable unilateralExitDelay; pass `exitDelay` to set the offer's exit closure explicitly, or `noExit: true` to publish without one"
562
+ );
563
+ }
564
+ return assertExitDelay({ value: delay, type: delay < BigInt(512) ? "blocks" : "seconds" });
565
+ }
566
+ async function createOffer(wallet, params) {
473
567
  if (Boolean(params.wantAsset) === Boolean(params.offerAsset)) {
474
568
  throw new Error("set exactly one of wantAsset (BTC->asset) or offerAsset (asset->BTC)");
475
569
  }
476
570
  const [info, makerAddress, makerPublicKey] = await Promise.all([
477
- new RestArkProvider(arkServerUrl).getInfo(),
571
+ // requireLive: this info binds signerPubkey into the covenant — a
572
+ // snapshot could derive an address the operator no longer co-signs
573
+ // for, so an unreachable operator fails the call instead (fail closed,
574
+ // the same behaviour the pre-#734 caller-held provider had)
575
+ wallet.getArkadeInfo({ requireLive: true }),
478
576
  wallet.getAddress(),
479
577
  wallet.identity.xOnlyPublicKey()
480
578
  ]);
481
- const serverPubKey = hex2.decode(toXOnlySignerHex(info.signerPubkey));
482
- const network = getNetwork(info.network);
579
+ const operatorPubKey = hex2.decode(toXOnlySignerHex(info.signerPubkey));
580
+ const network = networkFromArkadeInfo(info);
483
581
  const emuKey = hex2.decode(
484
582
  toXOnlySignerHex(resolveEmulatorPubkey(network, params.emulatorPubkey))
485
583
  );
@@ -489,35 +587,39 @@ async function createOffer(wallet, arkServerUrl, params) {
489
587
  offerAsset: params.offerAsset,
490
588
  makerPkScript: ArkAddress.decode(makerAddress).pkScript,
491
589
  makerPublicKey,
492
- emulatorPubkey: emuKey
590
+ emulatorPubkey: emuKey,
591
+ // checked HERE, before the covenant is derived and registered below:
592
+ // deferring it to `encodeOffer` leaves a registered contract behind for
593
+ // an offer that then fails to encode. @see assertExitDelay
594
+ exitDelay: params.noExit ? void 0 : params.exitDelay ? assertExitDelay(params.exitDelay) : serverExitDelay(info.unilateralExitDelay)
493
595
  };
494
- const script = offerVtxoScript(binding, serverPubKey);
596
+ const script = offerContract(binding, operatorPubKey);
495
597
  const offer = { ...binding, swapPkScript: script.pkScript };
496
- await registerOfferContract(
497
- wallet,
498
- arkServerUrl,
499
- info.network,
500
- binding,
501
- serverPubKey,
502
- script.pkScript
503
- );
598
+ await registerOfferContract(wallet, info, binding, operatorPubKey, script.pkScript);
504
599
  const payload = encodeOffer(offer);
505
600
  return {
506
601
  offerHex: hex2.encode(payload),
507
602
  extension: { type: OFFER_PACKET_TYPE, payload },
508
- // VtxoScript.address owns address construction; assembling an ArkAddress
603
+ // the contract's .address() builds the address; assembling an ArkAddress
509
604
  // from tweakedPublicKey here would silently miss any future step it gains
510
- address: script.address(network.hrp, serverPubKey).encode(),
605
+ address: script.address(network.hrp, operatorPubKey).encode(),
511
606
  swapPkScript: script.pkScript
512
607
  };
513
608
  }
514
- async function cancelOffer(wallet, arkServerUrl, offerHex, opts) {
609
+ async function cancelOffer(wallet, offerHex, opts) {
515
610
  const { repository, fundingTxid, swapAddress } = opts;
516
611
  const offer = decodeOffer(hex2.decode(offerHex));
517
- const contractManager = await wallet.getContractManager();
612
+ const [contractManager, info, reader, broadcaster] = await Promise.all([
613
+ wallet.getContractManager(),
614
+ wallet.getArkadeInfo({ requireLive: true }),
615
+ wallet.getArkadeReader(),
616
+ wallet.getArkadeBroadcaster()
617
+ ]);
518
618
  const client = await arkade.Arkade.connect({
519
- arkade: new RestArkProvider(arkServerUrl),
520
- indexer: new RestIndexerProvider(arkServerUrl),
619
+ // info the wallet already holds, plus its broadcast pair — the shape
620
+ // `ArkadeServerProvider` describes, without a second `/v1/info`
621
+ arkade: { getInfo: async () => info, ...broadcaster },
622
+ indexer: reader,
521
623
  identity: wallet.identity,
522
624
  // registered offers resolve their VTXOs from the contract repository
523
625
  // instead of a direct indexer query; the indexer above stays as the
@@ -532,7 +634,7 @@ async function cancelOffer(wallet, arkServerUrl, offerHex, opts) {
532
634
  const rebuilt = new arkade.ArkadeProgramScript(program, args, keys);
533
635
  if (hex2.encode(rebuilt.pkScript) !== hex2.encode(offer.swapPkScript)) {
534
636
  throw new Error(
535
- "rebuilt covenant does not match the offer's swapPkScript \u2014 the server signing key has likely rotated since funding; pass swapAddress (the funded address) to pin the original key"
637
+ "rebuilt covenant does not match the offer's swapPkScript \u2014 the operator signing key has likely rotated since funding; pass swapAddress (the funded address) to pin the original key"
536
638
  );
537
639
  }
538
640
  const contract = new arkade.ArkadeContract(client, program, args, keys);
@@ -940,7 +1042,7 @@ var LightningReceiveCorridor = {
940
1042
  const receive = swap;
941
1043
  return {
942
1044
  expectedAmount: receive.expectedAmount,
943
- ...receive.claimArkTxid ? { claimArkTxid: receive.claimArkTxid } : {}
1045
+ ...receive.claimTxid ? { claimTxid: receive.claimTxid } : {}
944
1046
  };
945
1047
  },
946
1048
  hydrate(profile) {
@@ -950,13 +1052,13 @@ var LightningReceiveCorridor = {
950
1052
  return {
951
1053
  ...hydrateHashlock(profile),
952
1054
  expectedAmount: profile.expectedAmount,
953
- ...profile.claimArkTxid ? { claimArkTxid: profile.claimArkTxid } : {}
1055
+ ...profile.claimTxid ? { claimTxid: profile.claimTxid } : {}
954
1056
  };
955
1057
  },
956
1058
  // We are the claimant here, so the preimage material on the hashlock is
957
1059
  // ours to use.
958
1060
  claimSecret: (profile) => ({ ...profile.signer, ...profile.hashlock }),
959
- activityTxids: (profile) => profile.claimArkTxid ? [profile.claimArkTxid] : []
1061
+ activityTxids: (profile) => profile.claimTxid ? [profile.claimTxid] : []
960
1062
  };
961
1063
  function onchainSendProfile(result) {
962
1064
  return {
@@ -1040,13 +1142,32 @@ var isRfqSwapTerminal = (state) => RFQ_SWAP_TERMINAL_STATES.includes(state);
1040
1142
 
1041
1143
  // src/rfqRecord.ts
1042
1144
  var RFQ_SWAP_RETENTION_SECONDS = 30 * 24 * 60 * 60;
1145
+ function renameLegacyClaimTxid(profile) {
1146
+ const { claimArkTxid, ...rest } = profile;
1147
+ return { ...rest, claimTxid: rest.claimTxid ?? claimArkTxid };
1148
+ }
1149
+ function normalizeRfqSwapRecord(record) {
1150
+ const { fundingArkTxid, refundArkTxid, lockupSpendArkTxids, ...rest } = record;
1151
+ const legacyClaim = record.kind === "lightning_receive" && record.profile.claimArkTxid !== void 0;
1152
+ if (!fundingArkTxid && !refundArkTxid && !lockupSpendArkTxids && !legacyClaim) return record;
1153
+ const fundingTxid = rest.fundingTxid ?? fundingArkTxid;
1154
+ const refundTxid = rest.refundTxid ?? refundArkTxid;
1155
+ const lockupSpendTxids = rest.lockupSpendTxids ?? lockupSpendArkTxids;
1156
+ return {
1157
+ ...rest,
1158
+ ...fundingTxid ? { fundingTxid } : {},
1159
+ ...refundTxid ? { refundTxid } : {},
1160
+ ...lockupSpendTxids?.length ? { lockupSpendTxids } : {},
1161
+ ...legacyClaim ? { profile: renameLegacyClaimTxid(record.profile) } : {}
1162
+ };
1163
+ }
1043
1164
  var managerState = (swap) => ({
1044
1165
  rfqId: swap.rfqId,
1045
1166
  state: swap.state,
1046
1167
  createdAt: swap.createdAt,
1047
1168
  updatedAt: swap.updatedAt,
1048
- ...swap.refundArkTxid ? { refundArkTxid: swap.refundArkTxid } : {},
1049
- ...swap.lockupSpendArkTxids?.length ? { lockupSpendArkTxids: [...swap.lockupSpendArkTxids] } : {},
1169
+ ...swap.refundTxid ? { refundTxid: swap.refundTxid } : {},
1170
+ ...swap.lockupSpendTxids?.length ? { lockupSpendTxids: [...swap.lockupSpendTxids] } : {},
1050
1171
  ...swap.failure ? { failure: swap.failure } : {},
1051
1172
  ...swap.blockedReason ? { blockedReason: swap.blockedReason } : {}
1052
1173
  });
@@ -1077,27 +1198,29 @@ function createRfqSwapRecord(origin, swap) {
1077
1198
  }
1078
1199
  function updateRfqSwapRecord(record, swap) {
1079
1200
  assertSameSwap(record, swap);
1201
+ const stored = normalizeRfqSwapRecord(record);
1080
1202
  const {
1081
- refundArkTxid: _refundArkTxid,
1082
- lockupSpendArkTxids: _lockupSpendArkTxids,
1203
+ refundTxid: _refundTxid,
1204
+ lockupSpendTxids: _lockupSpendTxids,
1083
1205
  failure: _failure,
1084
1206
  blockedReason: _blockedReason,
1085
1207
  ...origin
1086
- } = record;
1087
- const handler = rfqCorridorHandlers.getOrThrow(record.kind);
1208
+ } = stored;
1209
+ const handler = rfqCorridorHandlers.getOrThrow(stored.kind);
1088
1210
  return {
1089
1211
  ...origin,
1090
1212
  ...managerState(swap),
1091
- profile: { ...record.profile, ...handler.project(swap) }
1213
+ profile: { ...stored.profile, ...handler.project(swap) }
1092
1214
  };
1093
1215
  }
1094
1216
  function rfqSwapOriginOf(record) {
1217
+ const stored = normalizeRfqSwapRecord(record);
1095
1218
  return {
1096
- kind: record.kind,
1097
- lockupAddress: record.lockupAddress,
1098
- profile: { ...record.profile },
1099
- ...record.amount !== void 0 ? { amount: record.amount } : {},
1100
- ...record.fundingArkTxid ? { fundingArkTxid: record.fundingArkTxid } : {}
1219
+ kind: stored.kind,
1220
+ lockupAddress: stored.lockupAddress,
1221
+ profile: { ...stored.profile },
1222
+ ...stored.amount !== void 0 ? { amount: stored.amount } : {},
1223
+ ...stored.fundingTxid ? { fundingTxid: stored.fundingTxid } : {}
1101
1224
  };
1102
1225
  }
1103
1226
  function lockupScript(params, lockupAddress) {
@@ -1111,27 +1234,28 @@ function lockupScript(params, lockupAddress) {
1111
1234
  return script;
1112
1235
  }
1113
1236
  function rebuildRfqSwap(record, params) {
1114
- const script = lockupScript(params, record.lockupAddress);
1237
+ const stored = normalizeRfqSwapRecord(record);
1238
+ const script = lockupScript(params, stored.lockupAddress);
1115
1239
  const common = {
1116
- rfqId: record.rfqId,
1117
- state: record.state,
1240
+ rfqId: stored.rfqId,
1241
+ state: stored.state,
1118
1242
  lockupPkScript: script.pkScript,
1119
- lockup: { script, address: record.lockupAddress },
1243
+ lockup: { script, address: stored.lockupAddress },
1120
1244
  // From the covenant, which binds it: the record's own copy would be a
1121
1245
  // second source for the deadline the refund is gated on.
1122
1246
  refundLocktime: Number(script.options.refundLocktime),
1123
- createdAt: record.createdAt,
1124
- updatedAt: record.updatedAt,
1125
- ...record.refundArkTxid ? { refundArkTxid: record.refundArkTxid } : {},
1126
- ...record.lockupSpendArkTxids?.length ? { lockupSpendArkTxids: [...record.lockupSpendArkTxids] } : {},
1127
- ...record.failure ? { failure: record.failure } : {},
1128
- ...record.blockedReason ? { blockedReason: record.blockedReason } : {}
1247
+ createdAt: stored.createdAt,
1248
+ updatedAt: stored.updatedAt,
1249
+ ...stored.refundTxid ? { refundTxid: stored.refundTxid } : {},
1250
+ ...stored.lockupSpendTxids?.length ? { lockupSpendTxids: [...stored.lockupSpendTxids] } : {},
1251
+ ...stored.failure ? { failure: stored.failure } : {},
1252
+ ...stored.blockedReason ? { blockedReason: stored.blockedReason } : {}
1129
1253
  };
1130
- const handler = rfqCorridorHandlers.getOrThrow(record.kind);
1254
+ const handler = rfqCorridorHandlers.getOrThrow(stored.kind);
1131
1255
  return {
1132
1256
  ...common,
1133
- kind: record.kind,
1134
- ...handler.hydrate(record.profile, { lockup: script })
1257
+ kind: stored.kind,
1258
+ ...handler.hydrate(stored.profile, { lockup: script })
1135
1259
  };
1136
1260
  }
1137
1261
  function shouldRetainRfqSwap(record, now) {
@@ -1172,13 +1296,15 @@ async function fetchParsedTxs(indexer, txids) {
1172
1296
  var unscannedSwapCandidates = (txs, existingIds, scanned) => txs.filter(
1173
1297
  (tx) => tx.type === "sent" && tx.redeemTxid && !existingIds.has(tx.redeemTxid) && !scanned.has(tx.redeemTxid)
1174
1298
  );
1175
- function classifySpend(offer, serverPubkey, spendTx, deposit) {
1299
+ function classifySpend(offer, operatorPubkey, spendTx, deposit) {
1176
1300
  let leaves;
1177
1301
  try {
1178
- const script = offerVtxoScript(offer, serverPubkey);
1302
+ const script = offerContract(offer, operatorPubkey);
1179
1303
  if (hex5.encode(script.pkScript) !== hex5.encode(offer.swapPkScript)) return "indeterminate";
1180
1304
  leaves = {
1181
- cancel: script.functionByName("cancel")?.leafScript,
1305
+ // both routes that hand the deposit back; `exit` is absent on an
1306
+ // offer that carries no exit closure, and drops out here
1307
+ returned: ["cancel", "exit"].map((name) => script.functionByName(name)?.leafScript).filter((leaf) => leaf !== void 0),
1182
1308
  fulfill: script.functionByName("fulfill")?.leafScript
1183
1309
  };
1184
1310
  } catch {
@@ -1190,22 +1316,22 @@ function classifySpend(offer, serverPubkey, spendTx, deposit) {
1190
1316
  if (hex5.encode(input.txid) !== deposit.txid) continue;
1191
1317
  for (const leaf of input.tapLeafScript ?? []) {
1192
1318
  const spent = hex5.encode(scriptFromTapLeafScript(leaf));
1193
- if (leaves.cancel && spent === hex5.encode(leaves.cancel)) return "cancelled";
1319
+ if (leaves.returned.some((back) => spent === hex5.encode(back))) return "cancelled";
1194
1320
  if (leaves.fulfill && spent === hex5.encode(leaves.fulfill)) return "fulfilled";
1195
1321
  }
1196
1322
  }
1197
1323
  return "indeterminate";
1198
1324
  }
1199
1325
  var spendTxidsOf = (vtxo) => [vtxo.spentBy, vtxo.arkTxId].filter((id) => Boolean(id));
1200
- function classifyDepositSpend(offer, serverPubkey, spendTxs, deposit) {
1326
+ function classifyDepositSpend(offer, operatorPubkey, spendTxs, deposit) {
1201
1327
  for (const tx of spendTxs) {
1202
- const kind = classifySpend(offer, serverPubkey, tx, deposit);
1328
+ const kind = classifySpend(offer, operatorPubkey, tx, deposit);
1203
1329
  if (kind !== "indeterminate") return kind;
1204
1330
  }
1205
1331
  return "indeterminate";
1206
1332
  }
1207
1333
  async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
1208
- const { serverPubkey, scanned = /* @__PURE__ */ new Set() } = opts;
1334
+ const { operatorPubkey, scanned = /* @__PURE__ */ new Set() } = opts;
1209
1335
  const candidates = unscannedSwapCandidates(txs, existingIds, scanned);
1210
1336
  if (candidates.length === 0) return { restored: [], scannedTxids: [] };
1211
1337
  const byTxid = new Map(candidates.map((tx) => [tx.redeemTxid, tx]));
@@ -1274,7 +1400,7 @@ async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
1274
1400
  if (state === "swept") status = "recoverable";
1275
1401
  else if (state === "spent") {
1276
1402
  const spendTxs = spendTxidsOf(vtxo).map((id) => spendTxByTxid.get(id)).filter((tx) => tx !== void 0);
1277
- const kind = classifyDepositSpend(offer, serverPubkey, spendTxs, {
1403
+ const kind = classifyDepositSpend(offer, operatorPubkey, spendTxs, {
1278
1404
  txid: vtxo.txid,
1279
1405
  vout: vtxo.vout
1280
1406
  });
@@ -1291,7 +1417,7 @@ async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
1291
1417
  fromAmount,
1292
1418
  toAmount: offer.wantAmount.toString(),
1293
1419
  // ponytail(arkade-os/ts-sdk#680): empty address makes cancel fall back
1294
- // to the current server key; store the funded address if server-key
1420
+ // to the current operator key; store the funded address if operator-key
1295
1421
  // rotations become real (cancelOffer now at least diagnoses the
1296
1422
  // mismatch instead of reporting a missing VTXO)
1297
1423
  swapAddress: "",
@@ -1313,7 +1439,6 @@ async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
1313
1439
  import { base64 as base642, hex as hex6 } from "@scure/base";
1314
1440
  import {
1315
1441
  ArkAddress as ArkAddress3,
1316
- RestIndexerProvider as RestIndexerProvider3,
1317
1442
  Transaction as Transaction2
1318
1443
  } from "@arkade-os/sdk";
1319
1444
  var TERMINAL = ["fulfilled", "cancelled", "recoverable"];
@@ -1330,13 +1455,15 @@ function spendUpdate(swap, spend) {
1330
1455
  }
1331
1456
  async function watchOfferSwaps({
1332
1457
  wallet,
1333
- arkServerUrl,
1334
1458
  repository,
1335
1459
  onUpdate
1336
1460
  }) {
1337
- const manager = await wallet.getContractManager();
1338
- const serverPubkey = ArkAddress3.decode(await wallet.getAddress()).serverPubKey;
1339
- const indexer = new RestIndexerProvider3(arkServerUrl);
1461
+ const [manager, address, indexer] = await Promise.all([
1462
+ wallet.getContractManager(),
1463
+ wallet.getAddress(),
1464
+ wallet.getArkadeReader()
1465
+ ]);
1466
+ const operatorPubkey = ArkAddress3.decode(address).serverPubKey;
1340
1467
  let queue = Promise.resolve();
1341
1468
  const enqueue = (task) => {
1342
1469
  queue = queue.then(task).catch(() => {
@@ -1350,7 +1477,7 @@ async function watchOfferSwaps({
1350
1477
  const { txs } = await indexer.getVirtualTxs(candidates);
1351
1478
  return classifyDepositSpend(
1352
1479
  decodeOffer(hex6.decode(swap.offerHex)),
1353
- serverPubkey,
1480
+ operatorPubkey,
1354
1481
  txs.map((psbt) => Transaction2.fromPSBT(base642.decode(psbt))),
1355
1482
  { txid: vtxo.txid, vout: vtxo.vout }
1356
1483
  );
@@ -1445,11 +1572,10 @@ var LockupNeedsRecoveryError = class extends Error {
1445
1572
  * into one settlement with no CLTV awareness, so an early attempt can fail
1446
1573
  * the whole batch — including unrelated outputs that were otherwise fine.
1447
1574
  *
1448
- * Exposed as a value, not only inside the message, so a caller can encode
1449
- * `packages/boltz-swap`'s "pre-CLTV recoverable → skipped" rule without
1450
- * parsing prose. Seconds-based locktimes mature against the chain tip's
1451
- * timestamp rather than wall clock, so treat this as a floor to wait past,
1452
- * not an exact alarm.
1575
+ * Exposed as a value, not only inside the message, so a caller can skip
1576
+ * pre-CLTV recoverable outputs without parsing prose. Seconds-based
1577
+ * locktimes mature against the chain tip's timestamp rather than wall
1578
+ * clock, so treat this as a floor to wait past, not an exact alarm.
1453
1579
  */
1454
1580
  recoverableAfter;
1455
1581
  constructor(outpoints, recoverableAfter) {
@@ -1510,7 +1636,7 @@ async function readLockupFate(indexer, input) {
1510
1636
  if (vtxo.spentBy)
1511
1637
  spentBy.set(vtxo.spentBy, {
1512
1638
  checkpointTxid: vtxo.spentBy,
1513
- arkTxid: vtxo.arkTxId
1639
+ txid: vtxo.arkTxId
1514
1640
  });
1515
1641
  else everySpendNamed = false;
1516
1642
  }
@@ -1539,32 +1665,32 @@ async function readLockupFate(indexer, input) {
1539
1665
  }
1540
1666
  return everySpendNamed && observed.size === spentBy.size ? { fate: "returned", spends } : { fate: "unknown" };
1541
1667
  }
1542
- async function pushRefundWithoutReceiver(ark, input) {
1668
+ async function pushRefundWithoutReceiver(operator, input) {
1543
1669
  if (input.vtxos.length === 0) throw new Error("nothing to refund: no funded outputs");
1544
1670
  const swept = input.vtxos.filter((vtxo) => vtxo.recoverable);
1545
1671
  if (swept.length > 0) {
1546
1672
  throw new LockupNeedsRecoveryError(
1547
1673
  swept.map((vtxo) => `${vtxo.txid}:${vtxo.vout}`),
1548
- input.script.options.refundLocktime
1674
+ input.contract.options.refundLocktime
1549
1675
  );
1550
1676
  }
1551
- const refundPkScript = input.refundPkScript ?? input.script.options.nonInteractiveRefund?.senderPkScript;
1677
+ const refundPkScript = input.refundPkScript ?? input.contract.options.nonInteractiveParameters?.senderPkScript;
1552
1678
  if (!refundPkScript) {
1553
1679
  throw new Error(
1554
- "no refund destination: the contract carries no nonInteractiveRefund leaf, so pass refundPkScript explicitly"
1680
+ "no refund destination: the contract carries no emulator covenant suite, so pass refundPkScript explicitly"
1555
1681
  );
1556
1682
  }
1557
- const info = await ark.getInfo();
1558
- let serverUnrollScript;
1683
+ const info = await operator.getInfo();
1684
+ let operatorUnrollScript;
1559
1685
  try {
1560
- serverUnrollScript = CSVMultisigTapscript.decode(hex7.decode(info.checkpointTapscript));
1686
+ operatorUnrollScript = CSVMultisigTapscript.decode(hex7.decode(info.checkpointTapscript));
1561
1687
  } catch {
1562
- throw new Error("invalid checkpointTapscript from the Arkade server");
1688
+ throw new Error("invalid checkpointTapscript from the operator");
1563
1689
  }
1564
- const leaf = input.script.refundWithoutReceiver();
1565
- const tapTree = input.script.encode();
1690
+ const leaf = input.contract.refundWithoutReceiver();
1691
+ const tapTree = input.contract.encode();
1566
1692
  const amount = input.vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0);
1567
- const { arkTx, checkpoints } = buildOffchainTx(
1693
+ const { arkTx: tx, checkpoints } = buildOffchainTx(
1568
1694
  input.vtxos.map((vtxo) => ({
1569
1695
  txid: vtxo.txid,
1570
1696
  vout: vtxo.vout,
@@ -1573,14 +1699,14 @@ async function pushRefundWithoutReceiver(ark, input) {
1573
1699
  tapTree
1574
1700
  })),
1575
1701
  [{ script: refundPkScript, amount: BigInt(amount) }],
1576
- serverUnrollScript
1702
+ operatorUnrollScript
1577
1703
  );
1578
- const signedArkTx = await input.sender.sign(arkTx);
1579
- const submitted = await ark.submitTx(
1580
- base643.encode(signedArkTx.toPSBT()),
1704
+ const signedTx = await input.sender.sign(tx);
1705
+ const submitted = await operator.submitTx(
1706
+ base643.encode(signedTx.toPSBT()),
1581
1707
  checkpoints.map((c) => base643.encode(c.toPSBT()))
1582
1708
  );
1583
- assertSubmittedArkTxid(submitted, signedArkTx, "refundWithoutReceiver");
1709
+ assertSubmittedArkTxid(submitted, signedTx, "refundWithoutReceiver");
1584
1710
  const matched = matchServerCheckpoints(
1585
1711
  submitted.signedCheckpointTxs,
1586
1712
  checkpoints,
@@ -1591,11 +1717,11 @@ async function pushRefundWithoutReceiver(ark, input) {
1591
1717
  async ({ server }) => base643.encode((await input.sender.sign(server, [0])).toPSBT())
1592
1718
  )
1593
1719
  );
1594
- await ark.finalizeTx(submitted.arkTxid, finalCheckpoints);
1595
- return { arkTxid: submitted.arkTxid, amount };
1720
+ await operator.finalizeTx(submitted.arkTxid, finalCheckpoints);
1721
+ return { txid: submitted.arkTxid, amount };
1596
1722
  }
1597
1723
  var REFUND_MTP_LAG_SECONDS = 2 * 60 * 60;
1598
- async function refundIfUnresolved(transport, ark, indexer, input) {
1724
+ async function refundIfUnresolved(transport, operator, indexer, input) {
1599
1725
  const pollMs = input.pollMs ?? 5e3;
1600
1726
  const now = input.now ?? (() => Math.floor(Date.now() / 1e3));
1601
1727
  const attemptDeadline = input.attemptDeadline ?? input.refundLocktime + REFUND_MTP_LAG_SECONDS;
@@ -1603,11 +1729,26 @@ async function refundIfUnresolved(transport, ark, indexer, input) {
1603
1729
  const status = await transport.status(input.rfqId);
1604
1730
  if (status && isResolved(status.state)) return { outcome: "resolved", status };
1605
1731
  if (now() >= input.refundLocktime) {
1606
- const vtxos = await findLockupVtxos(indexer, input.script.pkScript);
1732
+ let fate = { fate: "unknown" };
1733
+ try {
1734
+ fate = await readLockupFate(indexer, {
1735
+ swapPkScript: input.contract.pkScript,
1736
+ paymentHash: input.paymentHash
1737
+ });
1738
+ } catch {
1739
+ }
1740
+ if (fate.fate === "exited") {
1741
+ return {
1742
+ outcome: "exited",
1743
+ outpoints: fate.outpoints.map((o) => `${o.txid}:${o.vout}`),
1744
+ status
1745
+ };
1746
+ }
1747
+ const vtxos = await findLockupVtxos(indexer, input.contract.pkScript);
1607
1748
  if (vtxos.length === 0) return { outcome: "nothing_to_refund", status };
1608
1749
  try {
1609
- const pushed = await pushRefundWithoutReceiver(ark, {
1610
- script: input.script,
1750
+ const pushed = await pushRefundWithoutReceiver(operator, {
1751
+ contract: input.contract,
1611
1752
  sender: input.sender,
1612
1753
  vtxos,
1613
1754
  refundPkScript: input.refundPkScript
@@ -1650,13 +1791,13 @@ var assertFiniteAmount = (value, reason, label) => {
1650
1791
  error.reason = reason;
1651
1792
  throw error;
1652
1793
  };
1653
- async function pushClaim(ark, input) {
1794
+ async function pushClaim(operator, input) {
1654
1795
  if (input.vtxos.length === 0) throw new Error("nothing to claim: no funded outputs");
1655
1796
  const swept = input.vtxos.filter((vtxo) => vtxo.recoverable);
1656
1797
  if (swept.length > 0) {
1657
1798
  throw new LockupNeedsRecoveryError(
1658
1799
  swept.map((vtxo) => `${vtxo.txid}:${vtxo.vout}`),
1659
- input.script.options.refundLocktime
1800
+ input.contract.options.refundLocktime
1660
1801
  );
1661
1802
  }
1662
1803
  const locked = input.vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0);
@@ -1667,22 +1808,22 @@ async function pushClaim(ark, input) {
1667
1808
  throw new LockupAmountMismatchError(input.expectedAmount, locked);
1668
1809
  }
1669
1810
  }
1670
- const committed = input.script.options.preimageHash;
1811
+ const committed = input.contract.options.preimageHash;
1671
1812
  if (hex8.encode(ripemd160(sha2563(input.preimage))) !== hex8.encode(committed)) {
1672
1813
  throw new Error("preimage does not match the covenant's payment hash");
1673
1814
  }
1674
- const info = await ark.getInfo();
1675
- let serverUnrollScript;
1815
+ const info = await operator.getInfo();
1816
+ let operatorUnrollScript;
1676
1817
  try {
1677
- serverUnrollScript = CSVMultisigTapscript2.decode(hex8.decode(info.checkpointTapscript));
1818
+ operatorUnrollScript = CSVMultisigTapscript2.decode(hex8.decode(info.checkpointTapscript));
1678
1819
  } catch {
1679
- throw new Error("invalid checkpointTapscript from the Arkade server");
1820
+ throw new Error("invalid checkpointTapscript from the operator");
1680
1821
  }
1681
- const leaf = input.script.claim();
1682
- const tapTree = input.script.encode();
1683
- const arkTxid = await signAndSubmitOffchainTx({
1822
+ const leaf = input.contract.claim();
1823
+ const tapTree = input.contract.encode();
1824
+ const txid = await signAndSubmitOffchainTx({
1684
1825
  identity: claimWithPreimageIdentity(input.receiver, input.preimage),
1685
- provider: ark,
1826
+ provider: operator,
1686
1827
  inputs: input.vtxos.map((vtxo) => ({
1687
1828
  txid: vtxo.txid,
1688
1829
  vout: vtxo.vout,
@@ -1693,10 +1834,10 @@ async function pushClaim(ark, input) {
1693
1834
  // One aggregate output: unlike the covenant refund, this leaf inspects
1694
1835
  // nothing about the output set.
1695
1836
  outputs: [{ script: input.destinationPkScript, amount: BigInt(locked) }],
1696
- serverUnrollScript,
1697
- verifyServerSignatures: { serverPubkey: input.script.options.server }
1837
+ serverUnrollScript: operatorUnrollScript,
1838
+ verifyServerSignatures: { serverPubkey: input.contract.options.server }
1698
1839
  });
1699
- return { arkTxid, amount: locked };
1840
+ return { txid, amount: locked };
1700
1841
  }
1701
1842
  async function awaitLockupFunding(indexer, swapPkScript, options = {}) {
1702
1843
  const pollMs = options.pollMs ?? 5e3;
@@ -1711,13 +1852,13 @@ async function awaitLockupFunding(indexer, swapPkScript, options = {}) {
1711
1852
  await sleep2(pollMs);
1712
1853
  }
1713
1854
  }
1714
- async function claimReceiveLockup(indexer, ark, input) {
1855
+ async function claimReceiveLockup(indexer, operator, input) {
1715
1856
  const vtxos = await awaitLockupFunding(indexer, input.swapPkScript, {
1716
1857
  pollMs: input.pollMs,
1717
1858
  deadline: input.deadline
1718
1859
  });
1719
- return pushClaim(ark, {
1720
- script: input.script,
1860
+ return pushClaim(operator, {
1861
+ contract: input.contract,
1721
1862
  receiver: input.receiver,
1722
1863
  preimage: input.preimage,
1723
1864
  vtxos,
@@ -1772,8 +1913,8 @@ async function senderIdentityForSwapRecord(wallet, record) {
1772
1913
  // src/arkadeRefunder.ts
1773
1914
  function arkadeRefunder(deps) {
1774
1915
  return async (swap) => {
1775
- const script = swap.lockup?.script;
1776
- if (!script) {
1916
+ const contract = swap.lockup?.script;
1917
+ if (!contract) {
1777
1918
  throw new Error(
1778
1919
  `swap ${swap.rfqId} carries no lockup covenant, so its refund cannot be built`
1779
1920
  );
@@ -1788,7 +1929,7 @@ function arkadeRefunder(deps) {
1788
1929
  );
1789
1930
  }
1790
1931
  const sender = await senderIdentityForSwapRecord(deps.wallet, rfqSignerOf(record) ?? {});
1791
- return pushRefundWithoutReceiver(deps.ark, { script, sender, vtxos });
1932
+ return pushRefundWithoutReceiver(deps.operator, { contract, sender, vtxos });
1792
1933
  };
1793
1934
  }
1794
1935
 
@@ -2184,6 +2325,19 @@ var RfqSwapManager = class {
2184
2325
  async getPendingSwaps() {
2185
2326
  return [...this.monitored.values()];
2186
2327
  }
2328
+ /**
2329
+ * Every swap this manager holds — {@link getPendingSwaps} plus the ones
2330
+ * that already ended. The two sets are disjoint: a swap leaves `monitored`
2331
+ * as it enters `finished`.
2332
+ *
2333
+ * The finished half is what this process has seen, which after
2334
+ * {@link restoreFromRepository} is the stored history minus what retention
2335
+ * pruned. A manager that has restored nothing answers with the live swaps
2336
+ * alone.
2337
+ */
2338
+ async getAllSwaps() {
2339
+ return [...this.monitored.values(), ...this.finished.values()];
2340
+ }
2187
2341
  async hasSwap(rfqId) {
2188
2342
  return this.monitored.has(rfqId);
2189
2343
  }
@@ -2462,14 +2616,14 @@ var RfqSwapManager = class {
2462
2616
  return this.claimIfFunded(swap, vtxos);
2463
2617
  }
2464
2618
  if (now < swap.refundLocktime + REFUND_MTP_LAG_SECONDS) {
2465
- if (swap.claimArkTxid) return;
2619
+ if (swap.claimTxid) return;
2466
2620
  return this.block(
2467
2621
  swap,
2468
2622
  "the claim window closed with the lockup unclaimed \u2014 only the solver can act now"
2469
2623
  );
2470
2624
  }
2471
2625
  const failure = this.lastClaimError.get(swap.rfqId);
2472
- if (failure && !swap.claimArkTxid) {
2626
+ if (failure && !swap.claimTxid) {
2473
2627
  return this.fail(swap, new Error(failure));
2474
2628
  }
2475
2629
  this.setState(swap, "refunded");
@@ -2484,7 +2638,7 @@ var RfqSwapManager = class {
2484
2638
  */
2485
2639
  async claimIfFunded(swap, vtxos) {
2486
2640
  if (vtxos.length === 0) return this.unblock(swap);
2487
- const partiallyClaimed = swap.claimArkTxid !== void 0;
2641
+ const partiallyClaimed = swap.claimTxid !== void 0;
2488
2642
  if (partiallyClaimed && !this.hasUnclaimedOutpoint(swap.rfqId, vtxos)) {
2489
2643
  return;
2490
2644
  }
@@ -2512,10 +2666,10 @@ var RfqSwapManager = class {
2512
2666
  this.setState(swap, "claimable");
2513
2667
  if (!this.config.enableAutoActions) return;
2514
2668
  try {
2515
- const { arkTxid } = await this.callbacks.claimLockup(swap, vtxos, { partiallyClaimed });
2669
+ const { txid } = await this.callbacks.claimLockup(swap, vtxos, { partiallyClaimed });
2516
2670
  this.lastClaimError.delete(swap.rfqId);
2517
2671
  this.rememberClaimed(swap.rfqId, vtxos);
2518
- swap.claimArkTxid = arkTxid;
2672
+ swap.claimTxid = txid;
2519
2673
  this.touch(swap);
2520
2674
  this.setState(swap, "claimed");
2521
2675
  this.emitAction(swap, "claimLockup");
@@ -2607,7 +2761,7 @@ var RfqSwapManager = class {
2607
2761
  try {
2608
2762
  const pushed = await this.callbacks.refundArkade(swap);
2609
2763
  if (pushed) {
2610
- swap.refundArkTxid = pushed.arkTxid;
2764
+ swap.refundTxid = pushed.txid;
2611
2765
  this.touch(swap);
2612
2766
  }
2613
2767
  this.setState(swap, "refunded");
@@ -2680,7 +2834,7 @@ var RfqSwapManager = class {
2680
2834
  /**
2681
2835
  * Record which ark transactions ended the lockup.
2682
2836
  *
2683
- * Only the ones the indexer actually named: `LockupSpend.arkTxid` is
2837
+ * Only the ones the indexer actually named: `LockupSpend.txid` is
2684
2838
  * optional, and a checkpoint txid is not what history correlates on — a
2685
2839
  * record carrying one would name a transaction the wallet's own activity
2686
2840
  * never shows. Fewer txids is the right failure here.
@@ -2690,9 +2844,9 @@ var RfqSwapManager = class {
2690
2844
  * verdict once.
2691
2845
  */
2692
2846
  stampLockupSpends(swap, spends) {
2693
- const arkTxids = spends.map((spend) => spend.arkTxid).filter((txid) => txid !== void 0);
2694
- if (arkTxids.length === 0) return;
2695
- swap.lockupSpendArkTxids = arkTxids;
2847
+ const txids = spends.map((spend) => spend.txid).filter((txid) => txid !== void 0);
2848
+ if (txids.length === 0) return;
2849
+ swap.lockupSpendTxids = txids;
2696
2850
  this.touch(swap);
2697
2851
  }
2698
2852
  touch(swap) {
@@ -2773,10 +2927,9 @@ var RfqSwapManager = class {
2773
2927
  /**
2774
2928
  * Drop a terminal swap from monitoring and report it exactly once.
2775
2929
  *
2776
- * `onSwapCompleted` and `onSwapFailed` are mutually exclusive here, unlike
2777
- * Boltz's manager, which fires completion for every swap that leaves
2778
- * monitoring including the failed ones — a listener named "completed" that
2779
- * also fires on failure is a trap worth not inheriting.
2930
+ * `onSwapCompleted` and `onSwapFailed` are mutually exclusive here: a
2931
+ * listener named "completed" that also fires on failure is a trap, so a
2932
+ * swap that leaves monitoring reports through exactly one of them.
2780
2933
  */
2781
2934
  finalize(swap) {
2782
2935
  if (!this.monitored.has(swap.rfqId)) return;
@@ -2812,7 +2965,7 @@ var traderClaimTxid = (swap) => {
2812
2965
  case "onchain_send":
2813
2966
  return swap.claimTxid;
2814
2967
  case "lightning_receive":
2815
- return swap.claimArkTxid;
2968
+ return swap.claimTxid;
2816
2969
  default:
2817
2970
  return void 0;
2818
2971
  }
@@ -2822,7 +2975,7 @@ var outcomeOf = (swap) => {
2822
2975
  const lostReceive = swap.kind === "lightning_receive" && swap.state === "refunded";
2823
2976
  return {
2824
2977
  state: swap.state,
2825
- txid: lostReceive ? swap.refundArkTxid : traderClaimTxid(swap) ?? swap.refundArkTxid
2978
+ txid: lostReceive ? swap.refundTxid : traderClaimTxid(swap) ?? swap.refundTxid
2826
2979
  };
2827
2980
  };
2828
2981
  var errorMessage = (error) => error instanceof Error ? error.message : String(error);
@@ -2889,16 +3042,17 @@ async function rfqSwapActivityInputs(deps) {
2889
3042
  const records = await deps.repository.getAllRfqSwaps();
2890
3043
  return Promise.all(records.map((record) => activityInputOf(record, deps.indexer)));
2891
3044
  }
2892
- async function activityInputOf(record, indexer) {
3045
+ async function activityInputOf(stored, indexer) {
3046
+ const record = normalizeRfqSwapRecord(stored);
2893
3047
  const txids = /* @__PURE__ */ new Set();
2894
- if (record.fundingArkTxid) txids.add(record.fundingArkTxid);
2895
- if (record.refundArkTxid) txids.add(record.refundArkTxid);
3048
+ if (record.fundingTxid) txids.add(record.fundingTxid);
3049
+ if (record.refundTxid) txids.add(record.refundTxid);
2896
3050
  const handler = rfqCorridorHandlers.getOrThrow(record.kind);
2897
3051
  for (const txid of handler.activityTxids?.(record.profile) ?? []) txids.add(txid);
2898
- for (const txid of record.lockupSpendArkTxids ?? []) txids.add(txid);
2899
- const spendUnknown = isRfqSwapTerminal(record.state) && !record.refundArkTxid && !record.lockupSpendArkTxids?.length;
2900
- if (indexer && (!record.fundingArkTxid || spendUnknown)) {
2901
- for (const txid of await lockupTxids(indexer, record, !record.fundingArkTxid)) {
3052
+ for (const txid of record.lockupSpendTxids ?? []) txids.add(txid);
3053
+ const spendUnknown = isRfqSwapTerminal(record.state) && !record.refundTxid && !record.lockupSpendTxids?.length;
3054
+ if (indexer && (!record.fundingTxid || spendUnknown)) {
3055
+ for (const txid of await lockupTxids(indexer, record, !record.fundingTxid)) {
2902
3056
  txids.add(txid);
2903
3057
  }
2904
3058
  }
@@ -2923,6 +3077,334 @@ async function lockupTxids(indexer, record, wantFunding) {
2923
3077
  return [];
2924
3078
  }
2925
3079
  }
3080
+
3081
+ // src/swapClient.ts
3082
+ import { hex as hex11 } from "@scure/base";
3083
+ import { ArkAddress as ArkAddress5, asset as asset2, contractSigner as contractSigner2 } from "@arkade-os/sdk";
3084
+ import { quoteOffer } from "@arkade-os/solver-discovery";
3085
+ var sideCorridorsOf = (market) => {
3086
+ const read = (value) => value === "lightning" || value === "onchain" ? value : "arkade";
3087
+ const m = market;
3088
+ return { base: read(m.base_corridor), quote: read(m.quote_corridor) };
3089
+ };
3090
+ var resolveKind = (market, give) => {
3091
+ const corridors = sideCorridorsOf(market);
3092
+ const giveCorridor = corridors[give];
3093
+ const receiveCorridor = corridors[give === "base" ? "quote" : "base"];
3094
+ if (giveCorridor === "arkade" && receiveCorridor === "arkade") return "spot";
3095
+ if (receiveCorridor === "lightning") return "ln_send";
3096
+ if (giveCorridor === "lightning") return "ln_receive";
3097
+ if (receiveCorridor === "onchain") return "onchain_send";
3098
+ return "onchain_receive";
3099
+ };
3100
+ var need = (value, what, leg) => {
3101
+ if (value === void 0) throw new Error(`a ${leg} quote needs ${what}`);
3102
+ return value;
3103
+ };
3104
+ var corridorAmount = (input, leg) => {
3105
+ const raw = need(input.amount, "an amount", leg);
3106
+ const amountOn = need(input.amountOn, "amountOn ('give' or 'receive')", leg);
3107
+ const amount = Number(raw);
3108
+ if (!Number.isSafeInteger(amount) || amount <= 0) {
3109
+ throw new Error(`a ${leg} amount must be a positive integer of sats, got ${String(raw)}`);
3110
+ }
3111
+ return { amount, amountSide: amountOn === "give" ? "from" : "to" };
3112
+ };
3113
+ function createSwapClient(deps) {
3114
+ const { wallet, repository, transportFor } = deps;
3115
+ let broadcaster;
3116
+ const broadcasting = () => broadcaster ??= wallet.getArkadeBroadcaster();
3117
+ const ark = deps.ark ?? {
3118
+ getInfo: () => wallet.getArkadeInfo({ requireLive: true }),
3119
+ submitTx: async (...args) => (await broadcasting()).submitTx(...args),
3120
+ finalizeTx: async (...args) => (await broadcasting()).finalizeTx(...args)
3121
+ };
3122
+ let reader;
3123
+ const reading = () => reader ??= wallet.getArkadeReader();
3124
+ const indexer = deps.indexer ?? {
3125
+ getVtxos: async (opts) => {
3126
+ if (!opts)
3127
+ throw new Error("getVtxos on the swap indexer requires scripts or outpoints");
3128
+ return (await reading()).getVtxos(opts);
3129
+ },
3130
+ getVirtualTxs: async (...args) => (await reading()).getVirtualTxs(...args)
3131
+ };
3132
+ const managerDeps = { indexer, chain: deps.chain, repository };
3133
+ const manager = new RfqSwapManager(managerDeps);
3134
+ const listeners = /* @__PURE__ */ new Set();
3135
+ const notify2 = (swap) => {
3136
+ for (const listener of listeners) {
3137
+ try {
3138
+ listener(swap);
3139
+ } catch {
3140
+ }
3141
+ }
3142
+ };
3143
+ manager.onSwapUpdate((swap) => notify2({ family: "rfq", swap }));
3144
+ const claimLockup = async (swap, vtxos, { partiallyClaimed }) => {
3145
+ const record = await repository.getRfqSwap(swap.rfqId);
3146
+ if (!record) throw new Error(`rfq swap ${swap.rfqId} has no stored record to claim from`);
3147
+ const secret = rfqClaimSecretOf(record);
3148
+ if (!secret) throw new Error(`rfq swap ${swap.rfqId} carries no claim secret`);
3149
+ const script = swap.lockup?.script;
3150
+ if (!script) throw new Error(`rfq swap ${swap.rfqId} carries no lockup covenant`);
3151
+ const payoutAddress = record.profile.payoutAddress;
3152
+ if (!payoutAddress) throw new Error(`rfq swap ${swap.rfqId} carries no payoutAddress`);
3153
+ return pushClaim(ark, {
3154
+ contract: script,
3155
+ receiver: await contractSigner2(wallet, secret.signingDescriptor),
3156
+ preimage: await preimageForSwapRecord(wallet, secret),
3157
+ vtxos,
3158
+ destinationPkScript: ArkAddress5.decode(payoutAddress).pkScript,
3159
+ expectedAmount: swap.expectedAmount,
3160
+ partiallyClaimed
3161
+ });
3162
+ };
3163
+ manager.setCallbacks({
3164
+ refundArkade: arkadeRefunder({ operator: ark, indexer, wallet, repository }),
3165
+ claimLockup,
3166
+ ...deps.claimOnchain ? { claimOnchain: deps.claimOnchain } : {}
3167
+ });
3168
+ let watcher;
3169
+ const quote = async (market, input) => {
3170
+ const kind = resolveKind(market, input.give);
3171
+ switch (kind) {
3172
+ case "spot": {
3173
+ const raw = need(input.amount, "an amount", kind);
3174
+ const amountOn = need(input.amountOn, "amountOn ('give' or 'receive')", kind);
3175
+ const plan = await quoteOffer(market, {
3176
+ give: input.give,
3177
+ ...amountOn === "give" ? { giveAmount: raw } : { wantAmount: raw },
3178
+ fetchImpl: deps.discovery.fetchImpl
3179
+ });
3180
+ return { kind, market, plan };
3181
+ }
3182
+ case "ln_send": {
3183
+ const invoice = need(input.invoice, "the invoice to pay", kind);
3184
+ const request = await requestLightningSend(wallet, transportFor(market), {
3185
+ invoice,
3186
+ emulatorPubkey: deps.emulatorPubkey
3187
+ });
3188
+ return { kind, market, request };
3189
+ }
3190
+ case "ln_receive": {
3191
+ const params = {
3192
+ ...corridorAmount(input, kind),
3193
+ covclaimdPubkey: need(deps.covclaimdPubkey, "deps.covclaimdPubkey", kind),
3194
+ decodeInvoice: need(deps.decodeInvoice, "deps.decodeInvoice", kind),
3195
+ maxPayAmount: input.maxPayAmount,
3196
+ emulatorPubkey: deps.emulatorPubkey
3197
+ };
3198
+ const request = await requestLightningReceive(wallet, transportFor(market), params);
3199
+ return { kind, market, request, invoice: request.invoice };
3200
+ }
3201
+ case "onchain_send": {
3202
+ need(deps.chain, "deps.chain (L1 access)", kind);
3203
+ const params = {
3204
+ ...corridorAmount(input, kind),
3205
+ payoutPubkey: need(input.payoutPubkey, "a payoutPubkey", kind),
3206
+ preimage: input.preimage,
3207
+ emulatorPubkey: deps.emulatorPubkey
3208
+ };
3209
+ const request = await requestOnchainSend(wallet, transportFor(market), params);
3210
+ return { kind, market, request };
3211
+ }
3212
+ case "onchain_receive":
3213
+ throw new Error(
3214
+ "onchain->arkade is not driven by RfqSwapManager yet; quote it directly with requestOnchainReceive"
3215
+ );
3216
+ }
3217
+ };
3218
+ const admit = async (swap, origin) => {
3219
+ let announced = false;
3220
+ const heard = manager.onSwapUpdate((updated) => {
3221
+ if (updated.rfqId === swap.rfqId) announced = true;
3222
+ });
3223
+ try {
3224
+ await manager.addSwap(swap, origin);
3225
+ } finally {
3226
+ heard();
3227
+ }
3228
+ const unified = { family: "rfq", swap };
3229
+ if (!announced) notify2(unified);
3230
+ return unified;
3231
+ };
3232
+ const fundPersisted = async (swap, origin, funding) => {
3233
+ await repository.saveRfqSwap(createRfqSwapRecord(origin, swap));
3234
+ return wallet.send({ address: funding.address, amount: funding.amount });
3235
+ };
3236
+ const accept = async (accepted) => {
3237
+ const now = Math.floor(Date.now() / 1e3);
3238
+ switch (accepted.kind) {
3239
+ case "spot": {
3240
+ const { plan } = accepted;
3241
+ const depositIsBtc = plan.deposit.asset.id === BTC_ASSET_ID;
3242
+ const offer = await createOffer(wallet, {
3243
+ wantAmount: plan.receive.atomic,
3244
+ ...plan.receive.asset.id === BTC_ASSET_ID ? { offerAsset: asset2.AssetId.fromString(plan.deposit.asset.id) } : { wantAsset: asset2.AssetId.fromString(plan.receive.asset.id) },
3245
+ emulatorPubkey: deps.emulatorPubkey
3246
+ });
3247
+ const txid = await wallet.send({
3248
+ address: offer.address,
3249
+ // asset deposits ride the SDK's dust-sat carrier
3250
+ amount: depositIsBtc ? Number(plan.deposit.atomic) : void 0,
3251
+ assets: depositIsBtc ? void 0 : [{ assetId: plan.deposit.asset.id, amount: plan.deposit.atomic }],
3252
+ extensions: [offer.extension]
3253
+ });
3254
+ const swap = {
3255
+ id: txid,
3256
+ fromAsset: plan.deposit.asset.id,
3257
+ toAsset: plan.receive.asset.id,
3258
+ fromAmount: plan.deposit.atomic.toString(),
3259
+ toAmount: plan.receive.atomic.toString(),
3260
+ swapAddress: offer.address,
3261
+ swapPkScript: hex11.encode(offer.swapPkScript),
3262
+ offerHex: offer.offerHex,
3263
+ fundingTxid: txid,
3264
+ status: "pending",
3265
+ createdAt: Date.now()
3266
+ };
3267
+ await addAssetSwap(repository, swap);
3268
+ const unified = { family: "offer", swap };
3269
+ notify2(unified);
3270
+ return unified;
3271
+ }
3272
+ case "ln_send": {
3273
+ const { request } = accepted;
3274
+ const swap = {
3275
+ kind: "lightning_send",
3276
+ rfqId: request.rfqId,
3277
+ state: "pending",
3278
+ lockupPkScript: request.swapPkScript,
3279
+ lockup: { script: request.script, address: request.address },
3280
+ // the trader's own decode, never the solver's echo
3281
+ paymentHash: request.contractParams.paymentHash,
3282
+ refundLocktime: request.contractParams.refundLocktime,
3283
+ createdAt: now,
3284
+ updatedAt: now
3285
+ };
3286
+ const origin = {
3287
+ kind: "lightning_send",
3288
+ lockupAddress: request.address,
3289
+ profile: rfqSecretsProfile(request.secrets, swap.paymentHash),
3290
+ amount: request.fundAmount
3291
+ };
3292
+ const txid = await fundPersisted(swap, origin, {
3293
+ address: request.address,
3294
+ amount: request.fundAmount
3295
+ });
3296
+ return admit(swap, { ...origin, fundingTxid: txid });
3297
+ }
3298
+ case "ln_receive": {
3299
+ const { request } = accepted;
3300
+ const paymentHash = hex11.encode(request.secrets.paymentHash);
3301
+ const swap = {
3302
+ kind: "lightning_receive",
3303
+ rfqId: request.rfqId,
3304
+ state: "pending",
3305
+ lockupPkScript: request.swapPkScript,
3306
+ lockup: { script: request.script, address: request.address },
3307
+ paymentHash,
3308
+ refundLocktime: request.contractParams.refundLocktime,
3309
+ expectedAmount: request.expectedAmount,
3310
+ createdAt: now,
3311
+ updatedAt: now
3312
+ };
3313
+ return admit(swap, {
3314
+ kind: "lightning_receive",
3315
+ lockupAddress: request.address,
3316
+ profile: {
3317
+ ...rfqSecretsProfile(request.secrets, paymentHash),
3318
+ expectedAmount: request.expectedAmount,
3319
+ payoutAddress: request.payoutAddress
3320
+ },
3321
+ amount: request.expectedAmount
3322
+ });
3323
+ }
3324
+ case "onchain_send": {
3325
+ const { request } = accepted;
3326
+ const paymentHash = hex11.encode(request.secrets.paymentHash);
3327
+ const swap = {
3328
+ kind: "onchain_send",
3329
+ rfqId: request.rfqId,
3330
+ state: "pending",
3331
+ lockupPkScript: request.swapPkScript,
3332
+ lockup: { script: request.script, address: request.address },
3333
+ paymentHash,
3334
+ refundLocktime: request.refundLocktime,
3335
+ htlc: request.htlc,
3336
+ minConfirmations: request.minConfirmations,
3337
+ createdAt: now,
3338
+ updatedAt: now
3339
+ };
3340
+ const origin = {
3341
+ kind: "onchain_send",
3342
+ lockupAddress: request.address,
3343
+ profile: {
3344
+ ...rfqSecretsProfile(request.secrets, paymentHash),
3345
+ ...onchainSendProfile(request)
3346
+ },
3347
+ amount: request.fundAmount
3348
+ };
3349
+ const txid = await fundPersisted(swap, origin, {
3350
+ address: request.address,
3351
+ amount: request.fundAmount
3352
+ });
3353
+ return admit(swap, { ...origin, fundingTxid: txid });
3354
+ }
3355
+ }
3356
+ };
3357
+ return {
3358
+ manager,
3359
+ markets: (useCache) => discoverMarkets({ ...deps.discovery, repository, useCache }),
3360
+ quote,
3361
+ accept,
3362
+ cancel: async (fundingTxid) => {
3363
+ const swaps = await getAssetSwapsOrThrow(repository);
3364
+ const swap = swaps.find((s) => s.id === fundingTxid);
3365
+ if (!swap) throw new Error(`no offer swap with funding txid ${fundingTxid}`);
3366
+ if (swap.status !== "pending") {
3367
+ throw new Error(`offer swap ${fundingTxid} is ${swap.status}, not cancellable`);
3368
+ }
3369
+ await updateAssetSwap(repository, fundingTxid, { status: "cancelling" });
3370
+ await cancelOffer(wallet, swap.offerHex, {
3371
+ repository,
3372
+ fundingTxid
3373
+ });
3374
+ },
3375
+ // Both families, live AND terminal — the offer half reads the whole
3376
+ // stored history, and the RFQ half is every swap the manager holds.
3377
+ // Its history is what `start()` restored plus what this session
3378
+ // accepted; records past `RFQ_SWAP_RETENTION_SECONDS` are pruned.
3379
+ swaps: async () => {
3380
+ const offers = await getAssetSwaps(repository);
3381
+ const rfq = await manager.getAllSwaps();
3382
+ return [
3383
+ ...offers.map((swap) => ({ family: "offer", swap })),
3384
+ ...rfq.map((swap) => ({ family: "rfq", swap }))
3385
+ ];
3386
+ },
3387
+ onUpdate: (listener) => {
3388
+ listeners.add(listener);
3389
+ return () => listeners.delete(listener);
3390
+ },
3391
+ start: async () => {
3392
+ managerDeps.contracts ??= await wallet.getContractManager();
3393
+ await manager.restoreFromRepository();
3394
+ await manager.start();
3395
+ watcher ??= await watchOfferSwaps({
3396
+ wallet,
3397
+ repository,
3398
+ onUpdate: (swap) => notify2({ family: "offer", swap })
3399
+ });
3400
+ },
3401
+ stop: async () => {
3402
+ await manager.stop();
3403
+ watcher?.stop();
3404
+ watcher = void 0;
3405
+ }
3406
+ };
3407
+ }
2926
3408
  export {
2927
3409
  ARKADE_ASSET,
2928
3410
  ARKADE_BTC,
@@ -2983,6 +3465,7 @@ export {
2983
3465
  classifySpend,
2984
3466
  createOffer,
2985
3467
  createRfqSwapRecord,
3468
+ createSwapClient,
2986
3469
  decodeOffer,
2987
3470
  deriveLightningReceive,
2988
3471
  deriveOnchainReceive,
@@ -2997,16 +3480,18 @@ export {
2997
3480
  httpTransport,
2998
3481
  isRfqSwapTerminal,
2999
3482
  isRfqTerminal,
3483
+ lightningReceiveContract,
3000
3484
  lightningReceiveRequest,
3485
+ lightningSendContract,
3001
3486
  lightningSendRequest,
3002
- lightningSendVtxoScript,
3003
3487
  lockupContractParams,
3004
3488
  makeCachedFeedFetch,
3005
3489
  newPreimage,
3006
3490
  newRfqId,
3007
3491
  nextOnchainAction,
3492
+ normalizeRfqSwapRecord,
3493
+ offerContract,
3008
3494
  offerTermsFromQuote,
3009
- offerVtxoScript,
3010
3495
  onchainHtlcScript,
3011
3496
  onchainReceiveRequest,
3012
3497
  onchainSendProfile,
@@ -3017,7 +3502,6 @@ export {
3017
3502
  pushRefundWithoutReceiver,
3018
3503
  readLockupFate,
3019
3504
  rebuildRfqSwap,
3020
- receiveVtxoScript,
3021
3505
  refundIfUnresolved,
3022
3506
  registerLockupContract,
3023
3507
  relayTransport,