@arkade-os/swap 0.0.9 → 0.0.11

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.cjs CHANGED
@@ -411,12 +411,30 @@ var swapPrograms = {
411
411
  wantAsset: import_sdk2.arkade.parseArtifact(swap_want_asset_program_default),
412
412
  wantBtc: import_sdk2.arkade.parseArtifact(swap_want_btc_program_default)
413
413
  };
414
+ function withExitClosure(program, exit) {
415
+ if (!exit) return program;
416
+ return {
417
+ ...program,
418
+ // typed params are authoritative: an undeclared `$exitDelay` fails
419
+ // validateProgram instead of compiling against an unbound value
420
+ params: [...program.params ?? [], { name: "exitDelay", type: "int" }],
421
+ functions: {
422
+ ...program.functions,
423
+ exit: {
424
+ tapscript: { signers: ["$user"], csv: { type: exit.type, value: "$exitDelay" } }
425
+ }
426
+ }
427
+ };
428
+ }
414
429
  function swapProgramBinding(offer, serverPubkey) {
415
430
  if (offer.makerPkScript.length !== FIELDS.makerPkScript.width) {
416
431
  throw new Error("makerPkScript is not a 34-byte taproot scriptPubKey");
417
432
  }
418
433
  return {
419
- program: offer.wantAsset ? swapPrograms.wantAsset : swapPrograms.wantBtc,
434
+ program: withExitClosure(
435
+ offer.wantAsset ? swapPrograms.wantAsset : swapPrograms.wantBtc,
436
+ offer.exitDelay
437
+ ),
420
438
  args: {
421
439
  makerWP: offer.makerPkScript.subarray(2),
422
440
  wantAmount: offer.wantAmount,
@@ -426,7 +444,8 @@ function swapProgramBinding(offer, serverPubkey) {
426
444
  ...offer.wantAsset && {
427
445
  wantAssetTxid: offer.wantAsset.txid.slice().reverse(),
428
446
  wantAssetGroupIndex: offer.wantAsset.groupIndex
429
- }
447
+ },
448
+ ...offer.exitDelay && { exitDelay: offer.exitDelay.value }
430
449
  },
431
450
  keys: {
432
451
  serverKey: serverPubkey,
@@ -447,9 +466,27 @@ var FIELDS = {
447
466
  makerPkScript: { tag: 5, width: 34 },
448
467
  makerPublicKey: { tag: 7, width: 32 },
449
468
  emulatorPubkey: { tag: 8, width: 32 },
450
- offerAsset: { tag: 11, width: void 0 }
469
+ ratioNum: { tag: 9, width: 8 },
470
+ ratioDen: { tag: 10, width: 8 },
471
+ offerAsset: { tag: 11, width: void 0 },
472
+ exitTimelock: { tag: 12, width: 9 }
451
473
  };
474
+ var EXIT_TYPES = ["blocks", "seconds"];
452
475
  var NAMES = Object.fromEntries(Object.entries(FIELDS).map(([k, f]) => [f.tag, k]));
476
+ function u64(name, value) {
477
+ if (value < BigInt(0) || value >> BigInt(64) > BigInt(0)) {
478
+ throw new Error(`${name} does not fit the offer wire format (u64)`);
479
+ }
480
+ const out = new Uint8Array(FIELDS.wantAmount.width);
481
+ new DataView(out.buffer).setBigUint64(0, value, false);
482
+ return out;
483
+ }
484
+ var readU64 = (value) => new DataView(value.buffer, value.byteOffset).getBigUint64(0, false);
485
+ var setRatio = (name, value) => {
486
+ if (value === void 0 || value === BigInt(0)) return void 0;
487
+ if (value < BigInt(0)) throw new Error(`${name} does not fit the offer wire format (u64)`);
488
+ return value;
489
+ };
453
490
  function tlv(type, value) {
454
491
  if (value.length > 65535) throw new Error("TLV value exceeds the u16 length field");
455
492
  return (0, import_utils.concatBytes)(Uint8Array.of(type, value.length >> 8 & 255, value.length & 255), value);
@@ -468,24 +505,45 @@ function encodeOffer(offer) {
468
505
  throw new Error(`${name} must be ${FIELDS[name].width} bytes`);
469
506
  }
470
507
  }
471
- if (offer.wantAmount < BigInt(0) || offer.wantAmount >> BigInt(64) > BigInt(0)) {
472
- throw new Error("wantAmount does not fit the offer wire format (u64)");
508
+ const ratioNum = setRatio("ratioNum", offer.ratioNum);
509
+ const ratioDen = setRatio("ratioDen", offer.ratioDen);
510
+ if (ratioNum === void 0 !== (ratioDen === void 0)) {
511
+ throw new Error("offer must carry both ratioNum and ratioDen, or neither");
473
512
  }
474
- const amount = new Uint8Array(FIELDS.wantAmount.width);
475
- new DataView(amount.buffer).setBigUint64(0, offer.wantAmount, false);
476
513
  const recs = [
477
514
  tlv(FIELDS.swapPkScript.tag, offer.swapPkScript),
478
- tlv(FIELDS.wantAmount.tag, amount)
515
+ tlv(FIELDS.wantAmount.tag, u64("wantAmount", offer.wantAmount))
479
516
  ];
480
517
  if (offer.wantAsset) recs.push(tlv(FIELDS.wantAsset.tag, offer.wantAsset.serialize()));
518
+ if (ratioNum !== void 0) recs.push(tlv(FIELDS.ratioNum.tag, u64("ratioNum", ratioNum)));
519
+ if (ratioDen !== void 0) recs.push(tlv(FIELDS.ratioDen.tag, u64("ratioDen", ratioDen)));
481
520
  if (offer.offerAsset) recs.push(tlv(FIELDS.offerAsset.tag, offer.offerAsset.serialize()));
482
521
  recs.push(
483
522
  tlv(FIELDS.makerPkScript.tag, offer.makerPkScript),
484
523
  tlv(FIELDS.makerPublicKey.tag, offer.makerPublicKey),
485
524
  tlv(FIELDS.emulatorPubkey.tag, offer.emulatorPubkey)
486
525
  );
526
+ if (offer.exitDelay) recs.push(tlv(FIELDS.exitTimelock.tag, encodeExitDelay(offer.exitDelay)));
487
527
  return (0, import_utils.concatBytes)(...recs);
488
528
  }
529
+ function encodeExitDelay(exit) {
530
+ return (0, import_utils.concatBytes)(
531
+ Uint8Array.of(EXIT_TYPES.indexOf(assertExitDelay(exit).type)),
532
+ u64("exitDelay", exit.value)
533
+ );
534
+ }
535
+ function assertExitDelay(exit) {
536
+ if (EXIT_TYPES.indexOf(exit.type) < 0) {
537
+ throw new Error(`unknown exitDelay locktime type: ${exit.type}`);
538
+ }
539
+ if (exit.value <= BigInt(0)) {
540
+ throw new Error("exitDelay must be a positive relative locktime");
541
+ }
542
+ if (exit.value >> BigInt(32) > BigInt(0)) {
543
+ throw new Error("exitDelay does not fit the locktime field (u32)");
544
+ }
545
+ return exit;
546
+ }
489
547
  function decodeOffer(data) {
490
548
  const fields = {};
491
549
  let off = 0;
@@ -505,6 +563,12 @@ function decodeOffer(data) {
505
563
  for (const name of ["wantAsset", "offerAsset"]) {
506
564
  if (fields[name]?.length === 0) throw new Error(`missing/invalid ${name}`);
507
565
  }
566
+ for (const [name, value] of Object.entries(fields)) {
567
+ const width = FIELDS[name].width;
568
+ if (width !== void 0 && value.length !== width) {
569
+ throw new Error(`missing/invalid ${name}`);
570
+ }
571
+ }
508
572
  const need = (name) => {
509
573
  const v = fields[name];
510
574
  const len = FIELDS[name].width;
@@ -516,16 +580,36 @@ function decodeOffer(data) {
516
580
  if (Boolean(fields.wantAsset) === Boolean(fields.offerAsset)) {
517
581
  throw new Error("offer must carry exactly one of wantAsset or offerAsset");
518
582
  }
583
+ const readRatio = (name) => {
584
+ const raw = fields[name];
585
+ if (!raw) return void 0;
586
+ const value = readU64(raw);
587
+ if (value === BigInt(0)) throw new Error(`missing/invalid ${name}`);
588
+ return value;
589
+ };
590
+ const ratioNum = readRatio("ratioNum");
591
+ const ratioDen = readRatio("ratioDen");
592
+ if (ratioNum === void 0 !== (ratioDen === void 0)) {
593
+ throw new Error("offer must carry both ratioNum and ratioDen, or neither");
594
+ }
519
595
  return {
520
596
  swapPkScript: need("swapPkScript"),
521
- wantAmount: new DataView(amount.buffer, amount.byteOffset).getBigUint64(0, false),
597
+ wantAmount: readU64(amount),
522
598
  ...fields.wantAsset && { wantAsset: import_sdk2.asset.AssetId.fromBytes(fields.wantAsset) },
523
599
  ...fields.offerAsset && { offerAsset: import_sdk2.asset.AssetId.fromBytes(fields.offerAsset) },
524
600
  makerPkScript: need("makerPkScript"),
525
601
  makerPublicKey: need("makerPublicKey"),
526
- emulatorPubkey: need("emulatorPubkey")
602
+ emulatorPubkey: need("emulatorPubkey"),
603
+ ...ratioNum !== void 0 && { ratioNum },
604
+ ...ratioDen !== void 0 && { ratioDen },
605
+ ...fields.exitTimelock && { exitDelay: decodeExitDelay(fields.exitTimelock) }
527
606
  };
528
607
  }
608
+ function decodeExitDelay(value) {
609
+ const type = EXIT_TYPES[value[0]];
610
+ if (!type) throw new Error(`unknown exitDelay locktime type: 0x${value[0].toString(16)}`);
611
+ return { type, value: readU64(value.subarray(1)) };
612
+ }
529
613
  var OFFER_CONTRACT_LABEL = "Arkade swap offer";
530
614
  var OFFER_CONTRACT_KIND = "asset-swap-offer";
531
615
  async function registerOfferContract(wallet, arkServerUrl, network, binding, serverPubkey, expectedPkScript) {
@@ -551,6 +635,14 @@ async function registerOfferContract(wallet, arkServerUrl, network, binding, ser
551
635
  });
552
636
  await promoteOfferContract(contractManager, import_base2.hex.encode(expectedPkScript));
553
637
  }
638
+ function serverExitDelay(delay) {
639
+ if (typeof delay !== "bigint" || delay <= BigInt(0)) {
640
+ throw new Error(
641
+ "the server reports no usable unilateralExitDelay; pass `exitDelay` to set the offer's exit closure explicitly, or `noExit: true` to publish without one"
642
+ );
643
+ }
644
+ return assertExitDelay({ value: delay, type: delay < BigInt(512) ? "blocks" : "seconds" });
645
+ }
554
646
  async function createOffer(wallet, arkServerUrl, params) {
555
647
  if (Boolean(params.wantAsset) === Boolean(params.offerAsset)) {
556
648
  throw new Error("set exactly one of wantAsset (BTC->asset) or offerAsset (asset->BTC)");
@@ -571,7 +663,11 @@ async function createOffer(wallet, arkServerUrl, params) {
571
663
  offerAsset: params.offerAsset,
572
664
  makerPkScript: import_sdk2.ArkAddress.decode(makerAddress).pkScript,
573
665
  makerPublicKey,
574
- emulatorPubkey: emuKey
666
+ emulatorPubkey: emuKey,
667
+ // checked HERE, before the covenant is derived and registered below:
668
+ // deferring it to `encodeOffer` leaves a registered contract behind for
669
+ // an offer that then fails to encode. @see assertExitDelay
670
+ exitDelay: params.noExit ? void 0 : params.exitDelay ? assertExitDelay(params.exitDelay) : serverExitDelay(info.unilateralExitDelay)
575
671
  };
576
672
  const script = offerVtxoScript(binding, serverPubKey);
577
673
  const offer = { ...binding, swapPkScript: script.pkScript };
@@ -609,8 +705,8 @@ async function cancelOffer(wallet, arkServerUrl, offerHex, opts) {
609
705
  // script and the payout script comes from wallet.getAddress(), so the
610
706
  // client's network (which only shapes address derivation) is unused here
611
707
  });
612
- const serverKey = swapAddress ? import_sdk2.ArkAddress.decode(swapAddress).serverPubKey : client.serverKey;
613
- const { program, args, keys } = swapProgramBinding(offer, serverKey);
708
+ const operatorPubkey = swapAddress ? import_sdk2.ArkAddress.decode(swapAddress).serverPubKey : client.serverKey;
709
+ const { program, args, keys } = swapProgramBinding(offer, operatorPubkey);
614
710
  const rebuilt = new import_sdk2.arkade.ArkadeProgramScript(program, args, keys);
615
711
  if (import_base2.hex.encode(rebuilt.pkScript) !== import_base2.hex.encode(offer.swapPkScript)) {
616
712
  throw new Error(
@@ -1512,7 +1608,9 @@ function classifySpend(offer, serverPubkey, spendTx, deposit) {
1512
1608
  const script = offerVtxoScript(offer, serverPubkey);
1513
1609
  if (import_base6.hex.encode(script.pkScript) !== import_base6.hex.encode(offer.swapPkScript)) return "indeterminate";
1514
1610
  leaves = {
1515
- cancel: script.functionByName("cancel")?.leafScript,
1611
+ // both routes that hand the deposit back; `exit` is absent on an
1612
+ // offer that carries no exit closure, and drops out here
1613
+ returned: ["cancel", "exit"].map((name) => script.functionByName(name)?.leafScript).filter((leaf) => leaf !== void 0),
1516
1614
  fulfill: script.functionByName("fulfill")?.leafScript
1517
1615
  };
1518
1616
  } catch {
@@ -1524,7 +1622,7 @@ function classifySpend(offer, serverPubkey, spendTx, deposit) {
1524
1622
  if (import_base6.hex.encode(input.txid) !== deposit.txid) continue;
1525
1623
  for (const leaf of input.tapLeafScript ?? []) {
1526
1624
  const spent = import_base6.hex.encode((0, import_sdk6.scriptFromTapLeafScript)(leaf));
1527
- if (leaves.cancel && spent === import_base6.hex.encode(leaves.cancel)) return "cancelled";
1625
+ if (leaves.returned.some((back) => spent === import_base6.hex.encode(back))) return "cancelled";
1528
1626
  if (leaves.fulfill && spent === import_base6.hex.encode(leaves.fulfill)) return "fulfilled";
1529
1627
  }
1530
1628
  }
@@ -1918,8 +2016,22 @@ var assertPairLength = (pair) => {
1918
2016
  };
1919
2017
  var verifyLockupAddress = (quote, derivedAddress) => {
1920
2018
  const quoted = quote.profile?.lockup_address;
1921
- if (derivedAddress !== quoted) throw new AddressMismatch(derivedAddress, quoted);
1922
- return derivedAddress;
2019
+ const candidates = Array.isArray(derivedAddress) ? derivedAddress : [derivedAddress];
2020
+ const matched = candidates.find((address) => address === quoted);
2021
+ if (matched === void 0) throw new AddressMismatch(candidates, quoted);
2022
+ return matched;
2023
+ };
2024
+ var LOCKUP_SHAPE_VARIANTS = [void 0, "preTimelockedRefund"];
2025
+ var matchQuotedLockup = (quote, hrp, serverPubkey, build) => {
2026
+ const candidates = LOCKUP_SHAPE_VARIANTS.map((legacy) => {
2027
+ const script = build(legacy);
2028
+ return { script, address: script.address(hrp, serverPubkey).encode(), legacy };
2029
+ });
2030
+ const matchedAddress = verifyLockupAddress(
2031
+ quote,
2032
+ candidates.map((candidate) => candidate.address)
2033
+ );
2034
+ return candidates.find((candidate) => candidate.address === matchedAddress);
1923
2035
  };
1924
2036
  var assertFundable = (input) => {
1925
2037
  const fail = (reason, message) => {
@@ -2121,13 +2233,11 @@ function lightningSendVtxoScript(params) {
2121
2233
  unilateralRefundWithoutReceiverDelay: seconds(
2122
2234
  unilateralRefundWithoutReceiverDelay(params.claimDelay)
2123
2235
  ),
2124
- nonInteractiveClaim: {
2236
+ nonInteractiveParameters: {
2125
2237
  receiverPkScript: params.receiverPkScript,
2126
- emulatorPubkey: params.emulatorPubkey
2127
- },
2128
- nonInteractiveRefund: {
2129
2238
  senderPkScript: params.refundPkScript,
2130
- emulatorPubkey: params.emulatorPubkey
2239
+ emulatorPubkey: params.emulatorPubkey,
2240
+ ...params.legacy !== void 0 && { legacy: params.legacy }
2131
2241
  }
2132
2242
  });
2133
2243
  }
@@ -2135,10 +2245,8 @@ async function requestLightningSend(wallet, arkServerUrl, transport, params) {
2135
2245
  const rfqId = params.rfqId ?? newRfqId();
2136
2246
  const secrets = await (0, import_sdk10.provisionRefundKey)(wallet);
2137
2247
  const senderPubkey = secrets.pubkey;
2138
- const [info, refundAddress] = await Promise.all([
2139
- new import_sdk9.RestArkProvider(arkServerUrl).getInfo(),
2140
- wallet.getAddress()
2141
- ]);
2248
+ const refundAddress = secrets.address;
2249
+ const info = await new import_sdk9.RestArkProvider(arkServerUrl).getInfo();
2142
2250
  const quote = await transport.requestQuote(
2143
2251
  lightningSendRequest({ rfqId, invoice: params.invoice.raw, refundAddress, senderPubkey })
2144
2252
  );
@@ -2173,11 +2281,20 @@ async function requestLightningSend(wallet, arkServerUrl, transport, params) {
2173
2281
  ),
2174
2282
  senderPubkey,
2175
2283
  receiverPkScript: solverHex(receiverPkScriptHex, "profile.receiver_pk_script"),
2176
- refundPkScript: import_sdk9.ArkAddress.decode(refundAddress).pkScript
2284
+ refundPkScript: secrets.pkScript
2285
+ };
2286
+ const matched = matchQuotedLockup(
2287
+ quote,
2288
+ network.hrp,
2289
+ serverPubkey,
2290
+ (legacy) => lightningSendVtxoScript({ ...treeParams, ...legacy !== void 0 && { legacy } })
2291
+ );
2292
+ const script = matched.script;
2293
+ const address = matched.address;
2294
+ const matchedTreeParams = {
2295
+ ...treeParams,
2296
+ ...matched.legacy !== void 0 && { legacy: matched.legacy }
2177
2297
  };
2178
- const script = lightningSendVtxoScript(treeParams);
2179
- const address = script.address(network.hrp, serverPubkey).encode();
2180
- verifyLockupAddress(quote, address);
2181
2298
  assertFundable({
2182
2299
  quote,
2183
2300
  invoiceExpiresAt: params.invoice.expiresAt,
@@ -2196,7 +2313,7 @@ async function requestLightningSend(wallet, arkServerUrl, transport, params) {
2196
2313
  refundAddress,
2197
2314
  senderPubkey,
2198
2315
  secrets,
2199
- treeParams
2316
+ treeParams: matchedTreeParams
2200
2317
  };
2201
2318
  }
2202
2319
  var offerTermsFromQuote = (quote, assets) => {
@@ -2261,7 +2378,7 @@ function deriveOnchainSend(input) {
2261
2378
  if (refundLocktime === void 0 || htlcPubkey === void 0 || htlcLocktime === void 0 || minConfirmations === void 0 || receiverPkScriptHex === void 0) {
2262
2379
  throw new Error("onchain-send quote is missing a binding field");
2263
2380
  }
2264
- const script = lightningSendVtxoScript({
2381
+ const treeParams = {
2265
2382
  solverPubkey: (0, import_sdk9.toXOnly)(import_base10.hex.decode(quote.solver_pubkey), "solver key"),
2266
2383
  refundLocktime,
2267
2384
  serverPubkey: input.serverPubkey,
@@ -2271,9 +2388,13 @@ function deriveOnchainSend(input) {
2271
2388
  senderPubkey: input.senderPubkey,
2272
2389
  receiverPkScript: solverHex(receiverPkScriptHex, "profile.receiver_pk_script"),
2273
2390
  refundPkScript: import_sdk9.ArkAddress.decode(input.refundAddress).pkScript
2274
- });
2275
- const address = script.address(input.hrp, input.serverPubkey).encode();
2276
- verifyLockupAddress(quote, address);
2391
+ };
2392
+ const { script, address } = matchQuotedLockup(
2393
+ quote,
2394
+ input.hrp,
2395
+ input.serverPubkey,
2396
+ (legacy) => lightningSendVtxoScript({ ...treeParams, ...legacy !== void 0 && { legacy } })
2397
+ );
2277
2398
  const htlcParams = {
2278
2399
  paymentHash: input.paymentHash,
2279
2400
  claimKey: input.payoutPubkey,
@@ -2448,13 +2569,11 @@ function receiveVtxoScript(params) {
2448
2569
  unilateralRefundWithoutReceiverDelay: seconds(
2449
2570
  unilateralRefundWithoutReceiverDelay(params.claimDelay)
2450
2571
  ),
2451
- nonInteractiveClaim: {
2572
+ nonInteractiveParameters: {
2452
2573
  receiverPkScript: params.payoutPkScript,
2453
- emulatorPubkey: params.emulatorPubkey
2454
- },
2455
- nonInteractiveRefund: {
2456
2574
  senderPkScript: params.solverRefundPkScript,
2457
- emulatorPubkey: params.emulatorPubkey
2575
+ emulatorPubkey: params.emulatorPubkey,
2576
+ ...params.legacy !== void 0 && { legacy: params.legacy }
2458
2577
  }
2459
2578
  });
2460
2579
  }
@@ -2478,10 +2597,23 @@ function deriveLightningReceive(input) {
2478
2597
  payoutPubkey: input.payoutPubkey,
2479
2598
  payoutPkScript: import_sdk9.ArkAddress.decode(input.payoutAddress).pkScript
2480
2599
  };
2481
- const script = receiveVtxoScript(treeParams);
2482
- const address = script.address(input.hrp, input.serverPubkey).encode();
2483
- verifyLockupAddress(quote, address);
2484
- return { address, swapPkScript: script.pkScript, script, invoice, refundLocktime, treeParams };
2600
+ const matched = matchQuotedLockup(
2601
+ quote,
2602
+ input.hrp,
2603
+ input.serverPubkey,
2604
+ (legacy) => receiveVtxoScript({ ...treeParams, ...legacy !== void 0 && { legacy } })
2605
+ );
2606
+ return {
2607
+ address: matched.address,
2608
+ swapPkScript: matched.script.pkScript,
2609
+ script: matched.script,
2610
+ invoice,
2611
+ refundLocktime,
2612
+ treeParams: {
2613
+ ...treeParams,
2614
+ ...matched.legacy !== void 0 && { legacy: matched.legacy }
2615
+ }
2616
+ };
2485
2617
  }
2486
2618
  async function requestLightningReceive(wallet, arkServerUrl, transport, params) {
2487
2619
  const rfqId = params.rfqId ?? newRfqId();
@@ -2569,7 +2701,7 @@ function deriveOnchainReceive(input) {
2569
2701
  if (refundLocktime === void 0 || claimPubkey === void 0 || htlcLocktime === void 0 || minConfirmations === void 0 || solverRefundPkScriptHex === void 0) {
2570
2702
  throw new Error("onchain-receive quote is missing a binding field");
2571
2703
  }
2572
- const script = receiveVtxoScript({
2704
+ const treeParams = {
2573
2705
  solverPubkey: (0, import_sdk9.toXOnly)(import_base10.hex.decode(quote.solver_pubkey), "solver key"),
2574
2706
  refundLocktime,
2575
2707
  serverPubkey: input.serverPubkey,
@@ -2579,9 +2711,13 @@ function deriveOnchainReceive(input) {
2579
2711
  solverRefundPkScript: solverHex(solverRefundPkScriptHex, "profile.solver_refund_pk_script"),
2580
2712
  payoutPubkey: input.payoutPubkey,
2581
2713
  payoutPkScript: import_sdk9.ArkAddress.decode(input.payoutAddress).pkScript
2582
- });
2583
- const address = script.address(input.hrp, input.serverPubkey).encode();
2584
- verifyLockupAddress(quote, address);
2714
+ };
2715
+ const { script, address } = matchQuotedLockup(
2716
+ quote,
2717
+ input.hrp,
2718
+ input.serverPubkey,
2719
+ (legacy) => receiveVtxoScript({ ...treeParams, ...legacy !== void 0 && { legacy } })
2720
+ );
2585
2721
  const htlc = onchainHtlcScript(
2586
2722
  {
2587
2723
  paymentHash: input.paymentHash,
@@ -2747,6 +2883,7 @@ async function findLockupVtxos(indexer, swapPkScript) {
2747
2883
  [recoverable.vtxos ?? [], true]
2748
2884
  ]) {
2749
2885
  for (const vtxo of vtxos) {
2886
+ if (vtxo.isUnrolled) continue;
2750
2887
  const key = `${vtxo.txid}:${vtxo.vout}`;
2751
2888
  if (seen.has(key)) continue;
2752
2889
  seen.add(key);
@@ -2769,10 +2906,17 @@ async function readLockupFate(indexer, input) {
2769
2906
  const { vtxos } = await indexer.getVtxos({ scripts: [import_base11.hex.encode(input.swapPkScript)] });
2770
2907
  const all = vtxos ?? [];
2771
2908
  if (all.length === 0) return { fate: "unknown" };
2909
+ const exited = all.filter((vtxo) => vtxo.isUnrolled && !(0, import_sdk11.hasTerminalSpend)(vtxo));
2910
+ if (exited.length > 0) {
2911
+ return {
2912
+ fate: "exited",
2913
+ outpoints: exited.map((vtxo) => ({ txid: vtxo.txid, vout: vtxo.vout }))
2914
+ };
2915
+ }
2772
2916
  const spentBy = /* @__PURE__ */ new Map();
2773
2917
  let everySpendNamed = true;
2774
2918
  for (const vtxo of all) {
2775
- if (!vtxo.isSpent && !vtxo.spentBy && !vtxo.settledBy) return { fate: "open" };
2919
+ if (!(0, import_sdk11.hasTerminalSpend)(vtxo)) return { fate: "open" };
2776
2920
  if (vtxo.spentBy)
2777
2921
  spentBy.set(vtxo.spentBy, {
2778
2922
  checkpointTxid: vtxo.spentBy,
@@ -2814,10 +2958,10 @@ async function pushRefundWithoutReceiver(ark, input) {
2814
2958
  input.script.options.refundLocktime
2815
2959
  );
2816
2960
  }
2817
- const refundPkScript = input.refundPkScript ?? input.script.options.nonInteractiveRefund?.senderPkScript;
2961
+ const refundPkScript = input.refundPkScript ?? input.script.options.nonInteractiveParameters?.senderPkScript;
2818
2962
  if (!refundPkScript) {
2819
2963
  throw new Error(
2820
- "no refund destination: the contract carries no nonInteractiveRefund leaf, so pass refundPkScript explicitly"
2964
+ "no refund destination: the contract carries no emulator covenant suite, so pass refundPkScript explicitly"
2821
2965
  );
2822
2966
  }
2823
2967
  const info = await ark.getInfo();
@@ -2869,6 +3013,21 @@ async function refundIfUnresolved(transport, ark, indexer, input) {
2869
3013
  const status = await transport.status(input.rfqId);
2870
3014
  if (status && isResolved(status.state)) return { outcome: "resolved", status };
2871
3015
  if (now() >= input.refundLocktime) {
3016
+ let fate = { fate: "unknown" };
3017
+ try {
3018
+ fate = await readLockupFate(indexer, {
3019
+ swapPkScript: input.script.pkScript,
3020
+ paymentHash: input.paymentHash
3021
+ });
3022
+ } catch {
3023
+ }
3024
+ if (fate.fate === "exited") {
3025
+ return {
3026
+ outcome: "exited",
3027
+ outpoints: fate.outpoints.map((o) => `${o.txid}:${o.vout}`),
3028
+ status
3029
+ };
3030
+ }
2872
3031
  const vtxos = await findLockupVtxos(indexer, input.script.pkScript);
2873
3032
  if (vtxos.length === 0) return { outcome: "nothing_to_refund", status };
2874
3033
  try {
@@ -3660,12 +3819,36 @@ var RfqSwapManager = class {
3660
3819
  this.setState(swap, fate.fate === "claimed" ? "settled" : "refunded");
3661
3820
  return;
3662
3821
  }
3663
- if (swap.kind === "lightning_receive") return this.driveReceiveClaim(swap);
3822
+ if (swap.kind === "lightning_receive") {
3823
+ return fate.fate === "exited" ? this.blockExitedLockup(swap, fate) : this.driveReceiveClaim(swap);
3824
+ }
3664
3825
  if (swap.kind === "onchain_send" && swap.state !== "claimed") {
3665
3826
  if (await this.driveOnchain(swap) === "handled") return;
3666
3827
  }
3828
+ if (fate.fate === "exited") {
3829
+ const claiming = swap.state === "claimable" || swap.state === "claimed";
3830
+ if (this.config.now() < swap.refundLocktime && claiming) return;
3831
+ return this.blockExitedLockup(swap, fate);
3832
+ }
3667
3833
  await this.driveArkadeRefund(swap);
3668
3834
  }
3835
+ /**
3836
+ * The lockup was unilaterally exited: its outputs sit onchain under the
3837
+ * VHTLC script, where no offchain claim or refund can reach them.
3838
+ *
3839
+ * `needs_counterparty` rather than a terminal state, because the money still
3840
+ * needs action and the swap can still end either way — an onchain claim can
3841
+ * reveal the preimage, an onchain refund can return it — and that state is
3842
+ * documented as re-checked every pass. It must be set from HERE and not from
3843
+ * inside `driveArkadeRefund`, whose two `unblock` calls would lift it again
3844
+ * on the very next pass.
3845
+ */
3846
+ blockExitedLockup(swap, fate) {
3847
+ this.block(
3848
+ swap,
3849
+ `the lockup was unilaterally exited (${fate.outpoints.length} output(s) onchain), so no offchain spend can move it \u2014 complete the unroll and spend it onchain`
3850
+ );
3851
+ }
3669
3852
  /**
3670
3853
  * The receive leg's whole state machine: claim the solver-funded lockup
3671
3854
  * while the window is open, and recognise the shapes in which it can be
package/dist/index.d.cts CHANGED
@@ -1,9 +1,9 @@
1
- import { ProvisionedKey, ProvisionedClaimSecret, asset, IWallet, arkade, RestIndexerProvider, Transaction, IContractManager, VHTLC, Identity, ActivityResolver } from '@arkade-os/sdk';
2
- import { S as SwapSecretsProjection, R as RfqSwapRecord, A as AssetSwapRepository, a as AssetSwap, M as MarketsCacheEntry, b as RefundArkProvider, c as RefundIndexer, L as LockupVtxo, d as RfqSwap, e as ArkadeRefundResult, f as LockupSpendIndexer, g as RfqSwapState } from './repository-DEHLtD9l.cjs';
3
- export { h as AssetSwapStatus, i as AvailableRfqSwapManagerCallbacks, B as BTC_ASSET_ID, I as InMemoryAssetSwapRepository, j as LightningReceiveSwap, k as LightningSendSwap, l as LockupFate, m as LockupNeedsRecoveryError, n as LockupParams, o as LockupSpend, O as OnchainSendAction, p as OnchainSendSwap, P as PersistableRfqSwap, q as PreimageBlockedReason, r as PreimageNotRecoverableError, s as REFUND_MTP_LAG_SECONDS, t as RFQ_RESOLVED_STATES, u as RFQ_SWAP_RETENTION_SECONDS, v as RFQ_SWAP_TERMINAL_STATES, w as RefundOutcome, x as RfqRestoreFailure, y as RfqRestoreOptions, z as RfqRestoreResult, C as RfqSwapActionName, D as RfqSwapLockup, E as RfqSwapManager, F as RfqSwapManagerCallbacks, G as RfqSwapManagerConfig, H as RfqSwapManagerDeps, J as RfqSwapManagerEvents, K as RfqSwapOrigin, N as RfqSwapOriginRequired, Q as RfqSwapOutcome, T as RfqSwapRecordStore, U as SwapContractRegistry, V as addAssetSwap, W as awaitRfqResolution, X as createRfqSwapRecord, Y as findLockupVtxos, Z as getAssetSwaps, _ as getAssetSwapsOrThrow, $ as isRfqSwapTerminal, a0 as isRfqTerminal, a1 as nextOnchainAction, a2 as preimageForSwapRecord, a3 as pushRefundWithoutReceiver, a4 as readLockupFate, a5 as rebuildRfqSwap, a6 as refundIfUnresolved, a7 as rfqSwapOriginOf, a8 as shouldRetainRfqSwap, a9 as swapSecretsToRecord, aa as updateAssetSwap, ab as updateAssetSwapBestEffort, ac as updateRfqSwapRecord } from './repository-DEHLtD9l.cjs';
1
+ import { ProvisionedKey, ProvisionedClaimSecret, asset, RelativeTimelock, IWallet, arkade, RestIndexerProvider, Transaction, IContractManager, VHTLC, Identity, ActivityResolver } from '@arkade-os/sdk';
2
+ import { S as SwapSecretsProjection, R as RfqSwapRecord, A as AssetSwapRepository, a as AssetSwap, M as MarketsCacheEntry, b as RefundArkProvider, c as RefundIndexer, L as LockupVtxo, d as RfqSwap, e as ArkadeRefundResult, f as LockupSpendIndexer, g as RfqSwapState } from './repository-B02vsgcV.cjs';
3
+ export { h as AssetSwapStatus, i as AvailableRfqSwapManagerCallbacks, B as BTC_ASSET_ID, I as InMemoryAssetSwapRepository, j as LightningReceiveSwap, k as LightningSendSwap, l as LockupFate, m as LockupNeedsRecoveryError, n as LockupParams, o as LockupSpend, O as OnchainSendAction, p as OnchainSendSwap, P as PersistableRfqSwap, q as PreimageBlockedReason, r as PreimageNotRecoverableError, s as REFUND_MTP_LAG_SECONDS, t as RFQ_RESOLVED_STATES, u as RFQ_SWAP_RETENTION_SECONDS, v as RFQ_SWAP_TERMINAL_STATES, w as RefundOutcome, x as RfqRestoreFailure, y as RfqRestoreOptions, z as RfqRestoreResult, C as RfqSwapActionName, D as RfqSwapLockup, E as RfqSwapManager, F as RfqSwapManagerCallbacks, G as RfqSwapManagerConfig, H as RfqSwapManagerDeps, J as RfqSwapManagerEvents, K as RfqSwapOrigin, N as RfqSwapOriginRequired, Q as RfqSwapOutcome, T as RfqSwapRecordStore, U as SwapContractRegistry, V as addAssetSwap, W as awaitRfqResolution, X as createRfqSwapRecord, Y as findLockupVtxos, Z as getAssetSwaps, _ as getAssetSwapsOrThrow, $ as isRfqSwapTerminal, a0 as isRfqTerminal, a1 as nextOnchainAction, a2 as preimageForSwapRecord, a3 as pushRefundWithoutReceiver, a4 as readLockupFate, a5 as rebuildRfqSwap, a6 as refundIfUnresolved, a7 as rfqSwapOriginOf, a8 as shouldRetainRfqSwap, a9 as swapSecretsToRecord, aa as updateAssetSwap, ab as updateAssetSwapBestEffort, ac as updateRfqSwapRecord } from './repository-B02vsgcV.cjs';
4
4
  import { Network, LocalCardInput, DiscoveredMarket, Side, OfferPlan } from '@arkade-os/solver-discovery';
5
- import { O as OnchainNetwork, a as OnchainHtlc, b as OnchainHtlcParams } from './rfq-hbzhTWHT.cjs';
6
- export { A as ARKADE_ASSET, c as ARKADE_BTC, d as AddressMismatch, C as ChainSource, e as ChainUtxo, H as HtlcUtxo, I as InvoiceFacts, L as LIGHTNING_BTC, f as LIGHTNING_RECEIVE_PAIR, g as LIGHTNING_SEND_PAIR, h as LOCKTIME_THRESHOLD, i as LightningReceiveTreeParams, j as LightningSendTreeParams, M as MAX_MIN_CONFIRMATIONS, k as MIN_CLAIM_WINDOW_SECONDS, l as MIN_HEADROOM_SECONDS, m as ONCHAIN_BTC, n as ONCHAIN_CLAIM_MARGIN_SECONDS, o as ONCHAIN_DUST_SATS, p as ONCHAIN_ORDER_MARGIN_SECONDS, q as ONCHAIN_RECEIVE_PAIR, r as ONCHAIN_SECONDS_PER_BLOCK, s as ONCHAIN_SEND_PAIR, t as OnchainHtlcPhase, R as RFQ_TERMINAL_STATES, u as RelaySocket, v as RfqQuote, w as RfqRefusalReason, x as RfqStatus, y as RfqTransport, S as SOLO_REFUND_HEADROOM_SECONDS, z as SwapRefusal, B as arkadeAssetLeg, D as arkadeSwapRequest, E as assertFundable, F as assertReceivable, G as awaitOnchainFill, J as buildHtlcClaim, K as buildHtlcRefund, N as claimOnchainFill, P as classifyOnchainHtlc, Q as deriveLightningReceive, T as deriveOnchainReceive, U as deriveOnchainSend, V as extractPreimage, W as httpTransport, X as lightningReceiveRequest, Y as lightningSendRequest, Z as lightningSendVtxoScript, _ as newPreimage, $ as newRfqId, a0 as offerTermsFromQuote, a1 as onchainHtlcScript, a2 as onchainReceiveRequest, a3 as onchainSendRequest, a4 as paymentHashOf, a5 as receiveVtxoScript, a6 as relayTransport, a7 as requestLightningReceive, a8 as requestLightningSend, a9 as requestOnchainReceive, aa as requestOnchainSend, ab as rfqPair, ac as unilateralClaimDelay, ad as unilateralRefundDelay, ae as unilateralRefundWithoutReceiverDelay, af as verifyLockupAddress, ag as verifyReceiveInvoice } from './rfq-hbzhTWHT.cjs';
5
+ import { O as OnchainNetwork, a as OnchainHtlc, b as OnchainHtlcParams } from './rfq-DkckzRKK.cjs';
6
+ export { A as ARKADE_ASSET, c as ARKADE_BTC, d as AddressMismatch, C as ChainSource, e as ChainUtxo, H as HtlcUtxo, I as InvoiceFacts, L as LIGHTNING_BTC, f as LIGHTNING_RECEIVE_PAIR, g as LIGHTNING_SEND_PAIR, h as LOCKTIME_THRESHOLD, i as LightningReceiveTreeParams, j as LightningSendTreeParams, M as MAX_MIN_CONFIRMATIONS, k as MIN_CLAIM_WINDOW_SECONDS, l as MIN_HEADROOM_SECONDS, m as ONCHAIN_BTC, n as ONCHAIN_CLAIM_MARGIN_SECONDS, o as ONCHAIN_DUST_SATS, p as ONCHAIN_ORDER_MARGIN_SECONDS, q as ONCHAIN_RECEIVE_PAIR, r as ONCHAIN_SECONDS_PER_BLOCK, s as ONCHAIN_SEND_PAIR, t as OnchainHtlcPhase, R as RFQ_TERMINAL_STATES, u as RelaySocket, v as RfqQuote, w as RfqRefusalReason, x as RfqStatus, y as RfqTransport, S as SOLO_REFUND_HEADROOM_SECONDS, z as SwapRefusal, B as arkadeAssetLeg, D as arkadeSwapRequest, E as assertFundable, F as assertReceivable, G as awaitOnchainFill, J as buildHtlcClaim, K as buildHtlcRefund, N as claimOnchainFill, P as classifyOnchainHtlc, Q as deriveLightningReceive, T as deriveOnchainReceive, U as deriveOnchainSend, V as extractPreimage, W as httpTransport, X as lightningReceiveRequest, Y as lightningSendRequest, Z as lightningSendVtxoScript, _ as newPreimage, $ as newRfqId, a0 as offerTermsFromQuote, a1 as onchainHtlcScript, a2 as onchainReceiveRequest, a3 as onchainSendRequest, a4 as paymentHashOf, a5 as receiveVtxoScript, a6 as relayTransport, a7 as requestLightningReceive, a8 as requestLightningSend, a9 as requestOnchainReceive, aa as requestOnchainSend, ab as rfqPair, ac as unilateralClaimDelay, ad as unilateralRefundDelay, ae as unilateralRefundWithoutReceiverDelay, af as verifyLockupAddress, ag as verifyReceiveInvoice } from './rfq-DkckzRKK.cjs';
7
7
 
8
8
  /**
9
9
  * Which wallet key signs this leg. Stored at `profile.signer`.
@@ -182,7 +182,18 @@ declare function onchainSendProfile(result: {
182
182
  minConfirmations: number;
183
183
  }): Omit<OnchainSendProfile, "signer" | "hashlock">;
184
184
 
185
- /** The contracts — pure data, shared verbatim with any other implementation. */
185
+ /**
186
+ * The contracts, one per WANT side — pure data, shared verbatim with any other
187
+ * implementation.
188
+ *
189
+ * **These are the base: an offer carrying an exit delay compiles to a third
190
+ * closure that is not in either file.** The tree is not fixed-shape — the
191
+ * protocol defines the exit as `iff ExitDelay`, and solverd appends it the same
192
+ * way (`pkg/swap/contract/offer.go`, `VtxoScript`) — so it cannot be a function
193
+ * in a static artifact without splitting these into one file per exit variant.
194
+ * {@link withExitClosure} owns that step, and the golden in `offer.test.ts`
195
+ * pins the artifact it produces so the whole contract is still readable as data.
196
+ */
186
197
  declare const swapPrograms: Record<"wantAsset" | "wantBtc", ReturnType<typeof arkade.parseArtifact>>;
187
198
  /** A full-fill offer. Exactly one field names an asset: `wantAsset` set = the
188
199
  * fill must deliver that asset (the deposit may be BTC or another asset,
@@ -203,6 +214,14 @@ interface Offer {
203
214
  makerPublicKey: Uint8Array;
204
215
  /** Covenant co-signer (emulator) x-only key (32 bytes). */
205
216
  emulatorPubkey: Uint8Array;
217
+ /** Partial-fill numerator. Reserved wire space in V1: carried through the
218
+ * codec so an offer that sets it decodes, never interpreted here. */
219
+ ratioNum?: bigint;
220
+ /** Partial-fill denominator. Set with {@link Offer.ratioNum} or not at all. */
221
+ ratioDen?: bigint;
222
+ /** The maker's unilateral exit path. Present adds a third closure to the
223
+ * taproot tree, so it changes `swapPkScript` — see {@link offerVtxoScript}. */
224
+ exitDelay?: RelativeTimelock;
206
225
  }
207
226
  /** Compile the offer's contract: program + args -> taproot tree. */
208
227
  declare function offerVtxoScript(offer: Omit<Offer, "swapPkScript">, serverPubkey: Uint8Array): InstanceType<typeof arkade.ArkadeProgramScript>;
@@ -233,6 +252,14 @@ declare function decodeOffer(data: Uint8Array): Offer;
233
252
  * funding rather than after: nothing is at stake yet, so a failure can throw
234
253
  * and be retried, where the same failure after `wallet.send` would leave a
235
254
  * funded deposit unwatched with no way to notice.
255
+ *
256
+ * **The maker's unilateral exit closure is built by default**, at the server's
257
+ * own `unilateralExitDelay` — the same thing solverd does. Without it `cancel`
258
+ * is the only way back out, and `cancel` needs the server's signature: a server
259
+ * that will not co-sign leaves the deposit stuck at the swap address until the
260
+ * VTXO expires and the operator sweeps it. An offer has no expiry of its own,
261
+ * so that exposure has no end. `noExit` opts out for a caller who wants the
262
+ * smaller tree and accepts the dependency.
236
263
  */
237
264
  declare function createOffer(wallet: IWallet, arkServerUrl: string, params: {
238
265
  wantAmount: bigint;
@@ -241,6 +268,12 @@ declare function createOffer(wallet: IWallet, arkServerUrl: string, params: {
241
268
  /** Co-signer key override (33-byte compressed hex); see
242
269
  * {@link resolveEmulatorPubkey}. */
243
270
  emulatorPubkey?: string;
271
+ /** Override the exit closure's delay. Defaults to the server's own
272
+ * `unilateralExitDelay`, which is the delay solverd uses too. */
273
+ exitDelay?: RelativeTimelock;
274
+ /** Publish without the exit closure, leaving `cancel` — which needs the
275
+ * server — as the only way back out. See the note on this function. */
276
+ noExit?: boolean;
244
277
  }): Promise<{
245
278
  /** The encoded offer, hex. **Persist this** — it is the only input
246
279
  * `cancelOffer` needs to rebuild the covenant, and the restore scan reads
@@ -263,19 +296,23 @@ declare function createOffer(wallet: IWallet, arkServerUrl: string, params: {
263
296
  /**
264
297
  * Cancel an offer: spend the swap VTXO back to the user. Returns the ark txid.
265
298
  *
266
- * This is the refund path — how a user takes back a deposit no solver filled.
267
- * **Neither program carries a timelock**, so an unfilled deposit keeps its
268
- * place at the swap address rather than expiring: no deadline to miss and no
269
- * "expired" state to unwind, at the cost of the refund being something the
299
+ * This is the cooperative refund path — how a user takes back a deposit no
300
+ * solver filled. **No path here is on a deadline**, so an unfilled deposit
301
+ * keeps its place at the swap address rather than expiring: nothing to miss and
302
+ * no "expired" state to unwind, at the cost of the refund being something the
270
303
  * user asks for rather than something a clock delivers.
271
304
  *
272
- * Both paths out of the covenant are deliberately asymmetric:
305
+ * The routes out of the covenant are deliberately asymmetric:
273
306
  * - `fulfill` is signed by the **server alone**, but the covenant constrains
274
307
  * it to pay output 0 to `makerWP` for at least `wantAmount` — a solver
275
308
  * cannot take the deposit without delivering.
276
309
  * - `cancel` is a **2-of-2 of the user and the server**, so cancelling is
277
310
  * cooperative: the server co-signs. No solver signature is involved, so the
278
311
  * refund never depends on the counterparty being reachable.
312
+ * - `exit` is the user **alone** after a relative timelock, present unless the
313
+ * offer was created with `noExit`. It is the route that survives a server
314
+ * that will not co-sign this one, and it is reached by unrolling the VTXO
315
+ * onchain rather than through this function.
279
316
  *
280
317
  * Cancel therefore races a fill rather than pre-empting it. An offer the solver
281
318
  * is filling in the same moment may be spent by `fulfill` first, in which case
@@ -451,10 +488,19 @@ type SpendKind = "cancelled" | "fulfilled" | "indeterminate";
451
488
  /**
452
489
  * Classify a spend by the covenant leaf it took.
453
490
  *
454
- * The covenant's whole vocabulary is two leaves: `cancel` returns the deposit
455
- * to the user, `fulfill` is the solver paying for it. A submitted ark tx keeps
456
- * each input's `tapLeafScript`, so the spend *states* which one it used — this
457
- * reads an answer rather than inferring one.
491
+ * The vocabulary is what became of the deposit, not which key moved it:
492
+ * `fulfill` is the solver paying for it, and everything else returns it to the
493
+ * user. A submitted ark tx keeps each input's `tapLeafScript`, so the spend
494
+ * *states* which one it used — this reads an answer rather than inferring one.
495
+ *
496
+ * **`exit` reports `cancelled`, like `cancel` does.** The two differ in who had
497
+ * to agree — `cancel` is cooperative with the signer, `exit` is the maker alone
498
+ * after a delay — but not in outcome: the deposit went back unfilled either
499
+ * way, which is the question this answers. Reporting the exit leaf as
500
+ * `indeterminate` instead would be worse than imprecise: no status is written,
501
+ * so the swap stays `pending`, `restoreAssetSwaps` re-queues it on every scan,
502
+ * and `retireOfferContract` never runs — leaving a dead script in the
503
+ * subscription and the failsafe poll for the life of the wallet.
458
504
  *
459
505
  * **Hand it the transaction that actually spends the deposit outpoint, which is
460
506
  * the checkpoint, not the ark tx.** A spend is two linked transactions: the