@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.d.ts 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-DIAr5XYk.js';
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-DIAr5XYk.js';
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-CfE18Fif.js';
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-CfE18Fif.js';
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.js';
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.js';
5
+ import { O as OnchainNetwork, a as OnchainHtlc, b as OnchainHtlcParams } from './rfq-DkckzRKK.js';
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.js';
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
package/dist/index.js CHANGED
@@ -63,7 +63,7 @@ import {
63
63
  unilateralRefundWithoutReceiverDelay,
64
64
  verifyLockupAddress,
65
65
  verifyReceiveInvoice
66
- } from "./chunk-2FEMUOIH.js";
66
+ } from "./chunk-RIC5ZTZK.js";
67
67
  import {
68
68
  InMemoryAssetSwapRepository,
69
69
  marketsCacheKey
@@ -329,12 +329,30 @@ var swapPrograms = {
329
329
  wantAsset: arkade.parseArtifact(swap_want_asset_program_default),
330
330
  wantBtc: arkade.parseArtifact(swap_want_btc_program_default)
331
331
  };
332
+ function withExitClosure(program, exit) {
333
+ if (!exit) return program;
334
+ return {
335
+ ...program,
336
+ // typed params are authoritative: an undeclared `$exitDelay` fails
337
+ // validateProgram instead of compiling against an unbound value
338
+ params: [...program.params ?? [], { name: "exitDelay", type: "int" }],
339
+ functions: {
340
+ ...program.functions,
341
+ exit: {
342
+ tapscript: { signers: ["$user"], csv: { type: exit.type, value: "$exitDelay" } }
343
+ }
344
+ }
345
+ };
346
+ }
332
347
  function swapProgramBinding(offer, serverPubkey) {
333
348
  if (offer.makerPkScript.length !== FIELDS.makerPkScript.width) {
334
349
  throw new Error("makerPkScript is not a 34-byte taproot scriptPubKey");
335
350
  }
336
351
  return {
337
- program: offer.wantAsset ? swapPrograms.wantAsset : swapPrograms.wantBtc,
352
+ program: withExitClosure(
353
+ offer.wantAsset ? swapPrograms.wantAsset : swapPrograms.wantBtc,
354
+ offer.exitDelay
355
+ ),
338
356
  args: {
339
357
  makerWP: offer.makerPkScript.subarray(2),
340
358
  wantAmount: offer.wantAmount,
@@ -344,7 +362,8 @@ function swapProgramBinding(offer, serverPubkey) {
344
362
  ...offer.wantAsset && {
345
363
  wantAssetTxid: offer.wantAsset.txid.slice().reverse(),
346
364
  wantAssetGroupIndex: offer.wantAsset.groupIndex
347
- }
365
+ },
366
+ ...offer.exitDelay && { exitDelay: offer.exitDelay.value }
348
367
  },
349
368
  keys: {
350
369
  serverKey: serverPubkey,
@@ -365,9 +384,27 @@ var FIELDS = {
365
384
  makerPkScript: { tag: 5, width: 34 },
366
385
  makerPublicKey: { tag: 7, width: 32 },
367
386
  emulatorPubkey: { tag: 8, width: 32 },
368
- offerAsset: { tag: 11, width: void 0 }
387
+ ratioNum: { tag: 9, width: 8 },
388
+ ratioDen: { tag: 10, width: 8 },
389
+ offerAsset: { tag: 11, width: void 0 },
390
+ exitTimelock: { tag: 12, width: 9 }
369
391
  };
392
+ var EXIT_TYPES = ["blocks", "seconds"];
370
393
  var NAMES = Object.fromEntries(Object.entries(FIELDS).map(([k, f]) => [f.tag, k]));
394
+ function u64(name, value) {
395
+ if (value < BigInt(0) || value >> BigInt(64) > BigInt(0)) {
396
+ throw new Error(`${name} does not fit the offer wire format (u64)`);
397
+ }
398
+ const out = new Uint8Array(FIELDS.wantAmount.width);
399
+ new DataView(out.buffer).setBigUint64(0, value, false);
400
+ return out;
401
+ }
402
+ var readU64 = (value) => new DataView(value.buffer, value.byteOffset).getBigUint64(0, false);
403
+ var setRatio = (name, value) => {
404
+ if (value === void 0 || value === BigInt(0)) return void 0;
405
+ if (value < BigInt(0)) throw new Error(`${name} does not fit the offer wire format (u64)`);
406
+ return value;
407
+ };
371
408
  function tlv(type, value) {
372
409
  if (value.length > 65535) throw new Error("TLV value exceeds the u16 length field");
373
410
  return concatBytes(Uint8Array.of(type, value.length >> 8 & 255, value.length & 255), value);
@@ -386,24 +423,45 @@ function encodeOffer(offer) {
386
423
  throw new Error(`${name} must be ${FIELDS[name].width} bytes`);
387
424
  }
388
425
  }
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)");
426
+ const ratioNum = setRatio("ratioNum", offer.ratioNum);
427
+ const ratioDen = setRatio("ratioDen", offer.ratioDen);
428
+ if (ratioNum === void 0 !== (ratioDen === void 0)) {
429
+ throw new Error("offer must carry both ratioNum and ratioDen, or neither");
391
430
  }
392
- const amount = new Uint8Array(FIELDS.wantAmount.width);
393
- new DataView(amount.buffer).setBigUint64(0, offer.wantAmount, false);
394
431
  const recs = [
395
432
  tlv(FIELDS.swapPkScript.tag, offer.swapPkScript),
396
- tlv(FIELDS.wantAmount.tag, amount)
433
+ tlv(FIELDS.wantAmount.tag, u64("wantAmount", offer.wantAmount))
397
434
  ];
398
435
  if (offer.wantAsset) recs.push(tlv(FIELDS.wantAsset.tag, offer.wantAsset.serialize()));
436
+ if (ratioNum !== void 0) recs.push(tlv(FIELDS.ratioNum.tag, u64("ratioNum", ratioNum)));
437
+ if (ratioDen !== void 0) recs.push(tlv(FIELDS.ratioDen.tag, u64("ratioDen", ratioDen)));
399
438
  if (offer.offerAsset) recs.push(tlv(FIELDS.offerAsset.tag, offer.offerAsset.serialize()));
400
439
  recs.push(
401
440
  tlv(FIELDS.makerPkScript.tag, offer.makerPkScript),
402
441
  tlv(FIELDS.makerPublicKey.tag, offer.makerPublicKey),
403
442
  tlv(FIELDS.emulatorPubkey.tag, offer.emulatorPubkey)
404
443
  );
444
+ if (offer.exitDelay) recs.push(tlv(FIELDS.exitTimelock.tag, encodeExitDelay(offer.exitDelay)));
405
445
  return concatBytes(...recs);
406
446
  }
447
+ function encodeExitDelay(exit) {
448
+ return concatBytes(
449
+ Uint8Array.of(EXIT_TYPES.indexOf(assertExitDelay(exit).type)),
450
+ u64("exitDelay", exit.value)
451
+ );
452
+ }
453
+ function assertExitDelay(exit) {
454
+ if (EXIT_TYPES.indexOf(exit.type) < 0) {
455
+ throw new Error(`unknown exitDelay locktime type: ${exit.type}`);
456
+ }
457
+ if (exit.value <= BigInt(0)) {
458
+ throw new Error("exitDelay must be a positive relative locktime");
459
+ }
460
+ if (exit.value >> BigInt(32) > BigInt(0)) {
461
+ throw new Error("exitDelay does not fit the locktime field (u32)");
462
+ }
463
+ return exit;
464
+ }
407
465
  function decodeOffer(data) {
408
466
  const fields = {};
409
467
  let off = 0;
@@ -423,6 +481,12 @@ function decodeOffer(data) {
423
481
  for (const name of ["wantAsset", "offerAsset"]) {
424
482
  if (fields[name]?.length === 0) throw new Error(`missing/invalid ${name}`);
425
483
  }
484
+ for (const [name, value] of Object.entries(fields)) {
485
+ const width = FIELDS[name].width;
486
+ if (width !== void 0 && value.length !== width) {
487
+ throw new Error(`missing/invalid ${name}`);
488
+ }
489
+ }
426
490
  const need = (name) => {
427
491
  const v = fields[name];
428
492
  const len = FIELDS[name].width;
@@ -434,16 +498,36 @@ function decodeOffer(data) {
434
498
  if (Boolean(fields.wantAsset) === Boolean(fields.offerAsset)) {
435
499
  throw new Error("offer must carry exactly one of wantAsset or offerAsset");
436
500
  }
501
+ const readRatio = (name) => {
502
+ const raw = fields[name];
503
+ if (!raw) return void 0;
504
+ const value = readU64(raw);
505
+ if (value === BigInt(0)) throw new Error(`missing/invalid ${name}`);
506
+ return value;
507
+ };
508
+ const ratioNum = readRatio("ratioNum");
509
+ const ratioDen = readRatio("ratioDen");
510
+ if (ratioNum === void 0 !== (ratioDen === void 0)) {
511
+ throw new Error("offer must carry both ratioNum and ratioDen, or neither");
512
+ }
437
513
  return {
438
514
  swapPkScript: need("swapPkScript"),
439
- wantAmount: new DataView(amount.buffer, amount.byteOffset).getBigUint64(0, false),
515
+ wantAmount: readU64(amount),
440
516
  ...fields.wantAsset && { wantAsset: asset.AssetId.fromBytes(fields.wantAsset) },
441
517
  ...fields.offerAsset && { offerAsset: asset.AssetId.fromBytes(fields.offerAsset) },
442
518
  makerPkScript: need("makerPkScript"),
443
519
  makerPublicKey: need("makerPublicKey"),
444
- emulatorPubkey: need("emulatorPubkey")
520
+ emulatorPubkey: need("emulatorPubkey"),
521
+ ...ratioNum !== void 0 && { ratioNum },
522
+ ...ratioDen !== void 0 && { ratioDen },
523
+ ...fields.exitTimelock && { exitDelay: decodeExitDelay(fields.exitTimelock) }
445
524
  };
446
525
  }
526
+ function decodeExitDelay(value) {
527
+ const type = EXIT_TYPES[value[0]];
528
+ if (!type) throw new Error(`unknown exitDelay locktime type: 0x${value[0].toString(16)}`);
529
+ return { type, value: readU64(value.subarray(1)) };
530
+ }
447
531
  var OFFER_CONTRACT_LABEL = "Arkade swap offer";
448
532
  var OFFER_CONTRACT_KIND = "asset-swap-offer";
449
533
  async function registerOfferContract(wallet, arkServerUrl, network, binding, serverPubkey, expectedPkScript) {
@@ -469,6 +553,14 @@ async function registerOfferContract(wallet, arkServerUrl, network, binding, ser
469
553
  });
470
554
  await promoteOfferContract(contractManager, hex2.encode(expectedPkScript));
471
555
  }
556
+ function serverExitDelay(delay) {
557
+ if (typeof delay !== "bigint" || delay <= BigInt(0)) {
558
+ throw new Error(
559
+ "the server reports no usable unilateralExitDelay; pass `exitDelay` to set the offer's exit closure explicitly, or `noExit: true` to publish without one"
560
+ );
561
+ }
562
+ return assertExitDelay({ value: delay, type: delay < BigInt(512) ? "blocks" : "seconds" });
563
+ }
472
564
  async function createOffer(wallet, arkServerUrl, params) {
473
565
  if (Boolean(params.wantAsset) === Boolean(params.offerAsset)) {
474
566
  throw new Error("set exactly one of wantAsset (BTC->asset) or offerAsset (asset->BTC)");
@@ -489,7 +581,11 @@ async function createOffer(wallet, arkServerUrl, params) {
489
581
  offerAsset: params.offerAsset,
490
582
  makerPkScript: ArkAddress.decode(makerAddress).pkScript,
491
583
  makerPublicKey,
492
- emulatorPubkey: emuKey
584
+ emulatorPubkey: emuKey,
585
+ // checked HERE, before the covenant is derived and registered below:
586
+ // deferring it to `encodeOffer` leaves a registered contract behind for
587
+ // an offer that then fails to encode. @see assertExitDelay
588
+ exitDelay: params.noExit ? void 0 : params.exitDelay ? assertExitDelay(params.exitDelay) : serverExitDelay(info.unilateralExitDelay)
493
589
  };
494
590
  const script = offerVtxoScript(binding, serverPubKey);
495
591
  const offer = { ...binding, swapPkScript: script.pkScript };
@@ -527,8 +623,8 @@ async function cancelOffer(wallet, arkServerUrl, offerHex, opts) {
527
623
  // script and the payout script comes from wallet.getAddress(), so the
528
624
  // client's network (which only shapes address derivation) is unused here
529
625
  });
530
- const serverKey = swapAddress ? ArkAddress.decode(swapAddress).serverPubKey : client.serverKey;
531
- const { program, args, keys } = swapProgramBinding(offer, serverKey);
626
+ const operatorPubkey = swapAddress ? ArkAddress.decode(swapAddress).serverPubKey : client.serverKey;
627
+ const { program, args, keys } = swapProgramBinding(offer, operatorPubkey);
532
628
  const rebuilt = new arkade.ArkadeProgramScript(program, args, keys);
533
629
  if (hex2.encode(rebuilt.pkScript) !== hex2.encode(offer.swapPkScript)) {
534
630
  throw new Error(
@@ -1178,7 +1274,9 @@ function classifySpend(offer, serverPubkey, spendTx, deposit) {
1178
1274
  const script = offerVtxoScript(offer, serverPubkey);
1179
1275
  if (hex5.encode(script.pkScript) !== hex5.encode(offer.swapPkScript)) return "indeterminate";
1180
1276
  leaves = {
1181
- cancel: script.functionByName("cancel")?.leafScript,
1277
+ // both routes that hand the deposit back; `exit` is absent on an
1278
+ // offer that carries no exit closure, and drops out here
1279
+ returned: ["cancel", "exit"].map((name) => script.functionByName(name)?.leafScript).filter((leaf) => leaf !== void 0),
1182
1280
  fulfill: script.functionByName("fulfill")?.leafScript
1183
1281
  };
1184
1282
  } catch {
@@ -1190,7 +1288,7 @@ function classifySpend(offer, serverPubkey, spendTx, deposit) {
1190
1288
  if (hex5.encode(input.txid) !== deposit.txid) continue;
1191
1289
  for (const leaf of input.tapLeafScript ?? []) {
1192
1290
  const spent = hex5.encode(scriptFromTapLeafScript(leaf));
1193
- if (leaves.cancel && spent === hex5.encode(leaves.cancel)) return "cancelled";
1291
+ if (leaves.returned.some((back) => spent === hex5.encode(back))) return "cancelled";
1194
1292
  if (leaves.fulfill && spent === hex5.encode(leaves.fulfill)) return "fulfilled";
1195
1293
  }
1196
1294
  }
@@ -1412,6 +1510,7 @@ import {
1412
1510
  assertSubmittedArkTxid,
1413
1511
  buildOffchainTx,
1414
1512
  getArkPsbtFields,
1513
+ hasTerminalSpend,
1415
1514
  matchServerCheckpoints
1416
1515
  } from "@arkade-os/sdk";
1417
1516
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -1472,6 +1571,7 @@ async function findLockupVtxos(indexer, swapPkScript) {
1472
1571
  [recoverable.vtxos ?? [], true]
1473
1572
  ]) {
1474
1573
  for (const vtxo of vtxos) {
1574
+ if (vtxo.isUnrolled) continue;
1475
1575
  const key = `${vtxo.txid}:${vtxo.vout}`;
1476
1576
  if (seen.has(key)) continue;
1477
1577
  seen.add(key);
@@ -1494,10 +1594,17 @@ async function readLockupFate(indexer, input) {
1494
1594
  const { vtxos } = await indexer.getVtxos({ scripts: [hex7.encode(input.swapPkScript)] });
1495
1595
  const all = vtxos ?? [];
1496
1596
  if (all.length === 0) return { fate: "unknown" };
1597
+ const exited = all.filter((vtxo) => vtxo.isUnrolled && !hasTerminalSpend(vtxo));
1598
+ if (exited.length > 0) {
1599
+ return {
1600
+ fate: "exited",
1601
+ outpoints: exited.map((vtxo) => ({ txid: vtxo.txid, vout: vtxo.vout }))
1602
+ };
1603
+ }
1497
1604
  const spentBy = /* @__PURE__ */ new Map();
1498
1605
  let everySpendNamed = true;
1499
1606
  for (const vtxo of all) {
1500
- if (!vtxo.isSpent && !vtxo.spentBy && !vtxo.settledBy) return { fate: "open" };
1607
+ if (!hasTerminalSpend(vtxo)) return { fate: "open" };
1501
1608
  if (vtxo.spentBy)
1502
1609
  spentBy.set(vtxo.spentBy, {
1503
1610
  checkpointTxid: vtxo.spentBy,
@@ -1539,10 +1646,10 @@ async function pushRefundWithoutReceiver(ark, input) {
1539
1646
  input.script.options.refundLocktime
1540
1647
  );
1541
1648
  }
1542
- const refundPkScript = input.refundPkScript ?? input.script.options.nonInteractiveRefund?.senderPkScript;
1649
+ const refundPkScript = input.refundPkScript ?? input.script.options.nonInteractiveParameters?.senderPkScript;
1543
1650
  if (!refundPkScript) {
1544
1651
  throw new Error(
1545
- "no refund destination: the contract carries no nonInteractiveRefund leaf, so pass refundPkScript explicitly"
1652
+ "no refund destination: the contract carries no emulator covenant suite, so pass refundPkScript explicitly"
1546
1653
  );
1547
1654
  }
1548
1655
  const info = await ark.getInfo();
@@ -1594,6 +1701,21 @@ async function refundIfUnresolved(transport, ark, indexer, input) {
1594
1701
  const status = await transport.status(input.rfqId);
1595
1702
  if (status && isResolved(status.state)) return { outcome: "resolved", status };
1596
1703
  if (now() >= input.refundLocktime) {
1704
+ let fate = { fate: "unknown" };
1705
+ try {
1706
+ fate = await readLockupFate(indexer, {
1707
+ swapPkScript: input.script.pkScript,
1708
+ paymentHash: input.paymentHash
1709
+ });
1710
+ } catch {
1711
+ }
1712
+ if (fate.fate === "exited") {
1713
+ return {
1714
+ outcome: "exited",
1715
+ outpoints: fate.outpoints.map((o) => `${o.txid}:${o.vout}`),
1716
+ status
1717
+ };
1718
+ }
1597
1719
  const vtxos = await findLockupVtxos(indexer, input.script.pkScript);
1598
1720
  if (vtxos.length === 0) return { outcome: "nothing_to_refund", status };
1599
1721
  try {
@@ -2389,12 +2511,36 @@ var RfqSwapManager = class {
2389
2511
  this.setState(swap, fate.fate === "claimed" ? "settled" : "refunded");
2390
2512
  return;
2391
2513
  }
2392
- if (swap.kind === "lightning_receive") return this.driveReceiveClaim(swap);
2514
+ if (swap.kind === "lightning_receive") {
2515
+ return fate.fate === "exited" ? this.blockExitedLockup(swap, fate) : this.driveReceiveClaim(swap);
2516
+ }
2393
2517
  if (swap.kind === "onchain_send" && swap.state !== "claimed") {
2394
2518
  if (await this.driveOnchain(swap) === "handled") return;
2395
2519
  }
2520
+ if (fate.fate === "exited") {
2521
+ const claiming = swap.state === "claimable" || swap.state === "claimed";
2522
+ if (this.config.now() < swap.refundLocktime && claiming) return;
2523
+ return this.blockExitedLockup(swap, fate);
2524
+ }
2396
2525
  await this.driveArkadeRefund(swap);
2397
2526
  }
2527
+ /**
2528
+ * The lockup was unilaterally exited: its outputs sit onchain under the
2529
+ * VHTLC script, where no offchain claim or refund can reach them.
2530
+ *
2531
+ * `needs_counterparty` rather than a terminal state, because the money still
2532
+ * needs action and the swap can still end either way — an onchain claim can
2533
+ * reveal the preimage, an onchain refund can return it — and that state is
2534
+ * documented as re-checked every pass. It must be set from HERE and not from
2535
+ * inside `driveArkadeRefund`, whose two `unblock` calls would lift it again
2536
+ * on the very next pass.
2537
+ */
2538
+ blockExitedLockup(swap, fate) {
2539
+ this.block(
2540
+ swap,
2541
+ `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`
2542
+ );
2543
+ }
2398
2544
  /**
2399
2545
  * The receive leg's whole state machine: claim the solver-funded lockup
2400
2546
  * while the window is open, and recognise the shapes in which it can be
package/dist/nostr.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { y as RfqTransport } from './rfq-hbzhTWHT.cjs';
1
+ import { y as RfqTransport } from './rfq-DkckzRKK.cjs';
2
2
  import { SimplePool } from 'nostr-tools';
3
3
  import '@arkade-os/sdk';
4
4
 
package/dist/nostr.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { y as RfqTransport } from './rfq-hbzhTWHT.js';
1
+ import { y as RfqTransport } from './rfq-DkckzRKK.js';
2
2
  import { SimplePool } from 'nostr-tools';
3
3
  import '@arkade-os/sdk';
4
4
 
package/dist/nostr.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  expectQuote,
3
3
  pairOf
4
- } from "./chunk-2FEMUOIH.js";
4
+ } from "./chunk-RIC5ZTZK.js";
5
5
 
6
6
  // src/nostr.ts
7
7
  import {
@@ -1,8 +1,8 @@
1
1
  import { RealmLike } from '@arkade-os/sdk/repositories/realm';
2
- import { A as AssetSwapRepository, a as AssetSwap, R as RfqSwapRecord, M as MarketsCacheEntry } from '../../repository-DEHLtD9l.cjs';
2
+ import { A as AssetSwapRepository, a as AssetSwap, R as RfqSwapRecord, M as MarketsCacheEntry } from '../../repository-B02vsgcV.cjs';
3
3
  import '@arkade-os/solver-discovery';
4
4
  import '@arkade-os/sdk';
5
- import '../../rfq-hbzhTWHT.cjs';
5
+ import '../../rfq-DkckzRKK.cjs';
6
6
 
7
7
  /**
8
8
  * Realm backend for React Native.
@@ -1,8 +1,8 @@
1
1
  import { RealmLike } from '@arkade-os/sdk/repositories/realm';
2
- import { A as AssetSwapRepository, a as AssetSwap, R as RfqSwapRecord, M as MarketsCacheEntry } from '../../repository-DIAr5XYk.js';
2
+ import { A as AssetSwapRepository, a as AssetSwap, R as RfqSwapRecord, M as MarketsCacheEntry } from '../../repository-CfE18Fif.js';
3
3
  import '@arkade-os/solver-discovery';
4
4
  import '@arkade-os/sdk';
5
- import '../../rfq-hbzhTWHT.js';
5
+ import '../../rfq-DkckzRKK.js';
6
6
 
7
7
  /**
8
8
  * Realm backend for React Native.
@@ -1,8 +1,8 @@
1
1
  import { SQLExecutor } from '@arkade-os/sdk/repositories/sqlite';
2
- import { A as AssetSwapRepository, a as AssetSwap, R as RfqSwapRecord, M as MarketsCacheEntry } from '../../repository-DEHLtD9l.cjs';
2
+ import { A as AssetSwapRepository, a as AssetSwap, R as RfqSwapRecord, M as MarketsCacheEntry } from '../../repository-B02vsgcV.cjs';
3
3
  import '@arkade-os/solver-discovery';
4
4
  import '@arkade-os/sdk';
5
- import '../../rfq-hbzhTWHT.cjs';
5
+ import '../../rfq-DkckzRKK.cjs';
6
6
 
7
7
  /**
8
8
  * SQLite backend over the SDK's `SQLExecutor`, so any driver plugs in
@@ -1,8 +1,8 @@
1
1
  import { SQLExecutor } from '@arkade-os/sdk/repositories/sqlite';
2
- import { A as AssetSwapRepository, a as AssetSwap, R as RfqSwapRecord, M as MarketsCacheEntry } from '../../repository-DIAr5XYk.js';
2
+ import { A as AssetSwapRepository, a as AssetSwap, R as RfqSwapRecord, M as MarketsCacheEntry } from '../../repository-CfE18Fif.js';
3
3
  import '@arkade-os/solver-discovery';
4
4
  import '@arkade-os/sdk';
5
- import '../../rfq-hbzhTWHT.js';
5
+ import '../../rfq-DkckzRKK.js';
6
6
 
7
7
  /**
8
8
  * SQLite backend over the SDK's `SQLExecutor`, so any driver plugs in