@arkade-os/swap 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +562 -0
- package/dist/chunk-C5P7R7JT.js +1363 -0
- package/dist/index.cjs +3568 -0
- package/dist/index.d.cts +1747 -0
- package/dist/index.d.ts +1747 -0
- package/dist/index.js +2242 -0
- package/dist/nostr.cjs +223 -0
- package/dist/nostr.d.cts +99 -0
- package/dist/nostr.d.ts +99 -0
- package/dist/nostr.js +134 -0
- package/dist/rfq-CRgIOQ_y.d.cts +1219 -0
- package/dist/rfq-CRgIOQ_y.d.ts +1219 -0
- package/package.json +81 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,3568 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
ARKADE_ASSET: () => ARKADE_ASSET,
|
|
34
|
+
ARKADE_BTC: () => ARKADE_BTC,
|
|
35
|
+
AddressMismatch: () => AddressMismatch,
|
|
36
|
+
BTC_ASSET_ID: () => BTC_ASSET_ID,
|
|
37
|
+
InMemoryAssetSwapRepository: () => InMemoryAssetSwapRepository,
|
|
38
|
+
IndexedDbAssetSwapRepository: () => IndexedDbAssetSwapRepository,
|
|
39
|
+
LIGHTNING_BTC: () => LIGHTNING_BTC,
|
|
40
|
+
LIGHTNING_RECEIVE_PAIR: () => LIGHTNING_RECEIVE_PAIR,
|
|
41
|
+
LIGHTNING_SEND_PAIR: () => LIGHTNING_SEND_PAIR,
|
|
42
|
+
LockupAmountMismatchError: () => LockupAmountMismatchError,
|
|
43
|
+
LockupNeedsRecoveryError: () => LockupNeedsRecoveryError,
|
|
44
|
+
LockupRegistrationFailed: () => LockupRegistrationFailed,
|
|
45
|
+
MAX_MIN_CONFIRMATIONS: () => MAX_MIN_CONFIRMATIONS,
|
|
46
|
+
MIN_CLAIM_WINDOW_SECONDS: () => MIN_CLAIM_WINDOW_SECONDS,
|
|
47
|
+
MIN_HEADROOM_SECONDS: () => MIN_HEADROOM_SECONDS,
|
|
48
|
+
OFFER_PACKET_TYPE: () => OFFER_PACKET_TYPE,
|
|
49
|
+
ONCHAIN_BTC: () => ONCHAIN_BTC,
|
|
50
|
+
ONCHAIN_CLAIM_MARGIN_SECONDS: () => ONCHAIN_CLAIM_MARGIN_SECONDS,
|
|
51
|
+
ONCHAIN_DUST_SATS: () => ONCHAIN_DUST_SATS,
|
|
52
|
+
ONCHAIN_ORDER_MARGIN_SECONDS: () => ONCHAIN_ORDER_MARGIN_SECONDS,
|
|
53
|
+
ONCHAIN_RECEIVE_PAIR: () => ONCHAIN_RECEIVE_PAIR,
|
|
54
|
+
ONCHAIN_SECONDS_PER_BLOCK: () => ONCHAIN_SECONDS_PER_BLOCK,
|
|
55
|
+
ONCHAIN_SEND_PAIR: () => ONCHAIN_SEND_PAIR,
|
|
56
|
+
QUOTE_OPTIONS: () => QUOTE_OPTIONS,
|
|
57
|
+
REFUND_MTP_LAG_SECONDS: () => REFUND_MTP_LAG_SECONDS,
|
|
58
|
+
RFQ_PREIMAGE_TAG: () => RFQ_PREIMAGE_TAG,
|
|
59
|
+
RFQ_RESOLVED_STATES: () => RFQ_RESOLVED_STATES,
|
|
60
|
+
RFQ_SWAP_TERMINAL_STATES: () => RFQ_SWAP_TERMINAL_STATES,
|
|
61
|
+
RFQ_TERMINAL_STATES: () => RFQ_TERMINAL_STATES,
|
|
62
|
+
RefundNotLocallyPossibleError: () => RefundNotLocallyPossibleError,
|
|
63
|
+
RfqSwapManager: () => RfqSwapManager,
|
|
64
|
+
SWAP_LOCKUP_CONTRACT_KIND: () => SWAP_LOCKUP_CONTRACT_KIND,
|
|
65
|
+
SWAP_LOCKUP_CONTRACT_LABEL: () => SWAP_LOCKUP_CONTRACT_LABEL,
|
|
66
|
+
SWAP_LOCKUP_CONTRACT_TYPE: () => SWAP_LOCKUP_CONTRACT_TYPE,
|
|
67
|
+
SwapRefusal: () => SwapRefusal,
|
|
68
|
+
addAssetSwap: () => addAssetSwap,
|
|
69
|
+
adoptSwapDescriptor: () => adoptSwapDescriptor,
|
|
70
|
+
arkadeSwapRequest: () => arkadeSwapRequest,
|
|
71
|
+
assertFundable: () => assertFundable,
|
|
72
|
+
assertReceivable: () => assertReceivable,
|
|
73
|
+
awaitLockupFunding: () => awaitLockupFunding,
|
|
74
|
+
awaitOnchainFill: () => awaitOnchainFill,
|
|
75
|
+
awaitRfqResolution: () => awaitRfqResolution,
|
|
76
|
+
buildHtlcClaim: () => buildHtlcClaim,
|
|
77
|
+
buildHtlcRefund: () => buildHtlcRefund,
|
|
78
|
+
buildPreimageMessage: () => buildPreimageMessage,
|
|
79
|
+
cancelOffer: () => cancelOffer,
|
|
80
|
+
claimOnchainFill: () => claimOnchainFill,
|
|
81
|
+
claimReceiveLockup: () => claimReceiveLockup,
|
|
82
|
+
classifyDepositSpend: () => classifyDepositSpend,
|
|
83
|
+
classifyOnchainHtlc: () => classifyOnchainHtlc,
|
|
84
|
+
classifySpend: () => classifySpend,
|
|
85
|
+
createOffer: () => createOffer,
|
|
86
|
+
decodeOffer: () => decodeOffer,
|
|
87
|
+
deriveLightningReceive: () => deriveLightningReceive,
|
|
88
|
+
deriveOnchainReceive: () => deriveOnchainReceive,
|
|
89
|
+
deriveOnchainSend: () => deriveOnchainSend,
|
|
90
|
+
derivePreimage: () => derivePreimage,
|
|
91
|
+
deriveSwapSecrets: () => deriveSwapSecrets,
|
|
92
|
+
discoverMarkets: () => discoverMarkets,
|
|
93
|
+
encodeOffer: () => encodeOffer,
|
|
94
|
+
extractPreimage: () => extractPreimage,
|
|
95
|
+
findLockupVtxos: () => findLockupVtxos,
|
|
96
|
+
findMarket: () => findMarket,
|
|
97
|
+
getAssetSwaps: () => getAssetSwaps,
|
|
98
|
+
getAssetSwapsOrThrow: () => getAssetSwapsOrThrow,
|
|
99
|
+
httpTransport: () => httpTransport,
|
|
100
|
+
isDeterministicSigner: () => isDeterministicSigner,
|
|
101
|
+
isRfqSwapTerminal: () => isRfqSwapTerminal,
|
|
102
|
+
isRfqTerminal: () => isRfqTerminal,
|
|
103
|
+
lightningReceiveRequest: () => lightningReceiveRequest,
|
|
104
|
+
lightningSendRequest: () => lightningSendRequest,
|
|
105
|
+
lightningSendVtxoScript: () => lightningSendVtxoScript,
|
|
106
|
+
makeCachedFeedFetch: () => makeCachedFeedFetch,
|
|
107
|
+
newPreimage: () => newPreimage,
|
|
108
|
+
newRfqId: () => newRfqId,
|
|
109
|
+
nextOnchainAction: () => nextOnchainAction,
|
|
110
|
+
offerTermsFromQuote: () => offerTermsFromQuote,
|
|
111
|
+
offerVtxoScript: () => offerVtxoScript,
|
|
112
|
+
onchainHtlcScript: () => onchainHtlcScript,
|
|
113
|
+
onchainReceiveRequest: () => onchainReceiveRequest,
|
|
114
|
+
onchainSendRequest: () => onchainSendRequest,
|
|
115
|
+
paymentHashOf: () => paymentHashOf,
|
|
116
|
+
preimageForRfqSecrets: () => preimageForRfqSecrets,
|
|
117
|
+
pushClaim: () => pushClaim,
|
|
118
|
+
pushRefundWithoutReceiver: () => pushRefundWithoutReceiver,
|
|
119
|
+
randomSwapSecrets: () => randomSwapSecrets,
|
|
120
|
+
readLockupFate: () => readLockupFate,
|
|
121
|
+
receiveVtxoScript: () => receiveVtxoScript,
|
|
122
|
+
refundIfUnresolved: () => refundIfUnresolved,
|
|
123
|
+
registerLockupContract: () => registerLockupContract,
|
|
124
|
+
relayTransport: () => relayTransport,
|
|
125
|
+
requestLightningReceive: () => requestLightningReceive,
|
|
126
|
+
requestLightningSend: () => requestLightningSend,
|
|
127
|
+
requestOnchainReceive: () => requestOnchainReceive,
|
|
128
|
+
requestOnchainSend: () => requestOnchainSend,
|
|
129
|
+
restoreAssetSwaps: () => restoreAssetSwaps,
|
|
130
|
+
retireSettledOfferContracts: () => retireSettledOfferContracts,
|
|
131
|
+
rfqPair: () => rfqPair,
|
|
132
|
+
rfqSecretsOfRecord: () => rfqSecretsOfRecord,
|
|
133
|
+
rfqSecretsToRecord: () => rfqSecretsToRecord,
|
|
134
|
+
sealClaimPacket: () => sealClaimPacket,
|
|
135
|
+
senderIdentityForRfqSecrets: () => senderIdentityForRfqSecrets,
|
|
136
|
+
senderIdentityForSwapRecord: () => senderIdentityForSwapRecord,
|
|
137
|
+
senderPubkeyForRfqSecrets: () => senderPubkeyForRfqSecrets,
|
|
138
|
+
spendTxidsOf: () => spendTxidsOf,
|
|
139
|
+
spendUpdate: () => spendUpdate,
|
|
140
|
+
swapPrograms: () => swapPrograms,
|
|
141
|
+
unilateralClaimDelay: () => unilateralClaimDelay,
|
|
142
|
+
unilateralRefundDelay: () => unilateralRefundDelay,
|
|
143
|
+
unilateralRefundWithoutReceiverDelay: () => unilateralRefundWithoutReceiverDelay,
|
|
144
|
+
updateAssetSwap: () => updateAssetSwap,
|
|
145
|
+
updateAssetSwapBestEffort: () => updateAssetSwapBestEffort,
|
|
146
|
+
validatePlan: () => validatePlan,
|
|
147
|
+
verifyLockupAddress: () => verifyLockupAddress,
|
|
148
|
+
verifyReceiveInvoice: () => verifyReceiveInvoice,
|
|
149
|
+
watchOfferSwaps: () => watchOfferSwaps
|
|
150
|
+
});
|
|
151
|
+
module.exports = __toCommonJS(index_exports);
|
|
152
|
+
|
|
153
|
+
// src/offer.ts
|
|
154
|
+
var import_base = require("@scure/base");
|
|
155
|
+
var import_utils = require("@scure/btc-signer/utils.js");
|
|
156
|
+
var import_sdk = require("@arkade-os/sdk");
|
|
157
|
+
|
|
158
|
+
// src/swap-want-asset.program.json
|
|
159
|
+
var swap_want_asset_program_default = {
|
|
160
|
+
version: 0,
|
|
161
|
+
name: "banco-btc-to-asset",
|
|
162
|
+
params: [
|
|
163
|
+
{ name: "makerWP", type: "pubkey" },
|
|
164
|
+
{ name: "wantAmount", type: "int" },
|
|
165
|
+
{ name: "wantAssetTxid", type: "hash" },
|
|
166
|
+
{ name: "wantAssetGroupIndex", type: "int" },
|
|
167
|
+
{ name: "server", type: "pubkey" },
|
|
168
|
+
{ name: "user", type: "pubkey" }
|
|
169
|
+
],
|
|
170
|
+
functions: {
|
|
171
|
+
fulfill: {
|
|
172
|
+
tapscript: { signers: ["$server"] },
|
|
173
|
+
arkadeScript: {
|
|
174
|
+
asm: [
|
|
175
|
+
0,
|
|
176
|
+
"$wantAssetTxid",
|
|
177
|
+
"$wantAssetGroupIndex",
|
|
178
|
+
"INSPECTOUTASSETLOOKUP",
|
|
179
|
+
"VERIFY",
|
|
180
|
+
"$wantAmount",
|
|
181
|
+
"GREATERTHANOREQUAL",
|
|
182
|
+
"VERIFY",
|
|
183
|
+
0,
|
|
184
|
+
"INSPECTOUTPUTSCRIPTPUBKEY",
|
|
185
|
+
1,
|
|
186
|
+
"EQUALVERIFY",
|
|
187
|
+
"$makerWP",
|
|
188
|
+
"EQUAL"
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
cancel: {
|
|
193
|
+
tapscript: { signers: ["$user", "$server"] }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// src/swap-want-btc.program.json
|
|
199
|
+
var swap_want_btc_program_default = {
|
|
200
|
+
version: 0,
|
|
201
|
+
name: "banco-asset-to-btc",
|
|
202
|
+
params: [
|
|
203
|
+
{ name: "makerWP", type: "pubkey" },
|
|
204
|
+
{ name: "wantAmount", type: "int" },
|
|
205
|
+
{ name: "server", type: "pubkey" },
|
|
206
|
+
{ name: "user", type: "pubkey" }
|
|
207
|
+
],
|
|
208
|
+
functions: {
|
|
209
|
+
fulfill: {
|
|
210
|
+
tapscript: { signers: ["$server"] },
|
|
211
|
+
arkadeScript: {
|
|
212
|
+
asm: [
|
|
213
|
+
0,
|
|
214
|
+
"INSPECTOUTPUTVALUE",
|
|
215
|
+
"$wantAmount",
|
|
216
|
+
"GREATERTHANOREQUAL",
|
|
217
|
+
"VERIFY",
|
|
218
|
+
0,
|
|
219
|
+
"INSPECTOUTPUTSCRIPTPUBKEY",
|
|
220
|
+
1,
|
|
221
|
+
"EQUALVERIFY",
|
|
222
|
+
"$makerWP",
|
|
223
|
+
"EQUAL"
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
cancel: {
|
|
228
|
+
tapscript: { signers: ["$user", "$server"] }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// src/coverage.ts
|
|
234
|
+
var RETIRABLE = ["fulfilled", "cancelled"];
|
|
235
|
+
var issuedAt = /* @__PURE__ */ new Map();
|
|
236
|
+
var inFlight = /* @__PURE__ */ new Map();
|
|
237
|
+
async function serialize(script, task) {
|
|
238
|
+
const previous = inFlight.get(script) ?? Promise.resolve();
|
|
239
|
+
const result = previous.then(task, task);
|
|
240
|
+
const settled = result.then(
|
|
241
|
+
() => {
|
|
242
|
+
},
|
|
243
|
+
() => {
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
inFlight.set(script, settled);
|
|
247
|
+
void settled.then(() => {
|
|
248
|
+
if (inFlight.get(script) === settled) inFlight.delete(script);
|
|
249
|
+
});
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
function addressOutstanding(swaps, script) {
|
|
253
|
+
const issued = issuedAt.get(script);
|
|
254
|
+
if (issued === void 0) return false;
|
|
255
|
+
if (swaps.some((s) => s.swapPkScript === script && s.createdAt >= issued)) {
|
|
256
|
+
issuedAt.delete(script);
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
async function promoteOfferContract(manager, script) {
|
|
262
|
+
await serialize(script, async () => {
|
|
263
|
+
await manager.setContractWatchState(script, "watched");
|
|
264
|
+
issuedAt.set(script, Date.now());
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
async function retireOfferContract(manager, swaps, script) {
|
|
268
|
+
await serialize(script, async () => {
|
|
269
|
+
if (swaps.some((s) => s.swapPkScript === script && !RETIRABLE.includes(s.status))) return;
|
|
270
|
+
if (addressOutstanding(swaps, script)) return;
|
|
271
|
+
try {
|
|
272
|
+
await manager.setContractWatchState(script, "retained");
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.warn(`[swap] could not retire offer contract ${script}`, err);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
async function retireSettledOfferContracts(manager, swaps) {
|
|
279
|
+
const settled = new Set(
|
|
280
|
+
swaps.filter((s) => RETIRABLE.includes(s.status)).map((s) => s.swapPkScript)
|
|
281
|
+
);
|
|
282
|
+
for (const script of settled) await retireOfferContract(manager, swaps, script);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// src/store.ts
|
|
286
|
+
var BTC_ASSET_ID = "btc";
|
|
287
|
+
var byNewest = (a, b) => b.createdAt - a.createdAt;
|
|
288
|
+
var getAssetSwapsOrThrow = async (repository) => {
|
|
289
|
+
return (await repository.getAllSwaps()).filter(
|
|
290
|
+
(s) => s && typeof s.id === "string" && // offer swaps carry the TLV; onchain-corridor swaps carry
|
|
291
|
+
// the payment hash instead — either marks a valid record
|
|
292
|
+
(typeof s.offerHex === "string" || typeof s.paymentHash === "string")
|
|
293
|
+
).sort(byNewest);
|
|
294
|
+
};
|
|
295
|
+
var getAssetSwaps = async (repository) => {
|
|
296
|
+
try {
|
|
297
|
+
return await getAssetSwapsOrThrow(repository);
|
|
298
|
+
} catch {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
var saveSwapOrThrow = async (repository, swap) => {
|
|
303
|
+
try {
|
|
304
|
+
await repository.saveSwap(swap);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
307
|
+
throw new Error(`failed to save swap ${swap.id}: ${reason}`);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
var addAssetSwap = async (repository, swap) => {
|
|
311
|
+
const swaps = await getAssetSwapsOrThrow(repository);
|
|
312
|
+
if (swaps.some((s) => s.id === swap.id)) return swaps;
|
|
313
|
+
await saveSwapOrThrow(repository, swap);
|
|
314
|
+
const at = swaps.findIndex((s) => byNewest(swap, s) <= 0);
|
|
315
|
+
const merged = [...swaps];
|
|
316
|
+
merged.splice(at === -1 ? merged.length : at, 0, swap);
|
|
317
|
+
return merged;
|
|
318
|
+
};
|
|
319
|
+
var updateAssetSwap = async (repository, id, changes) => {
|
|
320
|
+
const swaps = (await getAssetSwapsOrThrow(repository)).map(
|
|
321
|
+
(s) => s.id === id ? { ...s, ...changes } : s
|
|
322
|
+
);
|
|
323
|
+
const updated = swaps.find((s) => s.id === id);
|
|
324
|
+
if (updated) await saveSwapOrThrow(repository, updated);
|
|
325
|
+
return swaps;
|
|
326
|
+
};
|
|
327
|
+
var updateAssetSwapBestEffort = async (repository, id, changes) => {
|
|
328
|
+
try {
|
|
329
|
+
return { swaps: await updateAssetSwap(repository, id, changes), persisted: true };
|
|
330
|
+
} catch (error) {
|
|
331
|
+
console.warn(`[swap] failed to persist update for swap ${id}`, error);
|
|
332
|
+
const swaps = (await getAssetSwaps(repository)).map(
|
|
333
|
+
(s) => s.id === id ? { ...s, ...changes } : s
|
|
334
|
+
);
|
|
335
|
+
return { swaps, persisted: false };
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// src/offer.ts
|
|
340
|
+
var swapPrograms = {
|
|
341
|
+
wantAsset: import_sdk.arkade.parseArtifact(swap_want_asset_program_default),
|
|
342
|
+
wantBtc: import_sdk.arkade.parseArtifact(swap_want_btc_program_default)
|
|
343
|
+
};
|
|
344
|
+
function swapProgramBinding(offer, serverPubkey) {
|
|
345
|
+
if (offer.makerPkScript.length !== FIELDS.makerPkScript.width) {
|
|
346
|
+
throw new Error("makerPkScript is not a 34-byte taproot scriptPubKey");
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
program: offer.wantAsset ? swapPrograms.wantAsset : swapPrograms.wantBtc,
|
|
350
|
+
args: {
|
|
351
|
+
makerWP: offer.makerPkScript.subarray(2),
|
|
352
|
+
wantAmount: offer.wantAmount,
|
|
353
|
+
server: serverPubkey,
|
|
354
|
+
user: offer.makerPublicKey,
|
|
355
|
+
// internal byte order
|
|
356
|
+
...offer.wantAsset && {
|
|
357
|
+
wantAssetTxid: offer.wantAsset.txid.slice().reverse(),
|
|
358
|
+
wantAssetGroupIndex: offer.wantAsset.groupIndex
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
keys: {
|
|
362
|
+
serverKey: serverPubkey,
|
|
363
|
+
userKey: offer.makerPublicKey,
|
|
364
|
+
emulatorKey: offer.emulatorPubkey
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function offerVtxoScript(offer, serverPubkey) {
|
|
369
|
+
const { program, args, keys } = swapProgramBinding(offer, serverPubkey);
|
|
370
|
+
return new import_sdk.arkade.ArkadeProgramScript(program, args, keys);
|
|
371
|
+
}
|
|
372
|
+
var OFFER_PACKET_TYPE = 3;
|
|
373
|
+
var FIELDS = {
|
|
374
|
+
swapPkScript: { tag: 1, width: 34 },
|
|
375
|
+
wantAmount: { tag: 2, width: 8 },
|
|
376
|
+
wantAsset: { tag: 3, width: void 0 },
|
|
377
|
+
makerPkScript: { tag: 5, width: 34 },
|
|
378
|
+
makerPublicKey: { tag: 7, width: 32 },
|
|
379
|
+
emulatorPubkey: { tag: 8, width: 32 },
|
|
380
|
+
offerAsset: { tag: 11, width: void 0 }
|
|
381
|
+
};
|
|
382
|
+
var NAMES = Object.fromEntries(Object.entries(FIELDS).map(([k, f]) => [f.tag, k]));
|
|
383
|
+
var XONLY_LEN = 32;
|
|
384
|
+
var xOnly = (key, label) => {
|
|
385
|
+
if (key.length === XONLY_LEN) return key;
|
|
386
|
+
if (key.length !== 33 || key[0] !== 2 && key[0] !== 3) {
|
|
387
|
+
throw new Error(`${label} is not a compressed or x-only public key`);
|
|
388
|
+
}
|
|
389
|
+
return key.slice(1);
|
|
390
|
+
};
|
|
391
|
+
function tlv(type, value) {
|
|
392
|
+
if (value.length > 65535) throw new Error("TLV value exceeds the u16 length field");
|
|
393
|
+
return (0, import_utils.concatBytes)(Uint8Array.of(type, value.length >> 8 & 255, value.length & 255), value);
|
|
394
|
+
}
|
|
395
|
+
function encodeOffer(offer) {
|
|
396
|
+
if (Boolean(offer.wantAsset) === Boolean(offer.offerAsset)) {
|
|
397
|
+
throw new Error("offer must carry exactly one of wantAsset or offerAsset");
|
|
398
|
+
}
|
|
399
|
+
for (const name of [
|
|
400
|
+
"swapPkScript",
|
|
401
|
+
"makerPkScript",
|
|
402
|
+
"makerPublicKey",
|
|
403
|
+
"emulatorPubkey"
|
|
404
|
+
]) {
|
|
405
|
+
if (offer[name].length !== FIELDS[name].width) {
|
|
406
|
+
throw new Error(`${name} must be ${FIELDS[name].width} bytes`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (offer.wantAmount < BigInt(0) || offer.wantAmount >> BigInt(64) > BigInt(0)) {
|
|
410
|
+
throw new Error("wantAmount does not fit the offer wire format (u64)");
|
|
411
|
+
}
|
|
412
|
+
const amount = new Uint8Array(FIELDS.wantAmount.width);
|
|
413
|
+
new DataView(amount.buffer).setBigUint64(0, offer.wantAmount, false);
|
|
414
|
+
const recs = [
|
|
415
|
+
tlv(FIELDS.swapPkScript.tag, offer.swapPkScript),
|
|
416
|
+
tlv(FIELDS.wantAmount.tag, amount)
|
|
417
|
+
];
|
|
418
|
+
if (offer.wantAsset) recs.push(tlv(FIELDS.wantAsset.tag, offer.wantAsset.serialize()));
|
|
419
|
+
if (offer.offerAsset) recs.push(tlv(FIELDS.offerAsset.tag, offer.offerAsset.serialize()));
|
|
420
|
+
recs.push(
|
|
421
|
+
tlv(FIELDS.makerPkScript.tag, offer.makerPkScript),
|
|
422
|
+
tlv(FIELDS.makerPublicKey.tag, offer.makerPublicKey),
|
|
423
|
+
tlv(FIELDS.emulatorPubkey.tag, offer.emulatorPubkey)
|
|
424
|
+
);
|
|
425
|
+
return (0, import_utils.concatBytes)(...recs);
|
|
426
|
+
}
|
|
427
|
+
function decodeOffer(data) {
|
|
428
|
+
const fields = {};
|
|
429
|
+
let off = 0;
|
|
430
|
+
while (off < data.length) {
|
|
431
|
+
if (off + 3 > data.length) throw new Error("truncated TLV header");
|
|
432
|
+
const type = data[off];
|
|
433
|
+
const length = data[off + 1] << 8 | data[off + 2];
|
|
434
|
+
off += 3;
|
|
435
|
+
if (off + length > data.length)
|
|
436
|
+
throw new Error(`truncated TLV value for type 0x${type.toString(16)}`);
|
|
437
|
+
const name = NAMES[type];
|
|
438
|
+
if (!name) throw new Error(`unknown TLV type: 0x${type.toString(16)}`);
|
|
439
|
+
if (fields[name] !== void 0) throw new Error(`duplicate TLV record: ${name}`);
|
|
440
|
+
fields[name] = data.slice(off, off + length);
|
|
441
|
+
off += length;
|
|
442
|
+
}
|
|
443
|
+
for (const name of ["wantAsset", "offerAsset"]) {
|
|
444
|
+
if (fields[name]?.length === 0) throw new Error(`missing/invalid ${name}`);
|
|
445
|
+
}
|
|
446
|
+
const need = (name) => {
|
|
447
|
+
const v = fields[name];
|
|
448
|
+
const len = FIELDS[name].width;
|
|
449
|
+
if (!v || len !== void 0 && v.length !== len)
|
|
450
|
+
throw new Error(`missing/invalid ${name}`);
|
|
451
|
+
return v;
|
|
452
|
+
};
|
|
453
|
+
const amount = need("wantAmount");
|
|
454
|
+
if (Boolean(fields.wantAsset) === Boolean(fields.offerAsset)) {
|
|
455
|
+
throw new Error("offer must carry exactly one of wantAsset or offerAsset");
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
swapPkScript: need("swapPkScript"),
|
|
459
|
+
wantAmount: new DataView(amount.buffer, amount.byteOffset).getBigUint64(0, false),
|
|
460
|
+
...fields.wantAsset && { wantAsset: import_sdk.asset.AssetId.fromBytes(fields.wantAsset) },
|
|
461
|
+
...fields.offerAsset && { offerAsset: import_sdk.asset.AssetId.fromBytes(fields.offerAsset) },
|
|
462
|
+
makerPkScript: need("makerPkScript"),
|
|
463
|
+
makerPublicKey: need("makerPublicKey"),
|
|
464
|
+
emulatorPubkey: need("emulatorPubkey")
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
var OFFER_CONTRACT_LABEL = "Arkade swap offer";
|
|
468
|
+
var OFFER_CONTRACT_KIND = "asset-swap-offer";
|
|
469
|
+
async function registerOfferContract(wallet, arkServerUrl, network, binding, serverPubkey, expectedPkScript) {
|
|
470
|
+
const { program, args, keys } = swapProgramBinding(binding, serverPubkey);
|
|
471
|
+
const contractManager = await wallet.getContractManager();
|
|
472
|
+
const client = await import_sdk.arkade.Arkade.connect({
|
|
473
|
+
arkade: new import_sdk.RestArkProvider(arkServerUrl),
|
|
474
|
+
indexer: new import_sdk.RestIndexerProvider(arkServerUrl),
|
|
475
|
+
identity: wallet.identity,
|
|
476
|
+
// without this the row's `address` would be derived against the SDK's
|
|
477
|
+
// default network while its script is right — a row that disagrees with
|
|
478
|
+
// the address the user is about to fund
|
|
479
|
+
network: (0, import_sdk.getNetwork)(network),
|
|
480
|
+
contractManager
|
|
481
|
+
});
|
|
482
|
+
const contract = new import_sdk.arkade.ArkadeContract(client, program, args, keys);
|
|
483
|
+
if (import_base.hex.encode(contract.pkScript) !== import_base.hex.encode(expectedPkScript)) {
|
|
484
|
+
throw new Error("derived covenant does not match the offer's swapPkScript");
|
|
485
|
+
}
|
|
486
|
+
await contract.register({
|
|
487
|
+
label: OFFER_CONTRACT_LABEL,
|
|
488
|
+
metadata: { genericallySpendable: false, kind: OFFER_CONTRACT_KIND }
|
|
489
|
+
});
|
|
490
|
+
await promoteOfferContract(contractManager, import_base.hex.encode(expectedPkScript));
|
|
491
|
+
}
|
|
492
|
+
async function createOffer(wallet, arkServerUrl, emulatorPubkey, params) {
|
|
493
|
+
if (Boolean(params.wantAsset) === Boolean(params.offerAsset)) {
|
|
494
|
+
throw new Error("set exactly one of wantAsset (BTC->asset) or offerAsset (asset->BTC)");
|
|
495
|
+
}
|
|
496
|
+
const [info, makerAddress, makerPublicKey] = await Promise.all([
|
|
497
|
+
new import_sdk.RestArkProvider(arkServerUrl).getInfo(),
|
|
498
|
+
wallet.getAddress(),
|
|
499
|
+
wallet.identity.xOnlyPublicKey()
|
|
500
|
+
]);
|
|
501
|
+
const serverPubKey = xOnly(import_base.hex.decode(info.signerPubkey), "ark signer key");
|
|
502
|
+
const emuKey = xOnly(emulatorPubkey, "emulator pubkey");
|
|
503
|
+
const binding = {
|
|
504
|
+
wantAmount: params.wantAmount,
|
|
505
|
+
wantAsset: params.wantAsset,
|
|
506
|
+
offerAsset: params.offerAsset,
|
|
507
|
+
makerPkScript: import_sdk.ArkAddress.decode(makerAddress).pkScript,
|
|
508
|
+
makerPublicKey,
|
|
509
|
+
emulatorPubkey: emuKey
|
|
510
|
+
};
|
|
511
|
+
const script = offerVtxoScript(binding, serverPubKey);
|
|
512
|
+
const offer = { ...binding, swapPkScript: script.pkScript };
|
|
513
|
+
await registerOfferContract(
|
|
514
|
+
wallet,
|
|
515
|
+
arkServerUrl,
|
|
516
|
+
info.network,
|
|
517
|
+
binding,
|
|
518
|
+
serverPubKey,
|
|
519
|
+
script.pkScript
|
|
520
|
+
);
|
|
521
|
+
const payload = encodeOffer(offer);
|
|
522
|
+
return {
|
|
523
|
+
offerHex: import_base.hex.encode(payload),
|
|
524
|
+
extension: { type: OFFER_PACKET_TYPE, payload },
|
|
525
|
+
// VtxoScript.address owns address construction; assembling an ArkAddress
|
|
526
|
+
// from tweakedPublicKey here would silently miss any future step it gains
|
|
527
|
+
address: script.address((0, import_sdk.getNetwork)(info.network).hrp, serverPubKey).encode(),
|
|
528
|
+
swapPkScript: script.pkScript
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
async function cancelOffer(wallet, arkServerUrl, offerHex, opts) {
|
|
532
|
+
const { repository, fundingTxid, swapAddress } = opts;
|
|
533
|
+
const offer = decodeOffer(import_base.hex.decode(offerHex));
|
|
534
|
+
const contractManager = await wallet.getContractManager();
|
|
535
|
+
const client = await import_sdk.arkade.Arkade.connect({
|
|
536
|
+
arkade: new import_sdk.RestArkProvider(arkServerUrl),
|
|
537
|
+
indexer: new import_sdk.RestIndexerProvider(arkServerUrl),
|
|
538
|
+
identity: wallet.identity,
|
|
539
|
+
// registered offers resolve their VTXOs from the contract repository
|
|
540
|
+
// instead of a direct indexer query; the indexer above stays as the
|
|
541
|
+
// fallback for offers created before registration existed
|
|
542
|
+
contractManager
|
|
543
|
+
// no `network`, unlike registerOfferContract: the row lookup is by
|
|
544
|
+
// script and the payout script comes from wallet.getAddress(), so the
|
|
545
|
+
// client's network (which only shapes address derivation) is unused here
|
|
546
|
+
});
|
|
547
|
+
const serverKey = swapAddress ? import_sdk.ArkAddress.decode(swapAddress).serverPubKey : client.serverKey;
|
|
548
|
+
const { program, args, keys } = swapProgramBinding(offer, serverKey);
|
|
549
|
+
const rebuilt = new import_sdk.arkade.ArkadeProgramScript(program, args, keys);
|
|
550
|
+
if (import_base.hex.encode(rebuilt.pkScript) !== import_base.hex.encode(offer.swapPkScript)) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
"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"
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
const contract = new import_sdk.arkade.ArkadeContract(client, program, args, keys);
|
|
556
|
+
const [vtxos, makerAddress] = await Promise.all([contract.getUtxos(), wallet.getAddress()]);
|
|
557
|
+
if (!fundingTxid && vtxos.length > 1) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
"multiple spendable deposits at the swap address \u2014 pass fundingTxid to select one"
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
const vtxo = fundingTxid ? vtxos.find((v) => v.txid === fundingTxid) : vtxos[0];
|
|
563
|
+
if (!vtxo) throw new Error("no spendable VTXO at the swap address");
|
|
564
|
+
const makerPkScript = import_sdk.ArkAddress.decode(makerAddress).pkScript;
|
|
565
|
+
const cancel = contract.functions.cancel().from({ txid: vtxo.txid, vout: vtxo.vout, value: vtxo.value }).to(makerPkScript, BigInt(vtxo.value));
|
|
566
|
+
for (const a of vtxo.assets ?? []) {
|
|
567
|
+
cancel.withAsset({
|
|
568
|
+
assetId: a.assetId,
|
|
569
|
+
inputs: [{ vin: 0, amount: BigInt(a.amount) }],
|
|
570
|
+
outputs: [{ vout: 0, amount: BigInt(a.amount) }]
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
const swapId = fundingTxid ?? vtxo.txid;
|
|
574
|
+
const hasLocalRecord = (await getAssetSwapsOrThrow(repository)).some((s) => s.id === swapId);
|
|
575
|
+
if (hasLocalRecord) await updateAssetSwap(repository, swapId, { status: "cancelling" });
|
|
576
|
+
const { txid } = await cancel.send();
|
|
577
|
+
if (hasLocalRecord) {
|
|
578
|
+
const { persisted, swaps } = await updateAssetSwapBestEffort(repository, swapId, {
|
|
579
|
+
status: "cancelled",
|
|
580
|
+
spentTxid: txid
|
|
581
|
+
});
|
|
582
|
+
if (persisted) {
|
|
583
|
+
await retireOfferContract(contractManager, swaps, import_base.hex.encode(offer.swapPkScript));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return txid;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/markets.ts
|
|
590
|
+
var import_solver_discovery = require("@arkade-os/solver-discovery");
|
|
591
|
+
var import_sdk2 = require("@arkade-os/sdk");
|
|
592
|
+
var QUOTE_OPTIONS = { safetyBps: 0 };
|
|
593
|
+
var makeCachedFeedFetch = (ttlMs = 3e4, fetchImpl = fetch) => {
|
|
594
|
+
const cache = /* @__PURE__ */ new Map();
|
|
595
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
596
|
+
return async (input, init) => {
|
|
597
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
598
|
+
const hit = cache.get(url);
|
|
599
|
+
if (hit) {
|
|
600
|
+
if (Date.now() - hit.at < ttlMs) return new Response(hit.body);
|
|
601
|
+
cache.delete(url);
|
|
602
|
+
}
|
|
603
|
+
const pending = inflight.get(url);
|
|
604
|
+
if (pending) {
|
|
605
|
+
const body = await pending;
|
|
606
|
+
if (body !== void 0) return new Response(body);
|
|
607
|
+
}
|
|
608
|
+
let settle = () => {
|
|
609
|
+
};
|
|
610
|
+
inflight.set(
|
|
611
|
+
url,
|
|
612
|
+
new Promise((resolve) => {
|
|
613
|
+
settle = resolve;
|
|
614
|
+
})
|
|
615
|
+
);
|
|
616
|
+
try {
|
|
617
|
+
const response = await fetchImpl(input, init);
|
|
618
|
+
let body;
|
|
619
|
+
if (response.ok) {
|
|
620
|
+
try {
|
|
621
|
+
body = await response.clone().text();
|
|
622
|
+
cache.set(url, { at: Date.now(), body });
|
|
623
|
+
} catch {
|
|
624
|
+
body = void 0;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
settle(body);
|
|
628
|
+
return response;
|
|
629
|
+
} catch (err) {
|
|
630
|
+
settle(void 0);
|
|
631
|
+
throw err;
|
|
632
|
+
} finally {
|
|
633
|
+
inflight.delete(url);
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
};
|
|
637
|
+
var MARKETS_CACHE_TTL_MS = 60 * 60 * 1e3;
|
|
638
|
+
var isMarketShaped = (m) => {
|
|
639
|
+
const market = m;
|
|
640
|
+
return typeof market?.pair === "string" && typeof market.base_asset?.id === "string" && typeof market.quote_asset?.id === "string" && typeof market.quote_asset.decimals === "number";
|
|
641
|
+
};
|
|
642
|
+
var readMarketsCache = async (repository, network, registry) => {
|
|
643
|
+
try {
|
|
644
|
+
const entry = await repository.getCachedMarkets(network, registry);
|
|
645
|
+
if (!Array.isArray(entry?.markets) || typeof entry?.fetchedAt !== "number")
|
|
646
|
+
return void 0;
|
|
647
|
+
return entry.markets.every(isMarketShaped) ? entry : void 0;
|
|
648
|
+
} catch {
|
|
649
|
+
return void 0;
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
var discoverMarkets = async (options) => {
|
|
653
|
+
const {
|
|
654
|
+
network,
|
|
655
|
+
registryUrl: registry,
|
|
656
|
+
repository,
|
|
657
|
+
localCards = [],
|
|
658
|
+
logger,
|
|
659
|
+
fetchImpl,
|
|
660
|
+
useCache = true
|
|
661
|
+
} = options;
|
|
662
|
+
if (!registry || !(0, import_solver_discovery.isNetwork)(network)) return [];
|
|
663
|
+
const cached = repository && await readMarketsCache(repository, network, registry);
|
|
664
|
+
if (useCache && cached && Date.now() - cached.fetchedAt < MARKETS_CACHE_TTL_MS)
|
|
665
|
+
return cached.markets;
|
|
666
|
+
const { markets, sources, warnings } = await (0, import_solver_discovery.discover)({
|
|
667
|
+
registries: [registry],
|
|
668
|
+
localCards,
|
|
669
|
+
network,
|
|
670
|
+
fetchImpl
|
|
671
|
+
});
|
|
672
|
+
if (warnings.length) logger?.("solver discovery:", ...warnings);
|
|
673
|
+
const reachable = sources.some((source) => source.ok);
|
|
674
|
+
if (!reachable && cached) return cached.markets;
|
|
675
|
+
if (reachable && repository) {
|
|
676
|
+
try {
|
|
677
|
+
await repository.saveCachedMarkets(network, registry, {
|
|
678
|
+
markets,
|
|
679
|
+
fetchedAt: Date.now()
|
|
680
|
+
});
|
|
681
|
+
} catch {
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return markets;
|
|
685
|
+
};
|
|
686
|
+
var findMarket = (markets, fromId, toId) => {
|
|
687
|
+
if (fromId === toId) return void 0;
|
|
688
|
+
const givingBase = (0, import_solver_discovery.bestMarket)(markets, { baseId: fromId, quoteId: toId, wantSide: "quote" });
|
|
689
|
+
if (givingBase) return { market: givingBase, give: "base" };
|
|
690
|
+
return {
|
|
691
|
+
market: (0, import_solver_discovery.bestMarket)(markets, { baseId: toId, quoteId: fromId, wantSide: "base" }),
|
|
692
|
+
give: "quote"
|
|
693
|
+
};
|
|
694
|
+
};
|
|
695
|
+
var validatePlan = (plan, giveBalance, dust) => {
|
|
696
|
+
if (plan.deposit.atomic > giveBalance) return "insufficient-balance";
|
|
697
|
+
const { min, max, withinLimits } = plan.limits;
|
|
698
|
+
if (!min || !max) return "side-disabled";
|
|
699
|
+
const giveLimits = (0, import_solver_discovery.sideLimits)(plan.market, plan.give);
|
|
700
|
+
if (!giveLimits) return "side-disabled";
|
|
701
|
+
if (plan.deposit.atomic < giveLimits.min) return "below-min";
|
|
702
|
+
if (plan.deposit.atomic > giveLimits.max) return "above-max";
|
|
703
|
+
if (!withinLimits) return plan.receive.atomic < min.atomic ? "below-min" : "above-max";
|
|
704
|
+
const depositIsBtc = plan.deposit.asset.id === BTC_ASSET_ID;
|
|
705
|
+
const receiveIsBtc = plan.receive.asset.id === BTC_ASSET_ID;
|
|
706
|
+
if (depositIsBtc || receiveIsBtc) {
|
|
707
|
+
const btcSide = depositIsBtc ? plan.deposit.atomic : plan.receive.atomic;
|
|
708
|
+
if ((0, import_sdk2.isSubdust)(btcSide, dust)) return "below-dust";
|
|
709
|
+
}
|
|
710
|
+
return void 0;
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
// src/repository.ts
|
|
714
|
+
var marketsCacheKey = (network, registry) => `arkade-intents-markets-${network}-${registry}`;
|
|
715
|
+
var InMemoryAssetSwapRepository = class {
|
|
716
|
+
version = 1;
|
|
717
|
+
swaps = /* @__PURE__ */ new Map();
|
|
718
|
+
scanned = /* @__PURE__ */ new Set();
|
|
719
|
+
markets = /* @__PURE__ */ new Map();
|
|
720
|
+
async saveSwap(swap) {
|
|
721
|
+
this.swaps.set(swap.id, swap);
|
|
722
|
+
}
|
|
723
|
+
async getAllSwaps() {
|
|
724
|
+
return [...this.swaps.values()];
|
|
725
|
+
}
|
|
726
|
+
async getScannedTxids() {
|
|
727
|
+
return new Set(this.scanned);
|
|
728
|
+
}
|
|
729
|
+
async markTxidsScanned(txids) {
|
|
730
|
+
for (const txid of txids) this.scanned.add(txid);
|
|
731
|
+
}
|
|
732
|
+
async getCachedMarkets(network, registry) {
|
|
733
|
+
return this.markets.get(marketsCacheKey(network, registry));
|
|
734
|
+
}
|
|
735
|
+
async saveCachedMarkets(network, registry, entry) {
|
|
736
|
+
this.markets.set(marketsCacheKey(network, registry), entry);
|
|
737
|
+
}
|
|
738
|
+
async clear() {
|
|
739
|
+
this.swaps.clear();
|
|
740
|
+
this.scanned.clear();
|
|
741
|
+
this.markets.clear();
|
|
742
|
+
}
|
|
743
|
+
async [Symbol.asyncDispose]() {
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
// src/indexedDbRepository.ts
|
|
748
|
+
var import_sdk3 = require("@arkade-os/sdk");
|
|
749
|
+
var DEFAULT_DB_NAME = "arkade-intents";
|
|
750
|
+
var DB_VERSION = 1;
|
|
751
|
+
var STORE_SWAPS = "swaps";
|
|
752
|
+
var STORE_SCANNED = "scannedTxids";
|
|
753
|
+
var STORE_MARKETS = "markets";
|
|
754
|
+
var STORES = [
|
|
755
|
+
[STORE_SWAPS, { keyPath: "id" }],
|
|
756
|
+
[STORE_SCANNED],
|
|
757
|
+
[STORE_MARKETS]
|
|
758
|
+
];
|
|
759
|
+
function initDatabase(db) {
|
|
760
|
+
for (const [name, options] of STORES) {
|
|
761
|
+
if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, options);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
var request = (req) => new Promise((resolve, reject) => {
|
|
765
|
+
req.onsuccess = () => resolve(req.result);
|
|
766
|
+
req.onerror = () => reject(req.error);
|
|
767
|
+
});
|
|
768
|
+
var txDone = (tx) => new Promise((resolve, reject) => {
|
|
769
|
+
tx.oncomplete = () => resolve();
|
|
770
|
+
tx.onerror = () => reject(tx.error);
|
|
771
|
+
tx.onabort = () => reject(tx.error);
|
|
772
|
+
});
|
|
773
|
+
var IndexedDbAssetSwapRepository = class {
|
|
774
|
+
constructor(dbName = DEFAULT_DB_NAME) {
|
|
775
|
+
this.dbName = dbName;
|
|
776
|
+
}
|
|
777
|
+
dbName;
|
|
778
|
+
version = 1;
|
|
779
|
+
// the promise, not the resolved database: openDatabase bumps a refcount on
|
|
780
|
+
// every call including cache hits, while dispose closes once, so two
|
|
781
|
+
// concurrent first calls would strand the refcount above zero and leak the
|
|
782
|
+
// connection for the process lifetime. Cleared on failure so a failed open
|
|
783
|
+
// can be retried rather than cached forever.
|
|
784
|
+
dbPromise = null;
|
|
785
|
+
ensureDb() {
|
|
786
|
+
return this.dbPromise ??= (0, import_sdk3.openDatabase)(this.dbName, DB_VERSION, initDatabase).catch(
|
|
787
|
+
(err) => {
|
|
788
|
+
this.dbPromise = null;
|
|
789
|
+
throw err;
|
|
790
|
+
}
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
async readStore(name) {
|
|
794
|
+
return (await this.ensureDb()).transaction([name], "readonly").objectStore(name);
|
|
795
|
+
}
|
|
796
|
+
/** Every write in one place, so none of them can forget to await the
|
|
797
|
+
* commit. Requests need no individual await: a failed one aborts the
|
|
798
|
+
* transaction, which `txDone` reports. */
|
|
799
|
+
async write(name, apply) {
|
|
800
|
+
const tx = (await this.ensureDb()).transaction([name], "readwrite");
|
|
801
|
+
const done = txDone(tx);
|
|
802
|
+
apply(tx.objectStore(name));
|
|
803
|
+
await done;
|
|
804
|
+
}
|
|
805
|
+
async saveSwap(swap) {
|
|
806
|
+
await this.write(STORE_SWAPS, (store) => {
|
|
807
|
+
store.put(swap);
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
async getAllSwaps() {
|
|
811
|
+
return request((await this.readStore(STORE_SWAPS)).getAll());
|
|
812
|
+
}
|
|
813
|
+
async getScannedTxids() {
|
|
814
|
+
const keys = await request((await this.readStore(STORE_SCANNED)).getAllKeys());
|
|
815
|
+
return new Set(keys);
|
|
816
|
+
}
|
|
817
|
+
async markTxidsScanned(txids) {
|
|
818
|
+
await this.write(STORE_SCANNED, (store) => {
|
|
819
|
+
for (const txid of txids) store.put(txid, txid);
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
async getCachedMarkets(network, registry) {
|
|
823
|
+
const store = await this.readStore(STORE_MARKETS);
|
|
824
|
+
return request(store.get(marketsCacheKey(network, registry)));
|
|
825
|
+
}
|
|
826
|
+
async saveCachedMarkets(network, registry, entry) {
|
|
827
|
+
await this.write(STORE_MARKETS, (store) => {
|
|
828
|
+
store.put(entry, marketsCacheKey(network, registry));
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
/** All stores in one transaction: clearing swaps but keeping scanned txids
|
|
832
|
+
* would leave the restore scan permanently skipping those funding txs, so
|
|
833
|
+
* a partial clear must not be observable. */
|
|
834
|
+
async clear() {
|
|
835
|
+
const stores = STORES.map(([name]) => name);
|
|
836
|
+
const tx = (await this.ensureDb()).transaction(stores, "readwrite");
|
|
837
|
+
const done = txDone(tx);
|
|
838
|
+
for (const name of stores) tx.objectStore(name).clear();
|
|
839
|
+
await done;
|
|
840
|
+
}
|
|
841
|
+
async [Symbol.asyncDispose]() {
|
|
842
|
+
if (!this.dbPromise) return;
|
|
843
|
+
await (0, import_sdk3.closeDatabase)(this.dbName);
|
|
844
|
+
this.dbPromise = null;
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
// src/restore.ts
|
|
849
|
+
var import_base2 = require("@scure/base");
|
|
850
|
+
var import_sdk4 = require("@arkade-os/sdk");
|
|
851
|
+
var TXS_PER_REQUEST = 50;
|
|
852
|
+
async function fetchParsedTxs(indexer, txids) {
|
|
853
|
+
const parsedByTxid = /* @__PURE__ */ new Map();
|
|
854
|
+
if (txids.length === 0) return parsedByTxid;
|
|
855
|
+
const chunks = [];
|
|
856
|
+
for (let i = 0; i < txids.length; i += TXS_PER_REQUEST) {
|
|
857
|
+
chunks.push(txids.slice(i, i + TXS_PER_REQUEST));
|
|
858
|
+
}
|
|
859
|
+
const chunkResults = await Promise.allSettled(
|
|
860
|
+
chunks.map(async (ids) => (await indexer.getVirtualTxs(ids)).txs)
|
|
861
|
+
);
|
|
862
|
+
for (const result of chunkResults) {
|
|
863
|
+
if (result.status !== "fulfilled") continue;
|
|
864
|
+
for (const psbt of result.value) {
|
|
865
|
+
try {
|
|
866
|
+
const parsed = import_sdk4.Transaction.fromPSBT(import_base2.base64.decode(psbt));
|
|
867
|
+
parsedByTxid.set(parsed.id, parsed);
|
|
868
|
+
} catch {
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return parsedByTxid;
|
|
873
|
+
}
|
|
874
|
+
var unscannedSwapCandidates = (txs, existingIds, scanned) => txs.filter(
|
|
875
|
+
(tx) => tx.type === "sent" && tx.redeemTxid && !existingIds.has(tx.redeemTxid) && !scanned.has(tx.redeemTxid)
|
|
876
|
+
);
|
|
877
|
+
function classifySpend(offer, serverPubkey, spendTx, deposit) {
|
|
878
|
+
let leaves;
|
|
879
|
+
try {
|
|
880
|
+
const script = offerVtxoScript(offer, serverPubkey);
|
|
881
|
+
if (import_base2.hex.encode(script.pkScript) !== import_base2.hex.encode(offer.swapPkScript)) return "indeterminate";
|
|
882
|
+
leaves = {
|
|
883
|
+
cancel: script.functionByName("cancel")?.leafScript,
|
|
884
|
+
fulfill: script.functionByName("fulfill")?.leafScript
|
|
885
|
+
};
|
|
886
|
+
} catch {
|
|
887
|
+
return "indeterminate";
|
|
888
|
+
}
|
|
889
|
+
for (let i = 0; i < spendTx.inputsLength; i++) {
|
|
890
|
+
const input = spendTx.getInput(i);
|
|
891
|
+
if (!input.txid || input.index !== deposit.vout) continue;
|
|
892
|
+
if (import_base2.hex.encode(input.txid) !== deposit.txid) continue;
|
|
893
|
+
for (const leaf of input.tapLeafScript ?? []) {
|
|
894
|
+
const spent = import_base2.hex.encode((0, import_sdk4.scriptFromTapLeafScript)(leaf));
|
|
895
|
+
if (leaves.cancel && spent === import_base2.hex.encode(leaves.cancel)) return "cancelled";
|
|
896
|
+
if (leaves.fulfill && spent === import_base2.hex.encode(leaves.fulfill)) return "fulfilled";
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return "indeterminate";
|
|
900
|
+
}
|
|
901
|
+
var spendTxidsOf = (vtxo) => [vtxo.spentBy, vtxo.arkTxId].filter((id) => Boolean(id));
|
|
902
|
+
function classifyDepositSpend(offer, serverPubkey, spendTxs, deposit) {
|
|
903
|
+
for (const tx of spendTxs) {
|
|
904
|
+
const kind = classifySpend(offer, serverPubkey, tx, deposit);
|
|
905
|
+
if (kind !== "indeterminate") return kind;
|
|
906
|
+
}
|
|
907
|
+
return "indeterminate";
|
|
908
|
+
}
|
|
909
|
+
async function restoreAssetSwaps(indexer, txs, existingIds, opts) {
|
|
910
|
+
const { serverPubkey, scanned = /* @__PURE__ */ new Set() } = opts;
|
|
911
|
+
const candidates = unscannedSwapCandidates(txs, existingIds, scanned);
|
|
912
|
+
if (candidates.length === 0) return { restored: [], scannedTxids: [] };
|
|
913
|
+
const byTxid = new Map(candidates.map((tx) => [tx.redeemTxid, tx]));
|
|
914
|
+
const parsedByTxid = await fetchParsedTxs(
|
|
915
|
+
indexer,
|
|
916
|
+
candidates.map((tx) => tx.redeemTxid)
|
|
917
|
+
);
|
|
918
|
+
const fetchedTxids = [];
|
|
919
|
+
const found = [];
|
|
920
|
+
for (const [txid, parsed] of parsedByTxid) {
|
|
921
|
+
const fundingTx = byTxid.get(txid);
|
|
922
|
+
if (!fundingTx) continue;
|
|
923
|
+
fetchedTxids.push(txid);
|
|
924
|
+
try {
|
|
925
|
+
const packet = import_sdk4.Extension.fromTx(parsed).getPacketByType(OFFER_PACKET_TYPE);
|
|
926
|
+
if (!packet) continue;
|
|
927
|
+
const payload = packet.serialize();
|
|
928
|
+
found.push({
|
|
929
|
+
fundingTx,
|
|
930
|
+
offer: decodeOffer(payload),
|
|
931
|
+
offerHex: import_base2.hex.encode(payload)
|
|
932
|
+
});
|
|
933
|
+
} catch {
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (found.length === 0) return { restored: [], scannedTxids: fetchedTxids };
|
|
937
|
+
const scripts = [...new Set(found.map((f) => import_base2.hex.encode(f.offer.swapPkScript)))];
|
|
938
|
+
const { vtxos } = await indexer.getVtxos({ scripts });
|
|
939
|
+
const vtxoByScriptAndTxid = new Map(vtxos.map((v) => [`${v.script}:${v.txid}`, v]));
|
|
940
|
+
const txByAnyId = /* @__PURE__ */ new Map();
|
|
941
|
+
for (const tx of txs) {
|
|
942
|
+
for (const id of [tx.boardingTxid, tx.redeemTxid, tx.roundTxid]) {
|
|
943
|
+
if (id) txByAnyId.set(id, tx);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const spendTxids = /* @__PURE__ */ new Set();
|
|
947
|
+
for (const { fundingTx, offer } of found) {
|
|
948
|
+
const vtxo = vtxoByScriptAndTxid.get(
|
|
949
|
+
`${import_base2.hex.encode(offer.swapPkScript)}:${fundingTx.redeemTxid}`
|
|
950
|
+
);
|
|
951
|
+
if (vtxo?.virtualStatus.state !== "spent") continue;
|
|
952
|
+
for (const txid of spendTxidsOf(vtxo)) spendTxids.add(txid);
|
|
953
|
+
}
|
|
954
|
+
const spendTxByTxid = await fetchParsedTxs(indexer, [...spendTxids]);
|
|
955
|
+
const restored = [];
|
|
956
|
+
const unresolved = /* @__PURE__ */ new Set();
|
|
957
|
+
for (const { fundingTx, offer, offerHex } of found) {
|
|
958
|
+
const swapPkScript = import_base2.hex.encode(offer.swapPkScript);
|
|
959
|
+
const vtxo = vtxoByScriptAndTxid.get(`${swapPkScript}:${fundingTx.redeemTxid}`);
|
|
960
|
+
if (!vtxo) {
|
|
961
|
+
unresolved.add(fundingTx.redeemTxid);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
const depositRider = vtxo.assets?.length === 1 && vtxo.assets[0].amount > BigInt(0) ? vtxo.assets[0] : void 0;
|
|
965
|
+
const fromAsset = offer.offerAsset?.toString() ?? depositRider?.assetId ?? BTC_ASSET_ID;
|
|
966
|
+
const toAsset = offer.wantAsset?.toString() ?? BTC_ASSET_ID;
|
|
967
|
+
const depositAmount = fromAsset === BTC_ASSET_ID ? BigInt(vtxo.value) : vtxo.assets?.find((a) => a.assetId === fromAsset)?.amount;
|
|
968
|
+
if (depositAmount === void 0) {
|
|
969
|
+
unresolved.add(fundingTx.redeemTxid);
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
const fromAmount = depositAmount.toString();
|
|
973
|
+
const state = vtxo.virtualStatus.state;
|
|
974
|
+
const spentTxid = state === "spent" ? vtxo.arkTxId || vtxo.spentBy : void 0;
|
|
975
|
+
let status = "pending";
|
|
976
|
+
if (state === "swept") status = "recoverable";
|
|
977
|
+
else if (state === "spent") {
|
|
978
|
+
const spendTxs = spendTxidsOf(vtxo).map((id) => spendTxByTxid.get(id)).filter((tx) => tx !== void 0);
|
|
979
|
+
const kind = classifyDepositSpend(offer, serverPubkey, spendTxs, {
|
|
980
|
+
txid: vtxo.txid,
|
|
981
|
+
vout: vtxo.vout
|
|
982
|
+
});
|
|
983
|
+
if (kind === "indeterminate") {
|
|
984
|
+
unresolved.add(fundingTx.redeemTxid);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
status = kind;
|
|
988
|
+
}
|
|
989
|
+
restored.push({
|
|
990
|
+
id: fundingTx.redeemTxid,
|
|
991
|
+
fromAsset,
|
|
992
|
+
toAsset,
|
|
993
|
+
fromAmount,
|
|
994
|
+
toAmount: offer.wantAmount.toString(),
|
|
995
|
+
// ponytail(arkade-os/ts-sdk#680): empty address makes cancel fall back
|
|
996
|
+
// to the current server key; store the funded address if server-key
|
|
997
|
+
// rotations become real (cancelOffer now at least diagnoses the
|
|
998
|
+
// mismatch instead of reporting a missing VTXO)
|
|
999
|
+
swapAddress: "",
|
|
1000
|
+
swapPkScript,
|
|
1001
|
+
offerHex,
|
|
1002
|
+
fundingTxid: fundingTx.redeemTxid,
|
|
1003
|
+
spentTxid,
|
|
1004
|
+
status,
|
|
1005
|
+
createdAt: fundingTx.createdAt ? fundingTx.createdAt * 1e3 : vtxo.createdAt.getTime(),
|
|
1006
|
+
// the completion time is the caller's record of the spend, if it
|
|
1007
|
+
// has one — the psbt that classified it carries no timestamp
|
|
1008
|
+
...status === "fulfilled" && spentTxid && txByAnyId.get(spentTxid)?.createdAt ? { completedAt: txByAnyId.get(spentTxid).createdAt * 1e3 } : {}
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
return { restored, scannedTxids: fetchedTxids.filter((id) => !unresolved.has(id)) };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// src/watch.ts
|
|
1015
|
+
var import_base3 = require("@scure/base");
|
|
1016
|
+
var import_sdk5 = require("@arkade-os/sdk");
|
|
1017
|
+
var TERMINAL = ["fulfilled", "cancelled", "recoverable"];
|
|
1018
|
+
function spendUpdate(swap, spend) {
|
|
1019
|
+
if (TERMINAL.includes(swap.status)) return void 0;
|
|
1020
|
+
if (spend.kind === "indeterminate") return void 0;
|
|
1021
|
+
const status = spend.kind === "cancelled" ? "cancelled" : "fulfilled";
|
|
1022
|
+
return {
|
|
1023
|
+
status,
|
|
1024
|
+
spentTxid: spend.txid,
|
|
1025
|
+
// mirrors restore.ts: a completion time is a fill's, not a cancel's
|
|
1026
|
+
...status === "fulfilled" && spend.at ? { completedAt: spend.at } : {}
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
async function watchOfferSwaps({
|
|
1030
|
+
wallet,
|
|
1031
|
+
arkServerUrl,
|
|
1032
|
+
repository,
|
|
1033
|
+
onUpdate
|
|
1034
|
+
}) {
|
|
1035
|
+
const manager = await wallet.getContractManager();
|
|
1036
|
+
const serverPubkey = import_sdk5.ArkAddress.decode(await wallet.getAddress()).serverPubKey;
|
|
1037
|
+
const indexer = new import_sdk5.RestIndexerProvider(arkServerUrl);
|
|
1038
|
+
let queue = Promise.resolve();
|
|
1039
|
+
const enqueue = (task) => {
|
|
1040
|
+
queue = queue.then(task).catch(() => {
|
|
1041
|
+
});
|
|
1042
|
+
};
|
|
1043
|
+
const classify = async (swap, vtxo, spentTxid) => {
|
|
1044
|
+
if (swap.spentTxid === spentTxid && swap.status === "cancelling") return "cancelled";
|
|
1045
|
+
try {
|
|
1046
|
+
const candidates = spendTxidsOf(vtxo);
|
|
1047
|
+
if (candidates.length === 0) return "indeterminate";
|
|
1048
|
+
const { txs } = await indexer.getVirtualTxs(candidates);
|
|
1049
|
+
return classifyDepositSpend(
|
|
1050
|
+
decodeOffer(import_base3.hex.decode(swap.offerHex)),
|
|
1051
|
+
serverPubkey,
|
|
1052
|
+
txs.map((psbt) => import_sdk5.Transaction.fromPSBT(import_base3.base64.decode(psbt))),
|
|
1053
|
+
{ txid: vtxo.txid, vout: vtxo.vout }
|
|
1054
|
+
);
|
|
1055
|
+
} catch {
|
|
1056
|
+
return "indeterminate";
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
const handleSpend = async (event) => {
|
|
1060
|
+
if (event.contract.metadata?.kind !== OFFER_CONTRACT_KIND) return;
|
|
1061
|
+
for (const vtxo of event.vtxos) {
|
|
1062
|
+
const spentTxid = vtxo.arkTxId || vtxo.spentBy;
|
|
1063
|
+
if (!spentTxid) continue;
|
|
1064
|
+
const swap = (await getAssetSwaps(repository)).find(
|
|
1065
|
+
(s) => s.fundingTxid === vtxo.txid && s.swapPkScript === event.contractScript
|
|
1066
|
+
);
|
|
1067
|
+
if (!swap) continue;
|
|
1068
|
+
const kind = await classify(swap, vtxo, spentTxid);
|
|
1069
|
+
const changes = spendUpdate(swap, { txid: spentTxid, kind, at: event.timestamp });
|
|
1070
|
+
if (!changes) continue;
|
|
1071
|
+
const { persisted, swaps } = await updateAssetSwapBestEffort(
|
|
1072
|
+
repository,
|
|
1073
|
+
swap.id,
|
|
1074
|
+
changes
|
|
1075
|
+
);
|
|
1076
|
+
if (!persisted) continue;
|
|
1077
|
+
onUpdate?.({ ...swap, ...changes });
|
|
1078
|
+
if (changes.status && RETIRABLE.includes(changes.status)) {
|
|
1079
|
+
await retireOfferContract(manager, swaps, event.contractScript);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
const unsubscribe = manager.onContractEvent((event) => {
|
|
1084
|
+
if (event.type !== "vtxo_spent") return;
|
|
1085
|
+
enqueue(() => handleSpend(event));
|
|
1086
|
+
});
|
|
1087
|
+
return {
|
|
1088
|
+
stop: unsubscribe,
|
|
1089
|
+
idle: () => queue
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// src/rfq.ts
|
|
1094
|
+
var import_base8 = require("@scure/base");
|
|
1095
|
+
var import_legacy2 = require("@noble/hashes/legacy.js");
|
|
1096
|
+
var import_sdk8 = require("@arkade-os/sdk");
|
|
1097
|
+
|
|
1098
|
+
// src/onchainHtlc.ts
|
|
1099
|
+
var import_base4 = require("@scure/base");
|
|
1100
|
+
var import_sha2 = require("@noble/hashes/sha2.js");
|
|
1101
|
+
var import_legacy = require("@noble/hashes/legacy.js");
|
|
1102
|
+
var btc = __toESM(require("@scure/btc-signer"), 1);
|
|
1103
|
+
var ONCHAIN_ORDER_MARGIN_SECONDS = 2 * 60 * 60;
|
|
1104
|
+
var ONCHAIN_CLAIM_MARGIN_SECONDS = 90 * 60;
|
|
1105
|
+
var MAX_MIN_CONFIRMATIONS = 6;
|
|
1106
|
+
var ONCHAIN_SECONDS_PER_BLOCK = 600;
|
|
1107
|
+
var ONCHAIN_DUST_SATS = BigInt(330);
|
|
1108
|
+
var newPreimage = () => crypto.getRandomValues(new Uint8Array(32));
|
|
1109
|
+
var paymentHashOf = (preimage) => import_base4.hex.encode((0, import_sha2.sha256)(preimage));
|
|
1110
|
+
var h160FromPaymentHash = (paymentHash) => (0, import_legacy.ripemd160)(import_base4.hex.decode(paymentHash));
|
|
1111
|
+
var L1_NETWORKS = {
|
|
1112
|
+
bitcoin: btc.NETWORK,
|
|
1113
|
+
testnet: btc.TEST_NETWORK,
|
|
1114
|
+
regtest: { ...btc.TEST_NETWORK, bech32: "bcrt" }
|
|
1115
|
+
};
|
|
1116
|
+
function onchainHtlcScript(params, network) {
|
|
1117
|
+
if (params.claimKey.length !== 32 || params.refundKey.length !== 32) {
|
|
1118
|
+
throw new Error("claimKey and refundKey must be 32-byte x-only keys");
|
|
1119
|
+
}
|
|
1120
|
+
if (!Number.isInteger(params.refundLocktime) || params.refundLocktime <= 0) {
|
|
1121
|
+
throw new Error(
|
|
1122
|
+
`refundLocktime must be a positive unix timestamp, got ${params.refundLocktime}`
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
const h160 = h160FromPaymentHash(params.paymentHash);
|
|
1126
|
+
const claim = btc.Script.encode([
|
|
1127
|
+
"SIZE",
|
|
1128
|
+
32,
|
|
1129
|
+
"EQUALVERIFY",
|
|
1130
|
+
"HASH160",
|
|
1131
|
+
h160,
|
|
1132
|
+
"EQUALVERIFY",
|
|
1133
|
+
params.claimKey,
|
|
1134
|
+
"CHECKSIG"
|
|
1135
|
+
]);
|
|
1136
|
+
const refund = btc.Script.encode([
|
|
1137
|
+
btc.ScriptNum().encode(BigInt(params.refundLocktime)),
|
|
1138
|
+
"CHECKLOCKTIMEVERIFY",
|
|
1139
|
+
"DROP",
|
|
1140
|
+
params.refundKey,
|
|
1141
|
+
"CHECKSIG"
|
|
1142
|
+
]);
|
|
1143
|
+
const payment = btc.p2tr(
|
|
1144
|
+
btc.TAPROOT_UNSPENDABLE_KEY,
|
|
1145
|
+
btc.taprootListToTree([{ script: claim }, { script: refund }]),
|
|
1146
|
+
L1_NETWORKS[network],
|
|
1147
|
+
true
|
|
1148
|
+
);
|
|
1149
|
+
const controlBlockFor = (leaf) => {
|
|
1150
|
+
for (const [block, script] of payment.tapLeafScript ?? []) {
|
|
1151
|
+
if (script.length - 1 === leaf.length && import_base4.hex.encode(script.subarray(0, leaf.length)) === import_base4.hex.encode(leaf)) {
|
|
1152
|
+
return btc.TaprootControlBlock.encode(block);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
throw new Error("leaf missing from compiled taproot tree");
|
|
1156
|
+
};
|
|
1157
|
+
return {
|
|
1158
|
+
address: payment.address,
|
|
1159
|
+
pkScript: payment.script,
|
|
1160
|
+
leaves: { claim, refund },
|
|
1161
|
+
controlBlocks: { claim: controlBlockFor(claim), refund: controlBlockFor(refund) },
|
|
1162
|
+
paymentHash: params.paymentHash,
|
|
1163
|
+
refundLocktime: params.refundLocktime
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
var buildLeafSpend = async (input) => {
|
|
1167
|
+
if (!Number.isFinite(input.feeRateSatVb) || input.feeRateSatVb <= 0) {
|
|
1168
|
+
throw new Error(`feeRateSatVb must be positive, got ${input.feeRateSatVb}`);
|
|
1169
|
+
}
|
|
1170
|
+
const assemble = (payout2) => {
|
|
1171
|
+
const tx2 = new btc.Transaction({ lockTime: input.lockTime ?? 0 });
|
|
1172
|
+
tx2.addInput({
|
|
1173
|
+
txid: input.utxo.txid,
|
|
1174
|
+
index: input.utxo.vout,
|
|
1175
|
+
witnessUtxo: { script: input.htlc.pkScript, amount: input.utxo.amount },
|
|
1176
|
+
sequence: input.sequence
|
|
1177
|
+
});
|
|
1178
|
+
tx2.addOutput({ script: input.payoutPkScript, amount: payout2 });
|
|
1179
|
+
return tx2;
|
|
1180
|
+
};
|
|
1181
|
+
const witness = (sig2) => [
|
|
1182
|
+
sig2,
|
|
1183
|
+
...input.stackAboveSig,
|
|
1184
|
+
input.leaf,
|
|
1185
|
+
input.controlBlock
|
|
1186
|
+
];
|
|
1187
|
+
const sizing = assemble(input.utxo.amount);
|
|
1188
|
+
sizing.updateInput(0, { finalScriptWitness: witness(new Uint8Array(64)) }, true);
|
|
1189
|
+
const fee = BigInt(Math.ceil(sizing.vsize * input.feeRateSatVb));
|
|
1190
|
+
const payout = input.utxo.amount - fee;
|
|
1191
|
+
if (payout < ONCHAIN_DUST_SATS) {
|
|
1192
|
+
throw new Error(
|
|
1193
|
+
`fee ${fee} leaves ${payout} sats from a ${input.utxo.amount} sat HTLC \u2014 below the ${ONCHAIN_DUST_SATS} sat dust limit`
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
const tx = assemble(payout);
|
|
1197
|
+
const sighash = tx.preimageWitnessV1(
|
|
1198
|
+
0,
|
|
1199
|
+
[input.htlc.pkScript],
|
|
1200
|
+
btc.SigHash.DEFAULT,
|
|
1201
|
+
[input.utxo.amount],
|
|
1202
|
+
void 0,
|
|
1203
|
+
input.leaf,
|
|
1204
|
+
192
|
|
1205
|
+
);
|
|
1206
|
+
const sig = await input.sign(sighash);
|
|
1207
|
+
if (sig.length !== 64)
|
|
1208
|
+
throw new Error(`sign() must return a 64-byte BIP340 signature, got ${sig.length}`);
|
|
1209
|
+
tx.updateInput(0, { finalScriptWitness: witness(sig) }, true);
|
|
1210
|
+
return { txHex: tx.hex, txid: tx.id, payoutAmount: payout };
|
|
1211
|
+
};
|
|
1212
|
+
var buildHtlcClaim = async (input) => {
|
|
1213
|
+
if (paymentHashOf(input.preimage) !== input.htlc.paymentHash) {
|
|
1214
|
+
throw new Error("preimage does not hash to the HTLC's payment hash");
|
|
1215
|
+
}
|
|
1216
|
+
return buildLeafSpend({
|
|
1217
|
+
htlc: input.htlc,
|
|
1218
|
+
utxo: input.utxo,
|
|
1219
|
+
leaf: input.htlc.leaves.claim,
|
|
1220
|
+
controlBlock: input.htlc.controlBlocks.claim,
|
|
1221
|
+
stackAboveSig: [input.preimage],
|
|
1222
|
+
payoutPkScript: input.payoutPkScript,
|
|
1223
|
+
feeRateSatVb: input.feeRateSatVb,
|
|
1224
|
+
sign: input.sign,
|
|
1225
|
+
sequence: 4294967293
|
|
1226
|
+
});
|
|
1227
|
+
};
|
|
1228
|
+
var buildHtlcRefund = (input) => buildLeafSpend({
|
|
1229
|
+
htlc: input.htlc,
|
|
1230
|
+
utxo: input.utxo,
|
|
1231
|
+
leaf: input.htlc.leaves.refund,
|
|
1232
|
+
controlBlock: input.htlc.controlBlocks.refund,
|
|
1233
|
+
stackAboveSig: [],
|
|
1234
|
+
payoutPkScript: input.payoutPkScript,
|
|
1235
|
+
feeRateSatVb: input.feeRateSatVb,
|
|
1236
|
+
sign: input.sign,
|
|
1237
|
+
lockTime: input.htlc.refundLocktime,
|
|
1238
|
+
// any value below 0xffffffff enables nLockTime enforcement
|
|
1239
|
+
sequence: 4294967294
|
|
1240
|
+
});
|
|
1241
|
+
function extractPreimage(txHex, paymentHash) {
|
|
1242
|
+
let raw;
|
|
1243
|
+
try {
|
|
1244
|
+
raw = btc.RawTx.decode(import_base4.hex.decode(txHex));
|
|
1245
|
+
} catch {
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
for (const stack of raw.witnesses ?? []) {
|
|
1249
|
+
for (const item of stack) {
|
|
1250
|
+
if (item.length === 32 && import_base4.hex.encode((0, import_sha2.sha256)(item)) === paymentHash) return item;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return null;
|
|
1254
|
+
}
|
|
1255
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1256
|
+
async function awaitOnchainFill(chain, htlc, minConfirmations, options = {}) {
|
|
1257
|
+
const pollMs = options.pollMs ?? 5e3;
|
|
1258
|
+
for (; ; ) {
|
|
1259
|
+
const utxos = await chain.getScriptUtxos(htlc.pkScript);
|
|
1260
|
+
const eligible = utxos.filter((u) => u.confirmations >= minConfirmations).sort((a, b) => b.amount > a.amount ? 1 : -1);
|
|
1261
|
+
if (eligible[0]) return eligible[0];
|
|
1262
|
+
if (options.deadline !== void 0 && Date.now() / 1e3 >= options.deadline) {
|
|
1263
|
+
const error = new Error("HTLC was not filled before the deadline");
|
|
1264
|
+
error.reason = "fill_timeout";
|
|
1265
|
+
throw error;
|
|
1266
|
+
}
|
|
1267
|
+
await sleep(pollMs);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
async function claimOnchainFill(chain, input) {
|
|
1271
|
+
const now = input.now ?? Math.floor(Date.now() / 1e3);
|
|
1272
|
+
if (input.htlc.refundLocktime - now < ONCHAIN_CLAIM_MARGIN_SECONDS) {
|
|
1273
|
+
const error = new Error(
|
|
1274
|
+
"refund leaf opens too soon to claim safely \u2014 take the covenant refund instead"
|
|
1275
|
+
);
|
|
1276
|
+
error.reason = "claim_window_closed";
|
|
1277
|
+
throw error;
|
|
1278
|
+
}
|
|
1279
|
+
const spend = await buildHtlcClaim(input);
|
|
1280
|
+
const txid = await chain.broadcast(spend.txHex);
|
|
1281
|
+
return { txid, payoutAmount: spend.payoutAmount };
|
|
1282
|
+
}
|
|
1283
|
+
async function classifyOnchainHtlc(chain, input) {
|
|
1284
|
+
const utxos = await chain.getScriptUtxos(input.htlc.pkScript);
|
|
1285
|
+
const best = utxos.sort((a, b) => b.amount > a.amount ? 1 : -1)[0];
|
|
1286
|
+
if (!best) {
|
|
1287
|
+
if (!input.funding) return { phase: "unfunded" };
|
|
1288
|
+
const spend = await chain.getSpendingTx(input.funding.txid, input.funding.vout);
|
|
1289
|
+
if (!spend) return { phase: "unfunded" };
|
|
1290
|
+
const preimage = extractPreimage(spend.txHex, input.htlc.paymentHash);
|
|
1291
|
+
const txid = btc.Transaction.fromRaw(import_base4.hex.decode(spend.txHex), {
|
|
1292
|
+
allowUnknownInputs: true,
|
|
1293
|
+
allowUnknownOutputs: true
|
|
1294
|
+
}).id;
|
|
1295
|
+
return preimage ? { phase: "claimed", txid, preimage } : { phase: "swept", txid };
|
|
1296
|
+
}
|
|
1297
|
+
if (best.confirmations < input.minConfirmations)
|
|
1298
|
+
return { phase: "awaiting_confirmations", utxo: best };
|
|
1299
|
+
const mtp = await chain.getMtp();
|
|
1300
|
+
if (mtp >= input.htlc.refundLocktime) return { phase: "refundable", utxo: best };
|
|
1301
|
+
return { phase: "claimable", utxo: best };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// src/secrets.ts
|
|
1305
|
+
var import_base5 = require("@scure/base");
|
|
1306
|
+
var import_sha22 = require("@noble/hashes/sha2.js");
|
|
1307
|
+
var import_utils2 = require("@noble/hashes/utils.js");
|
|
1308
|
+
var import_secp256k1 = require("@noble/curves/secp256k1.js");
|
|
1309
|
+
var import_sdk6 = require("@arkade-os/sdk");
|
|
1310
|
+
var RFQ_PREIMAGE_TAG = "Arkade-RFQ-Preimage-v1";
|
|
1311
|
+
function buildPreimageMessage(xonly, index) {
|
|
1312
|
+
if (xonly.length !== 32) {
|
|
1313
|
+
throw new Error(`x-only pubkey must be 32 bytes, got ${xonly.length}`);
|
|
1314
|
+
}
|
|
1315
|
+
if (!Number.isInteger(index) || index < 0 || index > 4294967295) {
|
|
1316
|
+
throw new Error(`index must be a u32, got ${index}`);
|
|
1317
|
+
}
|
|
1318
|
+
const tag = new TextEncoder().encode(RFQ_PREIMAGE_TAG);
|
|
1319
|
+
const message = new Uint8Array(tag.length + 32 + 4);
|
|
1320
|
+
message.set(tag, 0);
|
|
1321
|
+
message.set(xonly, tag.length);
|
|
1322
|
+
new DataView(message.buffer).setUint32(tag.length + 32, index, true);
|
|
1323
|
+
return message;
|
|
1324
|
+
}
|
|
1325
|
+
var PREIMAGE_INDEX = 0;
|
|
1326
|
+
async function deriveSwapSecrets(wallet) {
|
|
1327
|
+
if (!(0, import_sdk6.isHDAllocationCapable)(wallet)) return void 0;
|
|
1328
|
+
const signingDescriptor = await wallet.getNextSigningDescriptor();
|
|
1329
|
+
if (!signingDescriptor) return void 0;
|
|
1330
|
+
return { derivable: true, signingDescriptor };
|
|
1331
|
+
}
|
|
1332
|
+
function randomSwapSecrets(opts = {}) {
|
|
1333
|
+
if (opts.preimage instanceof Uint8Array && opts.preimage.length !== 32) {
|
|
1334
|
+
throw new Error(`preimage must be 32 bytes, got ${opts.preimage.length}`);
|
|
1335
|
+
}
|
|
1336
|
+
const preimage = opts.preimage instanceof Uint8Array ? opts.preimage : opts.preimage ? (0, import_utils2.randomBytes)(32) : void 0;
|
|
1337
|
+
return {
|
|
1338
|
+
derivable: false,
|
|
1339
|
+
senderPrivateKey: import_secp256k1.schnorr.utils.randomSecretKey(),
|
|
1340
|
+
...preimage ? { preimage } : {}
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
function rfqSecretsToRecord(secrets) {
|
|
1344
|
+
if (secrets.derivable) {
|
|
1345
|
+
return {
|
|
1346
|
+
signingDescriptor: secrets.signingDescriptor,
|
|
1347
|
+
...secrets.preimage ? { preimageHex: import_base5.hex.encode(secrets.preimage) } : {}
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
return {
|
|
1351
|
+
fallbackSecrets: {
|
|
1352
|
+
version: 1,
|
|
1353
|
+
type: "stored",
|
|
1354
|
+
senderPrivateKeyHex: import_base5.hex.encode(secrets.senderPrivateKey),
|
|
1355
|
+
...secrets.preimage ? { preimageHex: import_base5.hex.encode(secrets.preimage) } : {}
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
function rfqSecretsOfRecord(record) {
|
|
1360
|
+
if (record.signingDescriptor) {
|
|
1361
|
+
return {
|
|
1362
|
+
derivable: true,
|
|
1363
|
+
signingDescriptor: record.signingDescriptor,
|
|
1364
|
+
...record.preimageHex ? { preimage: decodeHex32(record.preimageHex, "preimageHex") } : {}
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
const fallback = record.fallbackSecrets;
|
|
1368
|
+
if (!fallback) return void 0;
|
|
1369
|
+
if (fallback.version !== 1 || fallback.type !== "stored") {
|
|
1370
|
+
throw new Error("unsupported RFQ fallback secrets record");
|
|
1371
|
+
}
|
|
1372
|
+
return {
|
|
1373
|
+
derivable: false,
|
|
1374
|
+
senderPrivateKey: decodeHex32(fallback.senderPrivateKeyHex, "senderPrivateKeyHex"),
|
|
1375
|
+
...fallback.preimageHex ? { preimage: decodeHex32(fallback.preimageHex, "preimageHex") } : {}
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
function decodeHex32(value, label) {
|
|
1379
|
+
const bytes = import_base5.hex.decode(value);
|
|
1380
|
+
if (bytes.length !== 32) {
|
|
1381
|
+
throw new Error(label + " must be 32 bytes, got " + bytes.length);
|
|
1382
|
+
}
|
|
1383
|
+
return bytes;
|
|
1384
|
+
}
|
|
1385
|
+
async function adoptSwapDescriptor(wallet, signingDescriptor) {
|
|
1386
|
+
if (!(0, import_sdk6.isHDAllocationCapable)(wallet)) return;
|
|
1387
|
+
await wallet.advanceSigningDescriptorWatermark(signingDescriptor);
|
|
1388
|
+
}
|
|
1389
|
+
var RefundNotLocallyPossibleError = class extends Error {
|
|
1390
|
+
constructor(reason, message, options) {
|
|
1391
|
+
super(message, options);
|
|
1392
|
+
this.reason = reason;
|
|
1393
|
+
}
|
|
1394
|
+
reason;
|
|
1395
|
+
name = "RefundNotLocallyPossibleError";
|
|
1396
|
+
};
|
|
1397
|
+
async function senderIdentityForRfqSecrets(wallet, secrets) {
|
|
1398
|
+
if (!secrets.derivable) return import_sdk6.SingleKey.fromPrivateKey(secrets.senderPrivateKey);
|
|
1399
|
+
const signer = (0, import_sdk6.isHDWalletCapable)(wallet) ? await wallet.signerForDescriptor(secrets.signingDescriptor) : void 0;
|
|
1400
|
+
if (!signer || !isDeterministicSigner(signer)) {
|
|
1401
|
+
throw new RefundNotLocallyPossibleError(
|
|
1402
|
+
"foreign-descriptor",
|
|
1403
|
+
`this wallet cannot derive ${secrets.signingDescriptor}; the swap was created on another wallet`
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
return signer;
|
|
1407
|
+
}
|
|
1408
|
+
async function senderIdentityForSwapRecord(wallet, record) {
|
|
1409
|
+
let secrets;
|
|
1410
|
+
try {
|
|
1411
|
+
secrets = rfqSecretsOfRecord(record);
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
throw new RefundNotLocallyPossibleError(
|
|
1414
|
+
"unreadable-secrets",
|
|
1415
|
+
error instanceof Error ? error.message : String(error),
|
|
1416
|
+
// the record's own diagnosis, kept for a caller that wants more
|
|
1417
|
+
// than the message
|
|
1418
|
+
{ cause: error }
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
if (!secrets) {
|
|
1422
|
+
throw new RefundNotLocallyPossibleError(
|
|
1423
|
+
"no-secrets",
|
|
1424
|
+
"this swap record carries no signing descriptor and no fallback secrets"
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
return senderIdentityForRfqSecrets(wallet, secrets);
|
|
1428
|
+
}
|
|
1429
|
+
async function senderPubkeyForRfqSecrets(wallet, secrets) {
|
|
1430
|
+
if (!secrets.derivable) return import_secp256k1.schnorr.getPublicKey(secrets.senderPrivateKey);
|
|
1431
|
+
return (await senderIdentityForRfqSecrets(wallet, secrets)).xOnlyPublicKey();
|
|
1432
|
+
}
|
|
1433
|
+
function isDeterministicSigner(value) {
|
|
1434
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1435
|
+
const v = value;
|
|
1436
|
+
return typeof v.signSchnorrDeterministic === "function" && typeof v.xOnlyPublicKey === "function";
|
|
1437
|
+
}
|
|
1438
|
+
async function derivePreimage(signer) {
|
|
1439
|
+
const xonly = await signer.xOnlyPublicKey();
|
|
1440
|
+
const message = buildPreimageMessage(xonly, PREIMAGE_INDEX);
|
|
1441
|
+
return (0, import_sha22.sha256)(await signer.signSchnorrDeterministic((0, import_sha22.sha256)(message)));
|
|
1442
|
+
}
|
|
1443
|
+
async function preimageForRfqSecrets(wallet, secrets) {
|
|
1444
|
+
if (!secrets.derivable) {
|
|
1445
|
+
if (!secrets.preimage) throw new Error("this swap carries no stored preimage");
|
|
1446
|
+
return secrets.preimage;
|
|
1447
|
+
}
|
|
1448
|
+
if (secrets.preimage) return secrets.preimage;
|
|
1449
|
+
const signer = await senderIdentityForRfqSecrets(wallet, secrets);
|
|
1450
|
+
if (!isDeterministicSigner(signer)) {
|
|
1451
|
+
throw new Error(
|
|
1452
|
+
`wallet cannot sign deterministically for ${secrets.signingDescriptor}; its preimage is not derivable`
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
try {
|
|
1456
|
+
return await derivePreimage(signer);
|
|
1457
|
+
} catch (cause) {
|
|
1458
|
+
throw new Error(
|
|
1459
|
+
`wallet cannot sign deterministically for ${secrets.signingDescriptor}; its preimage is not derivable`,
|
|
1460
|
+
{ cause }
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// src/claimPacket.ts
|
|
1466
|
+
var import_base6 = require("@scure/base");
|
|
1467
|
+
var import_secp256k12 = require("@noble/curves/secp256k1.js");
|
|
1468
|
+
var import_hkdf = require("@noble/hashes/hkdf.js");
|
|
1469
|
+
var import_sha23 = require("@noble/hashes/sha2.js");
|
|
1470
|
+
var HKDF_INFO = new TextEncoder().encode("covclaimd/preimage/v1");
|
|
1471
|
+
async function sealClaimPacket(input) {
|
|
1472
|
+
return sealWithEntropy(
|
|
1473
|
+
input,
|
|
1474
|
+
import_secp256k12.secp256k1.utils.randomSecretKey(),
|
|
1475
|
+
crypto.getRandomValues(new Uint8Array(12))
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
async function sealWithEntropy(input, ephemeralKey, nonce) {
|
|
1479
|
+
if (input.preimage.length !== 32) throw new Error("preimage must be 32 bytes");
|
|
1480
|
+
if (input.covclaimdPubkey.length !== 33) {
|
|
1481
|
+
throw new Error("covclaimd pubkey must be 33-byte compressed");
|
|
1482
|
+
}
|
|
1483
|
+
const ephemeralPub = import_secp256k12.secp256k1.getPublicKey(ephemeralKey, true);
|
|
1484
|
+
const sharedX = import_secp256k12.secp256k1.getSharedSecret(ephemeralKey, input.covclaimdPubkey, true).subarray(1);
|
|
1485
|
+
const key = (0, import_hkdf.hkdf)(import_sha23.sha256, sharedX, ephemeralPub, HKDF_INFO, 32);
|
|
1486
|
+
if (nonce.length !== 12) throw new Error("nonce must be 12 bytes");
|
|
1487
|
+
const aesKey = await crypto.subtle.importKey("raw", key, "AES-GCM", false, [
|
|
1488
|
+
"encrypt"
|
|
1489
|
+
]);
|
|
1490
|
+
const sealed = new Uint8Array(
|
|
1491
|
+
await crypto.subtle.encrypt(
|
|
1492
|
+
{
|
|
1493
|
+
name: "AES-GCM",
|
|
1494
|
+
iv: nonce,
|
|
1495
|
+
additionalData: ephemeralPub
|
|
1496
|
+
},
|
|
1497
|
+
aesKey,
|
|
1498
|
+
input.preimage
|
|
1499
|
+
)
|
|
1500
|
+
);
|
|
1501
|
+
const packet = new Uint8Array(33 + 12 + sealed.length);
|
|
1502
|
+
packet.set(ephemeralPub, 0);
|
|
1503
|
+
packet.set(nonce, 33);
|
|
1504
|
+
packet.set(sealed, 45);
|
|
1505
|
+
return { ciphertext: import_base6.base64.encode(packet) };
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// src/lockupContract.ts
|
|
1509
|
+
var import_base7 = require("@scure/base");
|
|
1510
|
+
var import_sdk7 = require("@arkade-os/sdk");
|
|
1511
|
+
var SWAP_LOCKUP_CONTRACT_TYPE = "vhtlc-v2";
|
|
1512
|
+
var SWAP_LOCKUP_CONTRACT_LABEL = "Arkade RFQ swap lockup";
|
|
1513
|
+
var SWAP_LOCKUP_CONTRACT_KIND = "rfq-swap-lockup";
|
|
1514
|
+
var LockupRegistrationFailed = class extends Error {
|
|
1515
|
+
/** The lockup address that was never registered — never fund it: nothing
|
|
1516
|
+
* is watching it. */
|
|
1517
|
+
address;
|
|
1518
|
+
/** The covenant the row would have been written from — the other half of
|
|
1519
|
+
* `registerLockupContract`, so the write is retryable without a quote. */
|
|
1520
|
+
script;
|
|
1521
|
+
constructor(script, address, cause) {
|
|
1522
|
+
super(`failed to register the lockup contract for ${address}`, { cause });
|
|
1523
|
+
this.name = "LockupRegistrationFailed";
|
|
1524
|
+
this.address = address;
|
|
1525
|
+
this.script = script;
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1528
|
+
async function registerLockupContract(contracts, script, address) {
|
|
1529
|
+
try {
|
|
1530
|
+
await contracts.createContract({
|
|
1531
|
+
type: SWAP_LOCKUP_CONTRACT_TYPE,
|
|
1532
|
+
params: import_sdk7.VHTLCV2ContractHandler.serializeParams(script.options),
|
|
1533
|
+
script: import_base7.hex.encode(script.pkScript),
|
|
1534
|
+
address,
|
|
1535
|
+
label: SWAP_LOCKUP_CONTRACT_LABEL,
|
|
1536
|
+
metadata: { genericallySpendable: false, kind: SWAP_LOCKUP_CONTRACT_KIND }
|
|
1537
|
+
});
|
|
1538
|
+
} catch (error) {
|
|
1539
|
+
throw new LockupRegistrationFailed(script, address, error);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// src/rfq.ts
|
|
1544
|
+
var xOnly2 = (key, label) => {
|
|
1545
|
+
if (key.length === 32) return key;
|
|
1546
|
+
if (key.length !== 33 || key[0] !== 2 && key[0] !== 3) {
|
|
1547
|
+
throw new Error(`${label} is not a compressed or x-only public key`);
|
|
1548
|
+
}
|
|
1549
|
+
return key.slice(1);
|
|
1550
|
+
};
|
|
1551
|
+
var solverHex = (value, field) => {
|
|
1552
|
+
try {
|
|
1553
|
+
return import_base8.hex.decode(value);
|
|
1554
|
+
} catch {
|
|
1555
|
+
throw new Error(`solver sent malformed hex for ${field}`);
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
var ARKADE_BTC = "arkade:BTC";
|
|
1559
|
+
var ARKADE_ASSET = "arkade:ASSET";
|
|
1560
|
+
var LIGHTNING_BTC = "lightning:BTC";
|
|
1561
|
+
var ONCHAIN_BTC = "onchain:BTC";
|
|
1562
|
+
var rfqPair = (from, to) => `${from}->${to}`;
|
|
1563
|
+
var LIGHTNING_SEND_PAIR = rfqPair(ARKADE_BTC, LIGHTNING_BTC);
|
|
1564
|
+
var LIGHTNING_RECEIVE_PAIR = rfqPair(LIGHTNING_BTC, ARKADE_BTC);
|
|
1565
|
+
var ONCHAIN_SEND_PAIR = rfqPair(ARKADE_BTC, ONCHAIN_BTC);
|
|
1566
|
+
var ONCHAIN_RECEIVE_PAIR = rfqPair(ONCHAIN_BTC, ARKADE_BTC);
|
|
1567
|
+
var RFQ_TERMINAL_STATES = ["settled", "refused", "expired", "refunded", "stuck"];
|
|
1568
|
+
var SwapRefusal = class extends Error {
|
|
1569
|
+
reason;
|
|
1570
|
+
rfqId;
|
|
1571
|
+
constructor(reason, rfqId) {
|
|
1572
|
+
super(`solver refused: ${reason}`);
|
|
1573
|
+
this.name = "SwapRefusal";
|
|
1574
|
+
this.reason = reason;
|
|
1575
|
+
this.rfqId = rfqId;
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
var AddressMismatch = class extends Error {
|
|
1579
|
+
derived;
|
|
1580
|
+
quoted;
|
|
1581
|
+
constructor(derived, quoted) {
|
|
1582
|
+
super("solver lockup address does not match local derivation \u2014 refusing to fund");
|
|
1583
|
+
this.name = "AddressMismatch";
|
|
1584
|
+
this.derived = derived;
|
|
1585
|
+
this.quoted = quoted;
|
|
1586
|
+
}
|
|
1587
|
+
};
|
|
1588
|
+
var newRfqId = () => import_base8.hex.encode(crypto.getRandomValues(new Uint8Array(32)));
|
|
1589
|
+
var lightningSendRequest = (input) => ({
|
|
1590
|
+
v: 1,
|
|
1591
|
+
type: "rfq_request",
|
|
1592
|
+
rfq_id: input.rfqId,
|
|
1593
|
+
pair: LIGHTNING_SEND_PAIR,
|
|
1594
|
+
amount_side: "to",
|
|
1595
|
+
profile: {
|
|
1596
|
+
invoice: input.invoice,
|
|
1597
|
+
refund_address: input.refundAddress,
|
|
1598
|
+
client_refund_pubkey: import_base8.hex.encode(input.senderPubkey)
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1601
|
+
var arkadeSwapRequest = (input) => {
|
|
1602
|
+
if (Boolean(input.wantAsset) === Boolean(input.offerAsset)) {
|
|
1603
|
+
throw new Error("set exactly one of wantAsset (BTC->asset) or offerAsset (asset->BTC)");
|
|
1604
|
+
}
|
|
1605
|
+
return {
|
|
1606
|
+
v: 1,
|
|
1607
|
+
type: "rfq_request",
|
|
1608
|
+
rfq_id: input.rfqId,
|
|
1609
|
+
pair: rfqPair(
|
|
1610
|
+
input.offerAsset ? ARKADE_ASSET : ARKADE_BTC,
|
|
1611
|
+
input.wantAsset ? ARKADE_ASSET : ARKADE_BTC
|
|
1612
|
+
),
|
|
1613
|
+
amount_side: input.amountSide,
|
|
1614
|
+
amount: input.amount,
|
|
1615
|
+
profile: {
|
|
1616
|
+
...input.offerAsset && { offer_asset: import_base8.hex.encode(input.offerAsset.serialize()) },
|
|
1617
|
+
...input.wantAsset && { want_asset: import_base8.hex.encode(input.wantAsset.serialize()) }
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
};
|
|
1621
|
+
var MIN_HEADROOM_SECONDS = 90 * 60;
|
|
1622
|
+
var gateError = (reason, message) => {
|
|
1623
|
+
const error = new Error(message);
|
|
1624
|
+
error.reason = reason;
|
|
1625
|
+
return error;
|
|
1626
|
+
};
|
|
1627
|
+
var assertFinite = (value, reason, label) => {
|
|
1628
|
+
if (value !== void 0 && !Number.isFinite(value)) {
|
|
1629
|
+
throw gateError(reason, `${label} is not a finite number (${String(value)})`);
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
var verifyLockupAddress = (quote, derivedAddress) => {
|
|
1633
|
+
const quoted = quote.profile?.lockup_address;
|
|
1634
|
+
if (derivedAddress !== quoted) throw new AddressMismatch(derivedAddress, quoted);
|
|
1635
|
+
return derivedAddress;
|
|
1636
|
+
};
|
|
1637
|
+
var assertFundable = (input) => {
|
|
1638
|
+
const fail = (reason, message) => {
|
|
1639
|
+
throw gateError(reason, message);
|
|
1640
|
+
};
|
|
1641
|
+
if (input.invoiceExpiresAt !== void 0 && input.now >= input.invoiceExpiresAt) {
|
|
1642
|
+
fail("invoice_expired", "invoice expired");
|
|
1643
|
+
}
|
|
1644
|
+
if (input.now >= input.quote.valid_until)
|
|
1645
|
+
fail("quote_expired", "quote expired \u2014 request a fresh one");
|
|
1646
|
+
if (input.quote.refund_locktime !== void 0 && input.quote.refund_locktime - input.now < MIN_HEADROOM_SECONDS) {
|
|
1647
|
+
fail("insufficient_headroom", "refund deadline headroom below 90 minutes");
|
|
1648
|
+
}
|
|
1649
|
+
if (input.onchain) {
|
|
1650
|
+
const { htlcLocktime, minConfirmations, direction } = input.onchain;
|
|
1651
|
+
if (!Number.isInteger(minConfirmations) || minConfirmations < 1 || minConfirmations > MAX_MIN_CONFIRMATIONS) {
|
|
1652
|
+
fail(
|
|
1653
|
+
"confirmations_out_of_range",
|
|
1654
|
+
`min_confirmations must be 1..${MAX_MIN_CONFIRMATIONS}, got ${minConfirmations}`
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
const needed = minConfirmations * ONCHAIN_SECONDS_PER_BLOCK + ONCHAIN_CLAIM_MARGIN_SECONDS;
|
|
1658
|
+
if (htlcLocktime - input.now <= needed) {
|
|
1659
|
+
fail("claim_window_too_short", "L1 HTLC locktime leaves no safe claim window");
|
|
1660
|
+
}
|
|
1661
|
+
if (direction === "send") {
|
|
1662
|
+
if (input.quote.refund_locktime === void 0 || htlcLocktime + ONCHAIN_ORDER_MARGIN_SECONDS > input.quote.refund_locktime) {
|
|
1663
|
+
fail(
|
|
1664
|
+
"timelock_order",
|
|
1665
|
+
"L1 HTLC locktime + margin must fall before the Arkade refund locktime"
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
};
|
|
1671
|
+
var expectQuote = (payload, rfqId) => {
|
|
1672
|
+
const p = payload;
|
|
1673
|
+
if (p?.type === "rfq_refusal") throw new SwapRefusal(p.reason ?? "unknown", p.rfq_id ?? rfqId);
|
|
1674
|
+
if (p?.type !== "rfq_quote" || p.rfq_id !== rfqId) {
|
|
1675
|
+
throw new Error(`unexpected reply: ${p?.type ?? "no payload"}`);
|
|
1676
|
+
}
|
|
1677
|
+
return payload;
|
|
1678
|
+
};
|
|
1679
|
+
var httpTransport = (baseUrl, options = {}) => {
|
|
1680
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1681
|
+
const readJson = async (response, what) => {
|
|
1682
|
+
const body = await response.text();
|
|
1683
|
+
try {
|
|
1684
|
+
return JSON.parse(body);
|
|
1685
|
+
} catch {
|
|
1686
|
+
throw new Error(
|
|
1687
|
+
`${what} returned HTTP ${response.status} with a non-JSON body: ${body.slice(0, 200)}`
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
return {
|
|
1692
|
+
async requestQuote(payload) {
|
|
1693
|
+
const response = await fetchImpl(`${baseUrl}/v1/swap`, {
|
|
1694
|
+
method: "POST",
|
|
1695
|
+
headers: { "content-type": "application/json" },
|
|
1696
|
+
body: JSON.stringify(payload)
|
|
1697
|
+
});
|
|
1698
|
+
return expectQuote(await readJson(response, "quote request"), String(payload.rfq_id));
|
|
1699
|
+
},
|
|
1700
|
+
async status(rfqId) {
|
|
1701
|
+
const response = await fetchImpl(`${baseUrl}/v1/rfq/${rfqId}`, { method: "GET" });
|
|
1702
|
+
if (response.status === 404) return null;
|
|
1703
|
+
const payload = await readJson(response, "status request");
|
|
1704
|
+
return payload?.type === "rfq_status" ? payload : null;
|
|
1705
|
+
},
|
|
1706
|
+
async close() {
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
};
|
|
1710
|
+
var relayTransport = (relayUrl, options) => {
|
|
1711
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
1712
|
+
const Ctor = options.WebSocketCtor ?? WebSocket;
|
|
1713
|
+
const pending = /* @__PURE__ */ new Map();
|
|
1714
|
+
let sequence = 0;
|
|
1715
|
+
const socketReady = new Promise((resolve, reject) => {
|
|
1716
|
+
const ws = new Ctor(relayUrl);
|
|
1717
|
+
ws.addEventListener("open", () => {
|
|
1718
|
+
ws.send(
|
|
1719
|
+
JSON.stringify({
|
|
1720
|
+
op: "sub",
|
|
1721
|
+
id: "s1",
|
|
1722
|
+
filter: { recipient: options.clientPubkey }
|
|
1723
|
+
})
|
|
1724
|
+
);
|
|
1725
|
+
resolve(ws);
|
|
1726
|
+
});
|
|
1727
|
+
ws.addEventListener("error", () => reject(new Error("relay connection failed")));
|
|
1728
|
+
ws.addEventListener("message", (event) => {
|
|
1729
|
+
let frame;
|
|
1730
|
+
try {
|
|
1731
|
+
frame = JSON.parse(String(event.data));
|
|
1732
|
+
} catch {
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
if (frame.op !== "event") return;
|
|
1736
|
+
const payload = frame.event?.payload;
|
|
1737
|
+
const rfqId = payload?.rfq_id;
|
|
1738
|
+
const settle = rfqId !== void 0 ? pending.get(rfqId) : void 0;
|
|
1739
|
+
if (settle && rfqId !== void 0) {
|
|
1740
|
+
pending.delete(rfqId);
|
|
1741
|
+
settle(payload);
|
|
1742
|
+
}
|
|
1743
|
+
});
|
|
1744
|
+
});
|
|
1745
|
+
const roundTrip = async (payload, rfqId) => {
|
|
1746
|
+
const ws = await socketReady;
|
|
1747
|
+
const reply = new Promise((resolve, reject) => {
|
|
1748
|
+
const timer = setTimeout(() => {
|
|
1749
|
+
pending.delete(rfqId);
|
|
1750
|
+
reject(new Error(`no reply within ${timeoutMs}ms`));
|
|
1751
|
+
}, timeoutMs);
|
|
1752
|
+
pending.set(rfqId, (p) => {
|
|
1753
|
+
clearTimeout(timer);
|
|
1754
|
+
resolve(p);
|
|
1755
|
+
});
|
|
1756
|
+
});
|
|
1757
|
+
ws.send(
|
|
1758
|
+
JSON.stringify({
|
|
1759
|
+
op: "event",
|
|
1760
|
+
event: {
|
|
1761
|
+
id: `${options.clientPubkey}:${sequence += 1}`,
|
|
1762
|
+
author: options.clientPubkey,
|
|
1763
|
+
recipient: options.solverPubkey,
|
|
1764
|
+
createdAtMs: Date.now(),
|
|
1765
|
+
payload
|
|
1766
|
+
}
|
|
1767
|
+
})
|
|
1768
|
+
);
|
|
1769
|
+
return reply;
|
|
1770
|
+
};
|
|
1771
|
+
return {
|
|
1772
|
+
async requestQuote(payload) {
|
|
1773
|
+
return expectQuote(
|
|
1774
|
+
await roundTrip(payload, String(payload.rfq_id)),
|
|
1775
|
+
String(payload.rfq_id)
|
|
1776
|
+
);
|
|
1777
|
+
},
|
|
1778
|
+
async status(rfqId) {
|
|
1779
|
+
const payload = await roundTrip(
|
|
1780
|
+
{ v: 1, type: "rfq_status_request", rfq_id: rfqId },
|
|
1781
|
+
rfqId
|
|
1782
|
+
);
|
|
1783
|
+
return payload?.type === "rfq_status" ? payload : null;
|
|
1784
|
+
},
|
|
1785
|
+
async close() {
|
|
1786
|
+
try {
|
|
1787
|
+
(await socketReady).close();
|
|
1788
|
+
} catch {
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
};
|
|
1792
|
+
};
|
|
1793
|
+
var SEQUENCE_GRANULARITY_SECONDS = 512;
|
|
1794
|
+
var unilateralClaimDelay = (serverExitDelaySeconds) => {
|
|
1795
|
+
if (!Number.isFinite(serverExitDelaySeconds) || serverExitDelaySeconds < SEQUENCE_GRANULARITY_SECONDS) {
|
|
1796
|
+
throw new Error(
|
|
1797
|
+
`server exit delay must be at least ${SEQUENCE_GRANULARITY_SECONDS}s of seconds, got ${serverExitDelaySeconds}`
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
if (serverExitDelaySeconds > (65535 - 2) * SEQUENCE_GRANULARITY_SECONDS) {
|
|
1801
|
+
throw new Error(
|
|
1802
|
+
`server exit delay ${serverExitDelaySeconds}s exceeds what BIP68 can encode once the two refund tiers are stacked above it`
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
return Math.ceil(serverExitDelaySeconds / SEQUENCE_GRANULARITY_SECONDS) * SEQUENCE_GRANULARITY_SECONDS;
|
|
1806
|
+
};
|
|
1807
|
+
var unilateralRefundDelay = (claimDelay) => claimDelay + SEQUENCE_GRANULARITY_SECONDS;
|
|
1808
|
+
var unilateralRefundWithoutReceiverDelay = (claimDelay) => claimDelay + 2 * SEQUENCE_GRANULARITY_SECONDS;
|
|
1809
|
+
function lightningSendVtxoScript(params) {
|
|
1810
|
+
const seconds = (value) => ({
|
|
1811
|
+
type: "seconds",
|
|
1812
|
+
value: BigInt(value)
|
|
1813
|
+
});
|
|
1814
|
+
return new import_sdk8.VHTLC.ScriptV2({
|
|
1815
|
+
sender: params.senderPubkey,
|
|
1816
|
+
receiver: params.solverPubkey,
|
|
1817
|
+
server: params.serverPubkey,
|
|
1818
|
+
preimageHash: (0, import_legacy2.ripemd160)(import_base8.hex.decode(params.paymentHash)),
|
|
1819
|
+
refundLocktime: BigInt(params.refundLocktime),
|
|
1820
|
+
unilateralClaimDelay: seconds(params.claimDelay),
|
|
1821
|
+
unilateralRefundDelay: seconds(unilateralRefundDelay(params.claimDelay)),
|
|
1822
|
+
unilateralRefundWithoutReceiverDelay: seconds(
|
|
1823
|
+
unilateralRefundWithoutReceiverDelay(params.claimDelay)
|
|
1824
|
+
),
|
|
1825
|
+
nonInteractiveClaim: {
|
|
1826
|
+
receiverPkScript: params.receiverPkScript,
|
|
1827
|
+
emulatorPubkey: params.emulatorPubkey
|
|
1828
|
+
},
|
|
1829
|
+
nonInteractiveRefund: {
|
|
1830
|
+
senderPkScript: params.refundPkScript,
|
|
1831
|
+
emulatorPubkey: params.emulatorPubkey
|
|
1832
|
+
}
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
async function requestLightningSend(wallet, arkServerUrl, emulatorPubkey, transport, params) {
|
|
1836
|
+
const rfqId = params.rfqId ?? newRfqId();
|
|
1837
|
+
const secrets = await deriveSwapSecrets(wallet) ?? randomSwapSecrets();
|
|
1838
|
+
if (!secrets.derivable) {
|
|
1839
|
+
console.warn(
|
|
1840
|
+
"[swap] wallet cannot allocate an HD descriptor: the sender key is random and MUST be persisted before funding"
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
const senderPubkey = await senderPubkeyForRfqSecrets(wallet, secrets);
|
|
1844
|
+
const [info, refundAddress] = await Promise.all([
|
|
1845
|
+
new import_sdk8.RestArkProvider(arkServerUrl).getInfo(),
|
|
1846
|
+
wallet.getAddress()
|
|
1847
|
+
]);
|
|
1848
|
+
const quote = await transport.requestQuote(
|
|
1849
|
+
lightningSendRequest({ rfqId, invoice: params.invoice.raw, refundAddress, senderPubkey })
|
|
1850
|
+
);
|
|
1851
|
+
if (quote.refund_locktime === void 0) {
|
|
1852
|
+
throw new Error("lightning-send quote is missing refund_locktime");
|
|
1853
|
+
}
|
|
1854
|
+
const receiverPkScriptHex = quote.profile?.receiver_pk_script;
|
|
1855
|
+
if (receiverPkScriptHex === void 0) {
|
|
1856
|
+
throw new Error("lightning-send quote is missing profile.receiver_pk_script");
|
|
1857
|
+
}
|
|
1858
|
+
if (quote.to_amount !== params.invoice.amountSats) {
|
|
1859
|
+
throw new Error(
|
|
1860
|
+
`quote to_amount ${quote.to_amount} does not match the invoice's ${params.invoice.amountSats}`
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
if (quote.from_amount < quote.to_amount) {
|
|
1864
|
+
throw new Error(
|
|
1865
|
+
`quote from_amount ${quote.from_amount} is below the invoice amount \u2014 a negative spread is not a quote`
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
const serverPubkey = xOnly2(import_base8.hex.decode(info.signerPubkey), "ark signer key");
|
|
1869
|
+
const script = lightningSendVtxoScript({
|
|
1870
|
+
solverPubkey: xOnly2(import_base8.hex.decode(quote.solver_pubkey), "solver key"),
|
|
1871
|
+
refundLocktime: quote.refund_locktime,
|
|
1872
|
+
serverPubkey,
|
|
1873
|
+
paymentHash: params.invoice.paymentHash,
|
|
1874
|
+
claimDelay: unilateralClaimDelay(Number(info.unilateralExitDelay)),
|
|
1875
|
+
emulatorPubkey: xOnly2(emulatorPubkey, "emulator pubkey"),
|
|
1876
|
+
senderPubkey,
|
|
1877
|
+
receiverPkScript: solverHex(receiverPkScriptHex, "profile.receiver_pk_script"),
|
|
1878
|
+
refundPkScript: import_sdk8.ArkAddress.decode(refundAddress).pkScript
|
|
1879
|
+
});
|
|
1880
|
+
const address = script.address((0, import_sdk8.getNetwork)(info.network).hrp, serverPubkey).encode();
|
|
1881
|
+
verifyLockupAddress(quote, address);
|
|
1882
|
+
assertFundable({
|
|
1883
|
+
quote,
|
|
1884
|
+
invoiceExpiresAt: params.invoice.expiresAt,
|
|
1885
|
+
now: Math.floor(Date.now() / 1e3)
|
|
1886
|
+
});
|
|
1887
|
+
await registerLockupContract(await wallet.getContractManager(), script, address);
|
|
1888
|
+
return {
|
|
1889
|
+
rfqId,
|
|
1890
|
+
quote,
|
|
1891
|
+
address,
|
|
1892
|
+
// What the lockup must carry: the quote's `from_amount` — the invoice
|
|
1893
|
+
// PLUS the corridor's fee, never the bare invoice amount.
|
|
1894
|
+
fundAmount: quote.from_amount,
|
|
1895
|
+
swapPkScript: script.pkScript,
|
|
1896
|
+
script,
|
|
1897
|
+
refundAddress,
|
|
1898
|
+
senderPubkey,
|
|
1899
|
+
secrets
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
var offerTermsFromQuote = (quote, assets) => {
|
|
1903
|
+
if (Boolean(assets.wantAsset) === Boolean(assets.offerAsset)) {
|
|
1904
|
+
throw new Error("set exactly one of wantAsset or offerAsset");
|
|
1905
|
+
}
|
|
1906
|
+
return { wantAmount: BigInt(quote.to_amount), ...assets };
|
|
1907
|
+
};
|
|
1908
|
+
var l1NetworkFromArk = (network) => network === "bitcoin" ? "bitcoin" : network === "regtest" ? "regtest" : "testnet";
|
|
1909
|
+
var onchainSendRequest = (input) => ({
|
|
1910
|
+
v: 1,
|
|
1911
|
+
type: "rfq_request",
|
|
1912
|
+
rfq_id: input.rfqId,
|
|
1913
|
+
pair: ONCHAIN_SEND_PAIR,
|
|
1914
|
+
amount_side: input.amountSide,
|
|
1915
|
+
amount: input.amount,
|
|
1916
|
+
profile: {
|
|
1917
|
+
payment_hash: input.paymentHash,
|
|
1918
|
+
payout_pubkey: import_base8.hex.encode(input.payoutPubkey),
|
|
1919
|
+
refund_address: input.refundAddress,
|
|
1920
|
+
client_refund_pubkey: import_base8.hex.encode(input.senderPubkey)
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
var lightningReceiveRequest = (input) => ({
|
|
1924
|
+
v: 1,
|
|
1925
|
+
type: "rfq_request",
|
|
1926
|
+
rfq_id: input.rfqId,
|
|
1927
|
+
pair: LIGHTNING_RECEIVE_PAIR,
|
|
1928
|
+
amount_side: input.amountSide,
|
|
1929
|
+
amount: input.amount,
|
|
1930
|
+
profile: {
|
|
1931
|
+
payment_hash: input.paymentHash,
|
|
1932
|
+
payout_address: input.payoutAddress,
|
|
1933
|
+
payout_pubkey: import_base8.hex.encode(input.payoutPubkey),
|
|
1934
|
+
claim_packet: input.claimPacket
|
|
1935
|
+
}
|
|
1936
|
+
});
|
|
1937
|
+
var onchainReceiveRequest = (input) => ({
|
|
1938
|
+
v: 1,
|
|
1939
|
+
type: "rfq_request",
|
|
1940
|
+
rfq_id: input.rfqId,
|
|
1941
|
+
pair: ONCHAIN_RECEIVE_PAIR,
|
|
1942
|
+
amount_side: input.amountSide,
|
|
1943
|
+
amount: input.amount,
|
|
1944
|
+
profile: {
|
|
1945
|
+
payment_hash: input.paymentHash,
|
|
1946
|
+
claim_packet: input.claimPacket,
|
|
1947
|
+
refund_pubkey: import_base8.hex.encode(input.refundPubkey),
|
|
1948
|
+
payout_address: input.payoutAddress,
|
|
1949
|
+
payout_pubkey: import_base8.hex.encode(input.payoutPubkey)
|
|
1950
|
+
}
|
|
1951
|
+
});
|
|
1952
|
+
function deriveOnchainSend(input) {
|
|
1953
|
+
const { quote } = input;
|
|
1954
|
+
const profile = quote.profile ?? {};
|
|
1955
|
+
const refundLocktime = quote.refund_locktime ?? profile.refund_locktime;
|
|
1956
|
+
const htlcPubkey = profile.htlc_pubkey;
|
|
1957
|
+
const htlcLocktime = profile.htlc_locktime;
|
|
1958
|
+
const htlcAddress = profile.htlc_address;
|
|
1959
|
+
const minConfirmations = profile.min_confirmations;
|
|
1960
|
+
const receiverPkScriptHex = profile.receiver_pk_script;
|
|
1961
|
+
if (refundLocktime === void 0 || htlcPubkey === void 0 || htlcLocktime === void 0 || minConfirmations === void 0 || receiverPkScriptHex === void 0) {
|
|
1962
|
+
throw new Error("onchain-send quote is missing a binding field");
|
|
1963
|
+
}
|
|
1964
|
+
const script = lightningSendVtxoScript({
|
|
1965
|
+
solverPubkey: xOnly2(import_base8.hex.decode(quote.solver_pubkey), "solver key"),
|
|
1966
|
+
refundLocktime,
|
|
1967
|
+
serverPubkey: input.serverPubkey,
|
|
1968
|
+
paymentHash: input.paymentHash,
|
|
1969
|
+
claimDelay: input.claimDelay,
|
|
1970
|
+
emulatorPubkey: input.emulatorPubkey,
|
|
1971
|
+
senderPubkey: input.senderPubkey,
|
|
1972
|
+
receiverPkScript: solverHex(receiverPkScriptHex, "profile.receiver_pk_script"),
|
|
1973
|
+
refundPkScript: import_sdk8.ArkAddress.decode(input.refundAddress).pkScript
|
|
1974
|
+
});
|
|
1975
|
+
const address = script.address(input.hrp, input.serverPubkey).encode();
|
|
1976
|
+
verifyLockupAddress(quote, address);
|
|
1977
|
+
const htlc = onchainHtlcScript(
|
|
1978
|
+
{
|
|
1979
|
+
paymentHash: input.paymentHash,
|
|
1980
|
+
claimKey: input.payoutPubkey,
|
|
1981
|
+
refundKey: xOnly2(import_base8.hex.decode(htlcPubkey), "solver L1 htlc key"),
|
|
1982
|
+
refundLocktime: htlcLocktime
|
|
1983
|
+
},
|
|
1984
|
+
input.l1Network
|
|
1985
|
+
);
|
|
1986
|
+
if (htlc.address !== htlcAddress) throw new AddressMismatch(htlc.address, htlcAddress);
|
|
1987
|
+
return {
|
|
1988
|
+
address,
|
|
1989
|
+
swapPkScript: script.pkScript,
|
|
1990
|
+
script,
|
|
1991
|
+
htlc,
|
|
1992
|
+
refundLocktime,
|
|
1993
|
+
htlcLocktime,
|
|
1994
|
+
minConfirmations
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
async function requestOnchainSend(wallet, arkServerUrl, emulatorPubkey, transport, params) {
|
|
1998
|
+
const rfqId = params.rfqId ?? newRfqId();
|
|
1999
|
+
if (params.preimage && params.preimage.length !== 32) {
|
|
2000
|
+
throw new Error(`preimage must be 32 bytes, got ${params.preimage.length}`);
|
|
2001
|
+
}
|
|
2002
|
+
const derivedSecrets = await deriveSwapSecrets(wallet);
|
|
2003
|
+
const secrets = derivedSecrets ? params.preimage ? { ...derivedSecrets, preimage: params.preimage } : derivedSecrets : randomSwapSecrets({ preimage: params.preimage ?? true });
|
|
2004
|
+
if (!secrets.derivable) {
|
|
2005
|
+
console.warn(
|
|
2006
|
+
params.preimage ? "[swap] this swap's sender key is random; the supplied preimage and sender key MUST be persisted before funding" : "[swap] this swap's preimage and sender key are random and MUST be persisted before funding"
|
|
2007
|
+
);
|
|
2008
|
+
} else if (params.preimage) {
|
|
2009
|
+
console.warn(
|
|
2010
|
+
"[swap] this swap's preimage was supplied by the caller and MUST be persisted with the signing descriptor before funding"
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
const preimage = await preimageForRfqSecrets(wallet, secrets);
|
|
2014
|
+
const paymentHash = paymentHashOf(preimage);
|
|
2015
|
+
const senderPubkey = await senderPubkeyForRfqSecrets(wallet, secrets);
|
|
2016
|
+
const [info, refundAddress] = await Promise.all([
|
|
2017
|
+
new import_sdk8.RestArkProvider(arkServerUrl).getInfo(),
|
|
2018
|
+
wallet.getAddress()
|
|
2019
|
+
]);
|
|
2020
|
+
const quote = await transport.requestQuote(
|
|
2021
|
+
onchainSendRequest({
|
|
2022
|
+
rfqId,
|
|
2023
|
+
paymentHash,
|
|
2024
|
+
payoutPubkey: params.payoutPubkey,
|
|
2025
|
+
refundAddress,
|
|
2026
|
+
senderPubkey,
|
|
2027
|
+
amount: params.amount,
|
|
2028
|
+
amountSide: params.amountSide
|
|
2029
|
+
})
|
|
2030
|
+
);
|
|
2031
|
+
const derived = deriveOnchainSend({
|
|
2032
|
+
quote,
|
|
2033
|
+
paymentHash,
|
|
2034
|
+
payoutPubkey: params.payoutPubkey,
|
|
2035
|
+
serverPubkey: xOnly2(import_base8.hex.decode(info.signerPubkey), "ark signer key"),
|
|
2036
|
+
emulatorPubkey: xOnly2(emulatorPubkey, "emulator pubkey"),
|
|
2037
|
+
claimDelay: unilateralClaimDelay(Number(info.unilateralExitDelay)),
|
|
2038
|
+
hrp: (0, import_sdk8.getNetwork)(info.network).hrp,
|
|
2039
|
+
l1Network: l1NetworkFromArk(info.network),
|
|
2040
|
+
refundAddress,
|
|
2041
|
+
senderPubkey
|
|
2042
|
+
});
|
|
2043
|
+
assertFundable({
|
|
2044
|
+
quote,
|
|
2045
|
+
now: Math.floor(Date.now() / 1e3),
|
|
2046
|
+
onchain: {
|
|
2047
|
+
htlcLocktime: derived.htlcLocktime,
|
|
2048
|
+
minConfirmations: derived.minConfirmations,
|
|
2049
|
+
direction: "send"
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
await registerLockupContract(
|
|
2053
|
+
await wallet.getContractManager(),
|
|
2054
|
+
derived.script,
|
|
2055
|
+
derived.address
|
|
2056
|
+
);
|
|
2057
|
+
return {
|
|
2058
|
+
rfqId,
|
|
2059
|
+
quote,
|
|
2060
|
+
address: derived.address,
|
|
2061
|
+
fundAmount: quote.from_amount,
|
|
2062
|
+
swapPkScript: derived.swapPkScript,
|
|
2063
|
+
script: derived.script,
|
|
2064
|
+
refundAddress,
|
|
2065
|
+
htlc: derived.htlc,
|
|
2066
|
+
senderPubkey,
|
|
2067
|
+
secrets
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
var assertQuotedAmount = (quote, amountSide, amount) => {
|
|
2071
|
+
const quoted = amountSide === "from" ? quote.from_amount : quote.to_amount;
|
|
2072
|
+
if (quoted !== amount) {
|
|
2073
|
+
throw new Error(
|
|
2074
|
+
`quote ${amountSide === "from" ? "from_amount" : "to_amount"} ${quoted} does not match the requested ${amount} \u2014 not this trade's quote`
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
if (quote.to_amount > quote.from_amount) {
|
|
2078
|
+
throw new Error("quote pays out more than it takes in \u2014 not a quote to fund");
|
|
2079
|
+
}
|
|
2080
|
+
};
|
|
2081
|
+
var MIN_CLAIM_WINDOW_SECONDS = 30 * 60;
|
|
2082
|
+
var verifyReceiveInvoice = (input) => {
|
|
2083
|
+
let decoded;
|
|
2084
|
+
try {
|
|
2085
|
+
decoded = input.decode(input.invoice);
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
throw gateError(
|
|
2088
|
+
"invoice_undecodable",
|
|
2089
|
+
`solver sent an undecodable invoice: ${error instanceof Error ? error.message : String(error)}`
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
assertFinite(decoded.expiresAt, "invoice_undecodable", "the decoded invoice expiry");
|
|
2093
|
+
assertFinite(input.quote.valid_until, "quote_malformed", "quote valid_until");
|
|
2094
|
+
if (decoded.paymentHash !== input.paymentHash) {
|
|
2095
|
+
throw gateError(
|
|
2096
|
+
"invoice_hash_mismatch",
|
|
2097
|
+
`solver's invoice pays ${decoded.paymentHash}, not this swap's ${input.paymentHash}`
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
if (decoded.amountSats <= 0) {
|
|
2101
|
+
throw gateError("invoice_amount_mismatch", "solver's invoice names no amount");
|
|
2102
|
+
}
|
|
2103
|
+
if (decoded.amountSats !== input.quote.from_amount) {
|
|
2104
|
+
throw gateError(
|
|
2105
|
+
"invoice_amount_mismatch",
|
|
2106
|
+
`solver's invoice asks for ${decoded.amountSats}, not the quoted from_amount ${input.quote.from_amount}`
|
|
2107
|
+
);
|
|
2108
|
+
}
|
|
2109
|
+
return { payDeadline: Math.min(decoded.expiresAt, input.quote.valid_until) };
|
|
2110
|
+
};
|
|
2111
|
+
var assertReceivable = (input) => {
|
|
2112
|
+
assertFinite(input.payDeadline, "quote_malformed", "payDeadline");
|
|
2113
|
+
assertFinite(input.now, "invalid_gate_input", "now");
|
|
2114
|
+
assertFinite(input.minClaimWindowSeconds, "invalid_gate_input", "minClaimWindowSeconds");
|
|
2115
|
+
assertFinite(input.maxPayAmount, "invalid_gate_input", "maxPayAmount");
|
|
2116
|
+
const minClaimWindow = input.minClaimWindowSeconds ?? MIN_CLAIM_WINDOW_SECONDS;
|
|
2117
|
+
if (input.now >= input.payDeadline) {
|
|
2118
|
+
throw gateError("quote_expired", "quote or invoice already expired \u2014 request a fresh one");
|
|
2119
|
+
}
|
|
2120
|
+
if (input.quote.refund_locktime === void 0) {
|
|
2121
|
+
throw gateError("missing_refund_locktime", "receive quote carries no refund_locktime");
|
|
2122
|
+
}
|
|
2123
|
+
assertFinite(input.quote.refund_locktime, "quote_malformed", "quote refund_locktime");
|
|
2124
|
+
if (input.quote.refund_locktime - input.payDeadline < minClaimWindow) {
|
|
2125
|
+
throw gateError(
|
|
2126
|
+
"claim_window_too_short",
|
|
2127
|
+
`a payment at the deadline would leave under ${minClaimWindow}s to claim before the solver's refund opens`
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
if (input.maxPayAmount !== void 0 && input.quote.from_amount > input.maxPayAmount) {
|
|
2131
|
+
throw gateError(
|
|
2132
|
+
"price_too_high",
|
|
2133
|
+
`quote asks ${input.quote.from_amount} sats, above the ${input.maxPayAmount} ceiling`
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
};
|
|
2137
|
+
function receiveVtxoScript(params) {
|
|
2138
|
+
const seconds = (value) => ({
|
|
2139
|
+
type: "seconds",
|
|
2140
|
+
value: BigInt(value)
|
|
2141
|
+
});
|
|
2142
|
+
return new import_sdk8.VHTLC.ScriptV2({
|
|
2143
|
+
sender: params.solverPubkey,
|
|
2144
|
+
receiver: params.payoutPubkey,
|
|
2145
|
+
server: params.serverPubkey,
|
|
2146
|
+
preimageHash: (0, import_legacy2.ripemd160)(import_base8.hex.decode(params.paymentHash)),
|
|
2147
|
+
refundLocktime: BigInt(params.refundLocktime),
|
|
2148
|
+
unilateralClaimDelay: seconds(params.claimDelay),
|
|
2149
|
+
unilateralRefundDelay: seconds(unilateralRefundDelay(params.claimDelay)),
|
|
2150
|
+
unilateralRefundWithoutReceiverDelay: seconds(
|
|
2151
|
+
unilateralRefundWithoutReceiverDelay(params.claimDelay)
|
|
2152
|
+
),
|
|
2153
|
+
nonInteractiveClaim: {
|
|
2154
|
+
receiverPkScript: params.payoutPkScript,
|
|
2155
|
+
emulatorPubkey: params.emulatorPubkey
|
|
2156
|
+
},
|
|
2157
|
+
nonInteractiveRefund: {
|
|
2158
|
+
senderPkScript: params.solverRefundPkScript,
|
|
2159
|
+
emulatorPubkey: params.emulatorPubkey
|
|
2160
|
+
}
|
|
2161
|
+
});
|
|
2162
|
+
}
|
|
2163
|
+
function deriveLightningReceive(input) {
|
|
2164
|
+
const { quote } = input;
|
|
2165
|
+
const profile = quote.profile ?? {};
|
|
2166
|
+
const refundLocktime = quote.refund_locktime;
|
|
2167
|
+
const invoice = profile.invoice;
|
|
2168
|
+
const solverRefundPkScriptHex = profile.solver_refund_pk_script;
|
|
2169
|
+
if (refundLocktime === void 0 || invoice === void 0 || solverRefundPkScriptHex === void 0) {
|
|
2170
|
+
throw new Error("lightning-receive quote is missing a binding field");
|
|
2171
|
+
}
|
|
2172
|
+
const script = receiveVtxoScript({
|
|
2173
|
+
solverPubkey: xOnly2(import_base8.hex.decode(quote.solver_pubkey), "solver key"),
|
|
2174
|
+
refundLocktime,
|
|
2175
|
+
serverPubkey: input.serverPubkey,
|
|
2176
|
+
paymentHash: input.paymentHash,
|
|
2177
|
+
claimDelay: input.claimDelay,
|
|
2178
|
+
emulatorPubkey: input.emulatorPubkey,
|
|
2179
|
+
solverRefundPkScript: solverHex(solverRefundPkScriptHex, "profile.solver_refund_pk_script"),
|
|
2180
|
+
payoutPubkey: input.payoutPubkey,
|
|
2181
|
+
payoutPkScript: import_sdk8.ArkAddress.decode(input.payoutAddress).pkScript
|
|
2182
|
+
});
|
|
2183
|
+
const address = script.address(input.hrp, input.serverPubkey).encode();
|
|
2184
|
+
verifyLockupAddress(quote, address);
|
|
2185
|
+
return { address, swapPkScript: script.pkScript, script, invoice, refundLocktime };
|
|
2186
|
+
}
|
|
2187
|
+
async function requestLightningReceive(wallet, arkServerUrl, emulatorPubkey, transport, params) {
|
|
2188
|
+
const rfqId = params.rfqId ?? newRfqId();
|
|
2189
|
+
const secrets = await deriveSwapSecrets(wallet) ?? randomSwapSecrets({ preimage: true });
|
|
2190
|
+
if (!secrets.derivable) {
|
|
2191
|
+
console.warn(
|
|
2192
|
+
"[swap] this swap's preimage and payout key are random and MUST be persisted before paying"
|
|
2193
|
+
);
|
|
2194
|
+
}
|
|
2195
|
+
const preimage = await preimageForRfqSecrets(wallet, secrets);
|
|
2196
|
+
const paymentHash = paymentHashOf(preimage);
|
|
2197
|
+
const payoutPubkey = await senderPubkeyForRfqSecrets(wallet, secrets);
|
|
2198
|
+
const [info, payoutAddress] = await Promise.all([
|
|
2199
|
+
new import_sdk8.RestArkProvider(arkServerUrl).getInfo(),
|
|
2200
|
+
wallet.getAddress()
|
|
2201
|
+
]);
|
|
2202
|
+
const claimPacket = await sealClaimPacket({
|
|
2203
|
+
preimage,
|
|
2204
|
+
covclaimdPubkey: params.covclaimdPubkey
|
|
2205
|
+
});
|
|
2206
|
+
const quote = await transport.requestQuote(
|
|
2207
|
+
lightningReceiveRequest({
|
|
2208
|
+
rfqId,
|
|
2209
|
+
paymentHash,
|
|
2210
|
+
payoutAddress,
|
|
2211
|
+
payoutPubkey,
|
|
2212
|
+
claimPacket: claimPacket.ciphertext,
|
|
2213
|
+
amount: params.amount,
|
|
2214
|
+
amountSide: params.amountSide
|
|
2215
|
+
})
|
|
2216
|
+
);
|
|
2217
|
+
assertQuotedAmount(quote, params.amountSide, params.amount);
|
|
2218
|
+
const derived = deriveLightningReceive({
|
|
2219
|
+
quote,
|
|
2220
|
+
paymentHash,
|
|
2221
|
+
payoutPubkey,
|
|
2222
|
+
payoutAddress,
|
|
2223
|
+
serverPubkey: xOnly2(import_base8.hex.decode(info.signerPubkey), "ark signer key"),
|
|
2224
|
+
emulatorPubkey: xOnly2(emulatorPubkey, "emulator pubkey"),
|
|
2225
|
+
claimDelay: unilateralClaimDelay(Number(info.unilateralExitDelay)),
|
|
2226
|
+
hrp: (0, import_sdk8.getNetwork)(info.network).hrp
|
|
2227
|
+
});
|
|
2228
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2229
|
+
const { payDeadline } = verifyReceiveInvoice({
|
|
2230
|
+
invoice: derived.invoice,
|
|
2231
|
+
decode: params.decodeInvoice,
|
|
2232
|
+
paymentHash,
|
|
2233
|
+
quote
|
|
2234
|
+
});
|
|
2235
|
+
assertReceivable({ quote, payDeadline, now, maxPayAmount: params.maxPayAmount });
|
|
2236
|
+
await registerLockupContract(
|
|
2237
|
+
await wallet.getContractManager(),
|
|
2238
|
+
derived.script,
|
|
2239
|
+
derived.address
|
|
2240
|
+
);
|
|
2241
|
+
return {
|
|
2242
|
+
rfqId,
|
|
2243
|
+
quote,
|
|
2244
|
+
invoice: derived.invoice,
|
|
2245
|
+
payAmount: quote.from_amount,
|
|
2246
|
+
expectedAmount: quote.to_amount,
|
|
2247
|
+
invoiceExpiresAt: payDeadline,
|
|
2248
|
+
address: derived.address,
|
|
2249
|
+
swapPkScript: derived.swapPkScript,
|
|
2250
|
+
script: derived.script,
|
|
2251
|
+
payoutAddress,
|
|
2252
|
+
payoutPubkey,
|
|
2253
|
+
secrets
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
function deriveOnchainReceive(input) {
|
|
2257
|
+
const { quote } = input;
|
|
2258
|
+
const profile = quote.profile ?? {};
|
|
2259
|
+
const refundLocktime = quote.refund_locktime;
|
|
2260
|
+
const claimPubkey = profile.claim_pubkey;
|
|
2261
|
+
const htlcLocktime = profile.htlc_locktime;
|
|
2262
|
+
const htlcAddress = profile.htlc_address;
|
|
2263
|
+
const minConfirmations = profile.min_confirmations;
|
|
2264
|
+
const solverRefundPkScriptHex = profile.solver_refund_pk_script;
|
|
2265
|
+
if (refundLocktime === void 0 || claimPubkey === void 0 || htlcLocktime === void 0 || minConfirmations === void 0 || solverRefundPkScriptHex === void 0) {
|
|
2266
|
+
throw new Error("onchain-receive quote is missing a binding field");
|
|
2267
|
+
}
|
|
2268
|
+
const script = receiveVtxoScript({
|
|
2269
|
+
solverPubkey: xOnly2(import_base8.hex.decode(quote.solver_pubkey), "solver key"),
|
|
2270
|
+
refundLocktime,
|
|
2271
|
+
serverPubkey: input.serverPubkey,
|
|
2272
|
+
paymentHash: input.paymentHash,
|
|
2273
|
+
claimDelay: input.claimDelay,
|
|
2274
|
+
emulatorPubkey: input.emulatorPubkey,
|
|
2275
|
+
solverRefundPkScript: solverHex(solverRefundPkScriptHex, "profile.solver_refund_pk_script"),
|
|
2276
|
+
payoutPubkey: input.payoutPubkey,
|
|
2277
|
+
payoutPkScript: import_sdk8.ArkAddress.decode(input.payoutAddress).pkScript
|
|
2278
|
+
});
|
|
2279
|
+
const address = script.address(input.hrp, input.serverPubkey).encode();
|
|
2280
|
+
verifyLockupAddress(quote, address);
|
|
2281
|
+
const htlc = onchainHtlcScript(
|
|
2282
|
+
{
|
|
2283
|
+
paymentHash: input.paymentHash,
|
|
2284
|
+
claimKey: xOnly2(import_base8.hex.decode(claimPubkey), "solver L1 claim key"),
|
|
2285
|
+
refundKey: input.refundPubkey,
|
|
2286
|
+
refundLocktime: htlcLocktime
|
|
2287
|
+
},
|
|
2288
|
+
input.l1Network
|
|
2289
|
+
);
|
|
2290
|
+
if (htlc.address !== htlcAddress) throw new AddressMismatch(htlc.address, htlcAddress);
|
|
2291
|
+
return {
|
|
2292
|
+
address,
|
|
2293
|
+
swapPkScript: script.pkScript,
|
|
2294
|
+
script,
|
|
2295
|
+
htlc,
|
|
2296
|
+
refundLocktime,
|
|
2297
|
+
htlcLocktime,
|
|
2298
|
+
minConfirmations
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
async function requestOnchainReceive(wallet, arkServerUrl, emulatorPubkey, transport, params) {
|
|
2302
|
+
const rfqId = params.rfqId ?? newRfqId();
|
|
2303
|
+
const secrets = await deriveSwapSecrets(wallet) ?? randomSwapSecrets({ preimage: true });
|
|
2304
|
+
if (!secrets.derivable) {
|
|
2305
|
+
console.warn(
|
|
2306
|
+
"[swap] this swap's preimage and payout key are random and MUST be persisted before funding"
|
|
2307
|
+
);
|
|
2308
|
+
}
|
|
2309
|
+
const preimage = await preimageForRfqSecrets(wallet, secrets);
|
|
2310
|
+
const paymentHash = paymentHashOf(preimage);
|
|
2311
|
+
const payoutPubkey = await senderPubkeyForRfqSecrets(wallet, secrets);
|
|
2312
|
+
const [info, payoutAddress] = await Promise.all([
|
|
2313
|
+
new import_sdk8.RestArkProvider(arkServerUrl).getInfo(),
|
|
2314
|
+
wallet.getAddress()
|
|
2315
|
+
]);
|
|
2316
|
+
const claimPacket = await sealClaimPacket({
|
|
2317
|
+
preimage,
|
|
2318
|
+
covclaimdPubkey: params.covclaimdPubkey
|
|
2319
|
+
});
|
|
2320
|
+
const quote = await transport.requestQuote(
|
|
2321
|
+
onchainReceiveRequest({
|
|
2322
|
+
rfqId,
|
|
2323
|
+
paymentHash,
|
|
2324
|
+
payoutAddress,
|
|
2325
|
+
payoutPubkey,
|
|
2326
|
+
refundPubkey: params.refundPubkey,
|
|
2327
|
+
claimPacket: claimPacket.ciphertext,
|
|
2328
|
+
amount: params.amount,
|
|
2329
|
+
amountSide: params.amountSide
|
|
2330
|
+
})
|
|
2331
|
+
);
|
|
2332
|
+
assertQuotedAmount(quote, params.amountSide, params.amount);
|
|
2333
|
+
const derived = deriveOnchainReceive({
|
|
2334
|
+
quote,
|
|
2335
|
+
paymentHash,
|
|
2336
|
+
payoutPubkey,
|
|
2337
|
+
payoutAddress,
|
|
2338
|
+
refundPubkey: params.refundPubkey,
|
|
2339
|
+
serverPubkey: xOnly2(import_base8.hex.decode(info.signerPubkey), "ark signer key"),
|
|
2340
|
+
emulatorPubkey: xOnly2(emulatorPubkey, "emulator pubkey"),
|
|
2341
|
+
claimDelay: unilateralClaimDelay(Number(info.unilateralExitDelay)),
|
|
2342
|
+
hrp: (0, import_sdk8.getNetwork)(info.network).hrp,
|
|
2343
|
+
l1Network: l1NetworkFromArk(info.network)
|
|
2344
|
+
});
|
|
2345
|
+
assertFundable({
|
|
2346
|
+
quote,
|
|
2347
|
+
now: Math.floor(Date.now() / 1e3),
|
|
2348
|
+
onchain: {
|
|
2349
|
+
htlcLocktime: derived.htlcLocktime,
|
|
2350
|
+
minConfirmations: derived.minConfirmations,
|
|
2351
|
+
direction: "receive"
|
|
2352
|
+
}
|
|
2353
|
+
});
|
|
2354
|
+
await registerLockupContract(
|
|
2355
|
+
await wallet.getContractManager(),
|
|
2356
|
+
derived.script,
|
|
2357
|
+
derived.address
|
|
2358
|
+
);
|
|
2359
|
+
return {
|
|
2360
|
+
rfqId,
|
|
2361
|
+
quote,
|
|
2362
|
+
address: derived.address,
|
|
2363
|
+
fundAmount: quote.from_amount,
|
|
2364
|
+
expectedAmount: quote.to_amount,
|
|
2365
|
+
swapPkScript: derived.swapPkScript,
|
|
2366
|
+
script: derived.script,
|
|
2367
|
+
htlc: derived.htlc,
|
|
2368
|
+
payoutAddress,
|
|
2369
|
+
payoutPubkey,
|
|
2370
|
+
secrets
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// src/claim.ts
|
|
2375
|
+
var import_base10 = require("@scure/base");
|
|
2376
|
+
var import_legacy3 = require("@noble/hashes/legacy.js");
|
|
2377
|
+
var import_sha25 = require("@noble/hashes/sha2.js");
|
|
2378
|
+
var import_sdk10 = require("@arkade-os/sdk");
|
|
2379
|
+
|
|
2380
|
+
// src/refund.ts
|
|
2381
|
+
var import_base9 = require("@scure/base");
|
|
2382
|
+
var import_sha24 = require("@noble/hashes/sha2.js");
|
|
2383
|
+
var import_sdk9 = require("@arkade-os/sdk");
|
|
2384
|
+
var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2385
|
+
var isRfqTerminal = (state) => RFQ_TERMINAL_STATES.includes(state);
|
|
2386
|
+
var RFQ_RESOLVED_STATES = ["settled", "refunded"];
|
|
2387
|
+
var isResolved = (state) => RFQ_RESOLVED_STATES.includes(state);
|
|
2388
|
+
async function awaitRfqResolution(transport, rfqId, options = {}) {
|
|
2389
|
+
const pollMs = options.pollMs ?? 5e3;
|
|
2390
|
+
for (; ; ) {
|
|
2391
|
+
const status = await transport.status(rfqId);
|
|
2392
|
+
if (status && isRfqTerminal(status.state)) return status;
|
|
2393
|
+
if (options.deadline !== void 0 && Date.now() / 1e3 >= options.deadline) {
|
|
2394
|
+
const error = new Error(
|
|
2395
|
+
`rfq ${rfqId} did not reach a terminal state before the deadline`
|
|
2396
|
+
);
|
|
2397
|
+
error.reason = "status_timeout";
|
|
2398
|
+
throw error;
|
|
2399
|
+
}
|
|
2400
|
+
await sleep2(pollMs);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
var LockupNeedsRecoveryError = class extends Error {
|
|
2404
|
+
name = "LockupNeedsRecoveryError";
|
|
2405
|
+
reason = "needs_recovery";
|
|
2406
|
+
/** `txid:vout` for each output that must be recovered first. */
|
|
2407
|
+
outpoints;
|
|
2408
|
+
/**
|
|
2409
|
+
* The contract's `refundLocktime`. Recovering before this matures is the
|
|
2410
|
+
* hazard described above: `recoverVtxos()` sweeps EVERY recoverable output
|
|
2411
|
+
* into one settlement with no CLTV awareness, so an early attempt can fail
|
|
2412
|
+
* the whole batch — including unrelated outputs that were otherwise fine.
|
|
2413
|
+
*
|
|
2414
|
+
* Exposed as a value, not only inside the message, so a caller can encode
|
|
2415
|
+
* `packages/boltz-swap`'s "pre-CLTV recoverable → skipped" rule without
|
|
2416
|
+
* parsing prose. Seconds-based locktimes mature against the chain tip's
|
|
2417
|
+
* timestamp rather than wall clock, so treat this as a floor to wait past,
|
|
2418
|
+
* not an exact alarm.
|
|
2419
|
+
*/
|
|
2420
|
+
recoverableAfter;
|
|
2421
|
+
constructor(outpoints, recoverableAfter) {
|
|
2422
|
+
super(
|
|
2423
|
+
`refund refused: ${outpoints.length} lockup output(s) have been swept and can no longer be spent offchain (${outpoints.join(", ")}). Recover them into a fresh batch first \u2014 IVtxoManager.recoverVtxos() does this for a wallet whose contract manager has the lockup registered, once refundLocktime (${recoverableAfter}) has matured \u2014 then retry the refund. Recovering before then can fail the entire settlement, not just these outputs.`
|
|
2424
|
+
);
|
|
2425
|
+
this.outpoints = outpoints;
|
|
2426
|
+
this.recoverableAfter = recoverableAfter;
|
|
2427
|
+
}
|
|
2428
|
+
};
|
|
2429
|
+
async function findLockupVtxos(indexer, swapPkScript) {
|
|
2430
|
+
const scripts = [import_base9.hex.encode(swapPkScript)];
|
|
2431
|
+
const [spendable, recoverable] = await Promise.all([
|
|
2432
|
+
indexer.getVtxos({ scripts, spendableOnly: true }),
|
|
2433
|
+
indexer.getVtxos({ scripts, recoverableOnly: true })
|
|
2434
|
+
]);
|
|
2435
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2436
|
+
const out = [];
|
|
2437
|
+
for (const [vtxos, isRecoverable] of [
|
|
2438
|
+
[spendable.vtxos ?? [], false],
|
|
2439
|
+
[recoverable.vtxos ?? [], true]
|
|
2440
|
+
]) {
|
|
2441
|
+
for (const vtxo of vtxos) {
|
|
2442
|
+
const key = `${vtxo.txid}:${vtxo.vout}`;
|
|
2443
|
+
if (seen.has(key)) continue;
|
|
2444
|
+
seen.add(key);
|
|
2445
|
+
out.push({
|
|
2446
|
+
txid: vtxo.txid,
|
|
2447
|
+
vout: vtxo.vout,
|
|
2448
|
+
value: Number(vtxo.value),
|
|
2449
|
+
recoverable: isRecoverable
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
return out;
|
|
2454
|
+
}
|
|
2455
|
+
var hashesTo = (candidate, paymentHash) => import_base9.hex.encode((0, import_sha24.sha256)(candidate)) === paymentHash;
|
|
2456
|
+
var candidateWitnessItems = (tx, inputIndex) => [
|
|
2457
|
+
...(0, import_sdk9.getArkPsbtFields)(tx, inputIndex, import_sdk9.ConditionWitness).flat(),
|
|
2458
|
+
...tx.getInput(inputIndex).finalScriptWitness ?? []
|
|
2459
|
+
];
|
|
2460
|
+
async function readLockupFate(indexer, input) {
|
|
2461
|
+
const { vtxos } = await indexer.getVtxos({ scripts: [import_base9.hex.encode(input.swapPkScript)] });
|
|
2462
|
+
const all = vtxos ?? [];
|
|
2463
|
+
if (all.length === 0) return { fate: "unknown" };
|
|
2464
|
+
const spentBy = /* @__PURE__ */ new Set();
|
|
2465
|
+
let everySpendNamed = true;
|
|
2466
|
+
for (const vtxo of all) {
|
|
2467
|
+
if (!vtxo.isSpent && !vtxo.spentBy && !vtxo.settledBy) return { fate: "open" };
|
|
2468
|
+
if (vtxo.spentBy) spentBy.add(vtxo.spentBy);
|
|
2469
|
+
else everySpendNamed = false;
|
|
2470
|
+
}
|
|
2471
|
+
const { txs } = await indexer.getVirtualTxs([...spentBy]);
|
|
2472
|
+
const observed = /* @__PURE__ */ new Set();
|
|
2473
|
+
for (const raw of txs) {
|
|
2474
|
+
let tx;
|
|
2475
|
+
try {
|
|
2476
|
+
tx = import_sdk9.Transaction.fromPSBT(import_base9.base64.decode(raw));
|
|
2477
|
+
} catch {
|
|
2478
|
+
continue;
|
|
2479
|
+
}
|
|
2480
|
+
if (spentBy.has(tx.id)) observed.add(tx.id);
|
|
2481
|
+
for (let i = 0; i < tx.inputsLength; i++) {
|
|
2482
|
+
const spent = tx.getInput(i);
|
|
2483
|
+
if (!spent.txid) continue;
|
|
2484
|
+
const txid = import_base9.hex.encode(spent.txid);
|
|
2485
|
+
if (!all.some((vtxo) => vtxo.txid === txid && vtxo.vout === spent.index)) continue;
|
|
2486
|
+
for (const candidate of candidateWitnessItems(tx, i)) {
|
|
2487
|
+
if (hashesTo(candidate, input.paymentHash)) {
|
|
2488
|
+
return { fate: "claimed", preimage: candidate };
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
return everySpendNamed && observed.size === spentBy.size ? { fate: "returned" } : { fate: "unknown" };
|
|
2494
|
+
}
|
|
2495
|
+
async function pushRefundWithoutReceiver(ark, input) {
|
|
2496
|
+
if (input.vtxos.length === 0) throw new Error("nothing to refund: no funded outputs");
|
|
2497
|
+
const swept = input.vtxos.filter((vtxo) => vtxo.recoverable);
|
|
2498
|
+
if (swept.length > 0) {
|
|
2499
|
+
throw new LockupNeedsRecoveryError(
|
|
2500
|
+
swept.map((vtxo) => `${vtxo.txid}:${vtxo.vout}`),
|
|
2501
|
+
input.script.options.refundLocktime
|
|
2502
|
+
);
|
|
2503
|
+
}
|
|
2504
|
+
const refundPkScript = input.refundPkScript ?? input.script.options.nonInteractiveRefund?.senderPkScript;
|
|
2505
|
+
if (!refundPkScript) {
|
|
2506
|
+
throw new Error(
|
|
2507
|
+
"no refund destination: the contract carries no nonInteractiveRefund leaf, so pass refundPkScript explicitly"
|
|
2508
|
+
);
|
|
2509
|
+
}
|
|
2510
|
+
const info = await ark.getInfo();
|
|
2511
|
+
let serverUnrollScript;
|
|
2512
|
+
try {
|
|
2513
|
+
serverUnrollScript = import_sdk9.CSVMultisigTapscript.decode(import_base9.hex.decode(info.checkpointTapscript));
|
|
2514
|
+
} catch {
|
|
2515
|
+
throw new Error("invalid checkpointTapscript from the Arkade server");
|
|
2516
|
+
}
|
|
2517
|
+
const leaf = input.script.refundWithoutReceiver();
|
|
2518
|
+
const tapTree = input.script.encode();
|
|
2519
|
+
const amount = input.vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0);
|
|
2520
|
+
const { arkTx, checkpoints } = (0, import_sdk9.buildOffchainTx)(
|
|
2521
|
+
input.vtxos.map((vtxo) => ({
|
|
2522
|
+
txid: vtxo.txid,
|
|
2523
|
+
vout: vtxo.vout,
|
|
2524
|
+
value: vtxo.value,
|
|
2525
|
+
tapLeafScript: leaf,
|
|
2526
|
+
tapTree
|
|
2527
|
+
})),
|
|
2528
|
+
[{ script: refundPkScript, amount: BigInt(amount) }],
|
|
2529
|
+
serverUnrollScript
|
|
2530
|
+
);
|
|
2531
|
+
const signedArkTx = await input.sender.sign(arkTx);
|
|
2532
|
+
const submitted = await ark.submitTx(
|
|
2533
|
+
import_base9.base64.encode(signedArkTx.toPSBT()),
|
|
2534
|
+
checkpoints.map((c) => import_base9.base64.encode(c.toPSBT()))
|
|
2535
|
+
);
|
|
2536
|
+
(0, import_sdk9.assertSubmittedArkTxid)(submitted, signedArkTx, "refundWithoutReceiver");
|
|
2537
|
+
const matched = (0, import_sdk9.matchServerCheckpoints)(
|
|
2538
|
+
submitted.signedCheckpointTxs,
|
|
2539
|
+
checkpoints,
|
|
2540
|
+
"refundWithoutReceiver"
|
|
2541
|
+
);
|
|
2542
|
+
const finalCheckpoints = await Promise.all(
|
|
2543
|
+
matched.map(
|
|
2544
|
+
async ({ server }) => import_base9.base64.encode((await input.sender.sign(server, [0])).toPSBT())
|
|
2545
|
+
)
|
|
2546
|
+
);
|
|
2547
|
+
await ark.finalizeTx(submitted.arkTxid, finalCheckpoints);
|
|
2548
|
+
return { arkTxid: submitted.arkTxid, amount };
|
|
2549
|
+
}
|
|
2550
|
+
var REFUND_MTP_LAG_SECONDS = 2 * 60 * 60;
|
|
2551
|
+
async function refundIfUnresolved(transport, ark, indexer, input) {
|
|
2552
|
+
const pollMs = input.pollMs ?? 5e3;
|
|
2553
|
+
const now = input.now ?? (() => Math.floor(Date.now() / 1e3));
|
|
2554
|
+
const attemptDeadline = input.attemptDeadline ?? input.refundLocktime + REFUND_MTP_LAG_SECONDS;
|
|
2555
|
+
for (; ; ) {
|
|
2556
|
+
const status = await transport.status(input.rfqId);
|
|
2557
|
+
if (status && isResolved(status.state)) return { outcome: "resolved", status };
|
|
2558
|
+
if (now() >= input.refundLocktime) {
|
|
2559
|
+
const vtxos = await findLockupVtxos(indexer, input.script.pkScript);
|
|
2560
|
+
if (vtxos.length === 0) return { outcome: "nothing_to_refund", status };
|
|
2561
|
+
try {
|
|
2562
|
+
const pushed = await pushRefundWithoutReceiver(ark, {
|
|
2563
|
+
script: input.script,
|
|
2564
|
+
sender: input.sender,
|
|
2565
|
+
vtxos,
|
|
2566
|
+
refundPkScript: input.refundPkScript
|
|
2567
|
+
});
|
|
2568
|
+
return { outcome: "refunded", status, ...pushed };
|
|
2569
|
+
} catch (error) {
|
|
2570
|
+
if (error instanceof LockupNeedsRecoveryError) {
|
|
2571
|
+
return {
|
|
2572
|
+
outcome: "needs_recovery",
|
|
2573
|
+
outpoints: error.outpoints,
|
|
2574
|
+
vtxos,
|
|
2575
|
+
status
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
if (now() >= attemptDeadline) throw error;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
await sleep2(pollMs);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
// src/claim.ts
|
|
2586
|
+
var sleep3 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2587
|
+
var LockupAmountMismatchError = class extends Error {
|
|
2588
|
+
name = "LockupAmountMismatchError";
|
|
2589
|
+
reason = "amount_mismatch";
|
|
2590
|
+
expectedAmount;
|
|
2591
|
+
lockedAmount;
|
|
2592
|
+
constructor(expectedAmount, lockedAmount) {
|
|
2593
|
+
super(
|
|
2594
|
+
`lockup holds ${lockedAmount} sats, below the agreed ${expectedAmount} \u2014 refusing to publish the preimage`
|
|
2595
|
+
);
|
|
2596
|
+
this.expectedAmount = expectedAmount;
|
|
2597
|
+
this.lockedAmount = lockedAmount;
|
|
2598
|
+
}
|
|
2599
|
+
};
|
|
2600
|
+
var assertFiniteAmount = (value, reason, label) => {
|
|
2601
|
+
if (Number.isFinite(value)) return;
|
|
2602
|
+
const error = new Error(`${label} is not a finite number (${String(value)})`);
|
|
2603
|
+
error.reason = reason;
|
|
2604
|
+
throw error;
|
|
2605
|
+
};
|
|
2606
|
+
async function pushClaim(ark, input) {
|
|
2607
|
+
if (input.vtxos.length === 0) throw new Error("nothing to claim: no funded outputs");
|
|
2608
|
+
const swept = input.vtxos.filter((vtxo) => vtxo.recoverable);
|
|
2609
|
+
if (swept.length > 0) {
|
|
2610
|
+
throw new LockupNeedsRecoveryError(
|
|
2611
|
+
swept.map((vtxo) => `${vtxo.txid}:${vtxo.vout}`),
|
|
2612
|
+
input.script.options.refundLocktime
|
|
2613
|
+
);
|
|
2614
|
+
}
|
|
2615
|
+
const locked = input.vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0);
|
|
2616
|
+
assertFiniteAmount(locked, "lockup_malformed", "the lockup's summed value");
|
|
2617
|
+
if (!input.partiallyClaimed) {
|
|
2618
|
+
assertFiniteAmount(input.expectedAmount, "invalid_gate_input", "expectedAmount");
|
|
2619
|
+
if (locked < input.expectedAmount) {
|
|
2620
|
+
throw new LockupAmountMismatchError(input.expectedAmount, locked);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
const committed = input.script.options.preimageHash;
|
|
2624
|
+
if (import_base10.hex.encode((0, import_legacy3.ripemd160)((0, import_sha25.sha256)(input.preimage))) !== import_base10.hex.encode(committed)) {
|
|
2625
|
+
throw new Error("preimage does not match the covenant's payment hash");
|
|
2626
|
+
}
|
|
2627
|
+
const info = await ark.getInfo();
|
|
2628
|
+
let serverUnrollScript;
|
|
2629
|
+
try {
|
|
2630
|
+
serverUnrollScript = import_sdk10.CSVMultisigTapscript.decode(import_base10.hex.decode(info.checkpointTapscript));
|
|
2631
|
+
} catch {
|
|
2632
|
+
throw new Error("invalid checkpointTapscript from the Arkade server");
|
|
2633
|
+
}
|
|
2634
|
+
const leaf = input.script.claim();
|
|
2635
|
+
const tapTree = input.script.encode();
|
|
2636
|
+
const arkTxid = await (0, import_sdk10.signAndSubmitOffchainTx)({
|
|
2637
|
+
identity: (0, import_sdk10.claimWithPreimageIdentity)(input.receiver, input.preimage),
|
|
2638
|
+
provider: ark,
|
|
2639
|
+
inputs: input.vtxos.map((vtxo) => ({
|
|
2640
|
+
txid: vtxo.txid,
|
|
2641
|
+
vout: vtxo.vout,
|
|
2642
|
+
value: vtxo.value,
|
|
2643
|
+
tapLeafScript: leaf,
|
|
2644
|
+
tapTree
|
|
2645
|
+
})),
|
|
2646
|
+
// One aggregate output: unlike the covenant refund, this leaf inspects
|
|
2647
|
+
// nothing about the output set.
|
|
2648
|
+
outputs: [{ script: input.destinationPkScript, amount: BigInt(locked) }],
|
|
2649
|
+
serverUnrollScript,
|
|
2650
|
+
verifyServerSignatures: { serverPubkey: input.script.options.server }
|
|
2651
|
+
});
|
|
2652
|
+
return { arkTxid, amount: locked };
|
|
2653
|
+
}
|
|
2654
|
+
async function awaitLockupFunding(indexer, swapPkScript, options = {}) {
|
|
2655
|
+
const pollMs = options.pollMs ?? 5e3;
|
|
2656
|
+
for (; ; ) {
|
|
2657
|
+
const vtxos = await findLockupVtxos(indexer, swapPkScript);
|
|
2658
|
+
if (vtxos.length > 0) return vtxos;
|
|
2659
|
+
if (options.deadline !== void 0 && Date.now() / 1e3 >= options.deadline) {
|
|
2660
|
+
const error = new Error("the lockup never appeared at the covenant script");
|
|
2661
|
+
error.reason = "lockup_timeout";
|
|
2662
|
+
throw error;
|
|
2663
|
+
}
|
|
2664
|
+
await sleep3(pollMs);
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
async function claimReceiveLockup(indexer, ark, input) {
|
|
2668
|
+
const vtxos = await awaitLockupFunding(indexer, input.swapPkScript, {
|
|
2669
|
+
pollMs: input.pollMs,
|
|
2670
|
+
deadline: input.deadline
|
|
2671
|
+
});
|
|
2672
|
+
return pushClaim(ark, {
|
|
2673
|
+
script: input.script,
|
|
2674
|
+
receiver: input.receiver,
|
|
2675
|
+
preimage: input.preimage,
|
|
2676
|
+
vtxos,
|
|
2677
|
+
destinationPkScript: input.destinationPkScript,
|
|
2678
|
+
expectedAmount: input.expectedAmount,
|
|
2679
|
+
partiallyClaimed: input.partiallyClaimed
|
|
2680
|
+
});
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
// src/swapManager.ts
|
|
2684
|
+
var import_base11 = require("@scure/base");
|
|
2685
|
+
var RFQ_SWAP_TERMINAL_STATES = ["settled", "refunded", "failed"];
|
|
2686
|
+
var isRfqSwapTerminal = (state) => RFQ_SWAP_TERMINAL_STATES.includes(state);
|
|
2687
|
+
function nextOnchainAction(input) {
|
|
2688
|
+
switch (input.phase.phase) {
|
|
2689
|
+
case "unfunded":
|
|
2690
|
+
case "awaiting_confirmations":
|
|
2691
|
+
return "wait";
|
|
2692
|
+
case "claimed":
|
|
2693
|
+
return "claimed";
|
|
2694
|
+
case "swept":
|
|
2695
|
+
return "swept";
|
|
2696
|
+
case "refundable":
|
|
2697
|
+
return "claim_window_closed";
|
|
2698
|
+
case "claimable":
|
|
2699
|
+
return input.htlcLocktime - input.now >= ONCHAIN_CLAIM_MARGIN_SECONDS ? "claim" : "claim_window_closed";
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
var notify = (listeners, call) => {
|
|
2703
|
+
for (const listener of listeners) {
|
|
2704
|
+
try {
|
|
2705
|
+
call(listener);
|
|
2706
|
+
} catch {
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
};
|
|
2710
|
+
var RfqSwapManager = class {
|
|
2711
|
+
deps;
|
|
2712
|
+
config;
|
|
2713
|
+
callbacks = null;
|
|
2714
|
+
swapUpdateListeners = /* @__PURE__ */ new Set();
|
|
2715
|
+
swapCompletedListeners = /* @__PURE__ */ new Set();
|
|
2716
|
+
swapFailedListeners = /* @__PURE__ */ new Set();
|
|
2717
|
+
actionExecutedListeners = /* @__PURE__ */ new Set();
|
|
2718
|
+
monitored = /* @__PURE__ */ new Map();
|
|
2719
|
+
/** Monitored swaps by lockup script hex, so a contract event — which names
|
|
2720
|
+
* a script and nothing else — can find the swap it belongs to. */
|
|
2721
|
+
byLockupScript = /* @__PURE__ */ new Map();
|
|
2722
|
+
/**
|
|
2723
|
+
* Swaps whose lockup registration has been SETTLED one way or another,
|
|
2724
|
+
* mapped to whether a contract row actually resulted. Membership is what
|
|
2725
|
+
* stops a per-pass retry from becoming a per-pass round trip; the value is
|
|
2726
|
+
* what keeps a swap that could never be registered from later trying to
|
|
2727
|
+
* retire a row that does not exist, which would report a spurious failure
|
|
2728
|
+
* on a swap that in fact succeeded.
|
|
2729
|
+
*/
|
|
2730
|
+
registered = /* @__PURE__ */ new Map();
|
|
2731
|
+
/**
|
|
2732
|
+
* Swaps whose `refundArkade` answered {@link RefundNotLocallyPossibleError}
|
|
2733
|
+
* in this process. Membership stops the push from being re-issued every
|
|
2734
|
+
* pass — it cannot start working on its own, and re-issuing it is the
|
|
2735
|
+
* grind `needs_counterparty` exists to remove. Only
|
|
2736
|
+
* {@link RfqSwapManagerCallbacks.canRefundArkade} clears it, so a caller
|
|
2737
|
+
* with no probe learns again on the next start, when the wallet that can
|
|
2738
|
+
* sign may well have been restored.
|
|
2739
|
+
*/
|
|
2740
|
+
refundRefused = /* @__PURE__ */ new Set();
|
|
2741
|
+
/**
|
|
2742
|
+
* The last error a receive swap's claim callback threw, by rfqId.
|
|
2743
|
+
*
|
|
2744
|
+
* Kept only to tell two terminal outcomes apart once the claim window
|
|
2745
|
+
* shuts: a swap whose claim was attempted and kept failing ends `failed`
|
|
2746
|
+
* with that reason, while one that simply never became claimable ends
|
|
2747
|
+
* `refunded`. Without it a broken claim callback would resolve a caller's
|
|
2748
|
+
* {@link waitForSwapCompletion} as an ordinary unwind.
|
|
2749
|
+
*
|
|
2750
|
+
* Process-local, like {@link refundRefused}: after a restart the same swap
|
|
2751
|
+
* ends `refunded` instead, which costs the caller a reason and nothing else
|
|
2752
|
+
* — every throw was already reported through `onSwapFailed` as it happened.
|
|
2753
|
+
*/
|
|
2754
|
+
lastClaimError = /* @__PURE__ */ new Map();
|
|
2755
|
+
/**
|
|
2756
|
+
* The lockup outpoints a receive swap's claim callback has already been
|
|
2757
|
+
* handed, by rfqId.
|
|
2758
|
+
*
|
|
2759
|
+
* What this exists to prevent: a claim SUCCEEDS, and for the next few
|
|
2760
|
+
* passes the indexer still lists those outputs as unspent. Without a
|
|
2761
|
+
* record of what was already claimed, every one of those passes would
|
|
2762
|
+
* re-submit the same spend, fail against the server, and report a swap
|
|
2763
|
+
* that in fact worked as failing. With one, a re-claim happens only when
|
|
2764
|
+
* an outpoint appears that was never claimed — a lockup funded piecemeal,
|
|
2765
|
+
* which is legitimate and which `partiallyClaimed` exists for.
|
|
2766
|
+
*
|
|
2767
|
+
* Process-local: after a restart a swap with a live claim tries once more.
|
|
2768
|
+
* That is the recovery case rather than the spam one — a claim that never
|
|
2769
|
+
* landed leaves its outputs unspent, and one that did leaves a single
|
|
2770
|
+
* rejection.
|
|
2771
|
+
*/
|
|
2772
|
+
claimedOutpoints = /* @__PURE__ */ new Map();
|
|
2773
|
+
/** Live `onContractEvent` subscription, held so `stop()` can drop it. */
|
|
2774
|
+
unsubscribeContracts = null;
|
|
2775
|
+
/** Terminal records, kept so a late {@link waitForSwapCompletion} still
|
|
2776
|
+
* answers instead of throwing "not found". Cleared by {@link removeSwap}. */
|
|
2777
|
+
finished = /* @__PURE__ */ new Map();
|
|
2778
|
+
waiters = /* @__PURE__ */ new Map();
|
|
2779
|
+
/** Records changed during the current pass, flushed through `saveSwap`. */
|
|
2780
|
+
dirty = /* @__PURE__ */ new Set();
|
|
2781
|
+
/** Race guard: one action at a time per swap. */
|
|
2782
|
+
inProgress = /* @__PURE__ */ new Set();
|
|
2783
|
+
timer = null;
|
|
2784
|
+
running = false;
|
|
2785
|
+
constructor(deps, config = {}) {
|
|
2786
|
+
this.deps = deps;
|
|
2787
|
+
this.config = {
|
|
2788
|
+
enableAutoActions: config.enableAutoActions ?? true,
|
|
2789
|
+
pollIntervalMs: config.pollIntervalMs ?? 5e3,
|
|
2790
|
+
now: config.now ?? (() => Math.floor(Date.now() / 1e3))
|
|
2791
|
+
};
|
|
2792
|
+
if (config.events?.onSwapUpdate) this.swapUpdateListeners.add(config.events.onSwapUpdate);
|
|
2793
|
+
if (config.events?.onSwapCompleted) {
|
|
2794
|
+
this.swapCompletedListeners.add(config.events.onSwapCompleted);
|
|
2795
|
+
}
|
|
2796
|
+
if (config.events?.onSwapFailed) this.swapFailedListeners.add(config.events.onSwapFailed);
|
|
2797
|
+
if (config.events?.onActionExecuted) {
|
|
2798
|
+
this.actionExecutedListeners.add(config.events.onActionExecuted);
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
/** Wire the money-moving half. Without it the manager only watches. */
|
|
2802
|
+
setCallbacks(callbacks) {
|
|
2803
|
+
this.callbacks = callbacks;
|
|
2804
|
+
}
|
|
2805
|
+
onSwapUpdate(listener) {
|
|
2806
|
+
this.swapUpdateListeners.add(listener);
|
|
2807
|
+
return () => this.swapUpdateListeners.delete(listener);
|
|
2808
|
+
}
|
|
2809
|
+
onSwapCompleted(listener) {
|
|
2810
|
+
this.swapCompletedListeners.add(listener);
|
|
2811
|
+
return () => this.swapCompletedListeners.delete(listener);
|
|
2812
|
+
}
|
|
2813
|
+
onSwapFailed(listener) {
|
|
2814
|
+
this.swapFailedListeners.add(listener);
|
|
2815
|
+
return () => this.swapFailedListeners.delete(listener);
|
|
2816
|
+
}
|
|
2817
|
+
onActionExecuted(listener) {
|
|
2818
|
+
this.actionExecutedListeners.add(listener);
|
|
2819
|
+
return () => this.actionExecutedListeners.delete(listener);
|
|
2820
|
+
}
|
|
2821
|
+
/**
|
|
2822
|
+
* Load records and begin monitoring. Runs one pass immediately — a caller
|
|
2823
|
+
* resuming after a restart may be well past a deadline already — then
|
|
2824
|
+
* every `pollIntervalMs`. Records that are already terminal are kept only
|
|
2825
|
+
* so {@link waitForSwapCompletion} can answer for them.
|
|
2826
|
+
*
|
|
2827
|
+
* Calling it again while running loads the records and returns rather than
|
|
2828
|
+
* re-arming — dropping them silently would strand a funded swap on a
|
|
2829
|
+
* caller's harmless double-start.
|
|
2830
|
+
*/
|
|
2831
|
+
async start(swaps = []) {
|
|
2832
|
+
for (const swap of swaps) {
|
|
2833
|
+
if (isRfqSwapTerminal(swap.state)) this.finished.set(swap.rfqId, swap);
|
|
2834
|
+
else this.track(swap);
|
|
2835
|
+
}
|
|
2836
|
+
if (this.running) return;
|
|
2837
|
+
this.running = true;
|
|
2838
|
+
this.subscribe();
|
|
2839
|
+
await this.poll();
|
|
2840
|
+
this.arm();
|
|
2841
|
+
}
|
|
2842
|
+
/**
|
|
2843
|
+
* Stop monitoring and clear the timer. In-flight actions are not
|
|
2844
|
+
* cancellable and run to completion; outstanding
|
|
2845
|
+
* {@link waitForSwapCompletion} promises are left pending, since
|
|
2846
|
+
* stop/start is a pause rather than a cancellation.
|
|
2847
|
+
*
|
|
2848
|
+
* The contract subscription is dropped too — an open stream with nothing
|
|
2849
|
+
* reacting to it is a leak, and {@link start} puts it back. What is NOT
|
|
2850
|
+
* undone is the contract registration: those rows are the wallet's, they
|
|
2851
|
+
* outlive this manager's lifecycle, and dropping them would unwatch a
|
|
2852
|
+
* lockup that is still funded.
|
|
2853
|
+
*/
|
|
2854
|
+
async stop() {
|
|
2855
|
+
this.running = false;
|
|
2856
|
+
if (this.timer) {
|
|
2857
|
+
clearTimeout(this.timer);
|
|
2858
|
+
this.timer = null;
|
|
2859
|
+
}
|
|
2860
|
+
this.unsubscribeContracts?.();
|
|
2861
|
+
this.unsubscribeContracts = null;
|
|
2862
|
+
}
|
|
2863
|
+
/** Begin monitoring a swap. Polled immediately when the manager is running,
|
|
2864
|
+
* so a just-funded swap does not wait out a whole interval. */
|
|
2865
|
+
async addSwap(swap) {
|
|
2866
|
+
if (isRfqSwapTerminal(swap.state)) {
|
|
2867
|
+
this.finished.set(swap.rfqId, swap);
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
this.track(swap);
|
|
2871
|
+
if (this.running) await this.pollSwap(swap);
|
|
2872
|
+
}
|
|
2873
|
+
/** Forget a swap entirely, monitored or finished.
|
|
2874
|
+
*
|
|
2875
|
+
* Its contract row is left alone: registration is a wallet-level fact about
|
|
2876
|
+
* a script that may still hold money, and this call says only that THIS
|
|
2877
|
+
* manager stops driving the swap. Retiring the row is reserved for a swap
|
|
2878
|
+
* that reached a terminal state, where the lockup is provably done. */
|
|
2879
|
+
async removeSwap(rfqId) {
|
|
2880
|
+
this.untrack(rfqId);
|
|
2881
|
+
this.finished.delete(rfqId);
|
|
2882
|
+
this.registered.delete(rfqId);
|
|
2883
|
+
const waiting = this.waiters.get(rfqId);
|
|
2884
|
+
if (waiting) {
|
|
2885
|
+
const error = new Error(`swap ${rfqId} was removed from monitoring`);
|
|
2886
|
+
for (const waiter of waiting) waiter.reject(error);
|
|
2887
|
+
}
|
|
2888
|
+
this.waiters.delete(rfqId);
|
|
2889
|
+
this.dirty.delete(rfqId);
|
|
2890
|
+
}
|
|
2891
|
+
/** Every swap still being monitored. */
|
|
2892
|
+
async getPendingSwaps() {
|
|
2893
|
+
return [...this.monitored.values()];
|
|
2894
|
+
}
|
|
2895
|
+
async hasSwap(rfqId) {
|
|
2896
|
+
return this.monitored.has(rfqId);
|
|
2897
|
+
}
|
|
2898
|
+
/** True while an action for this swap holds the per-swap lock. */
|
|
2899
|
+
async isProcessing(rfqId) {
|
|
2900
|
+
return this.inProgress.has(rfqId);
|
|
2901
|
+
}
|
|
2902
|
+
async getStats() {
|
|
2903
|
+
return {
|
|
2904
|
+
isRunning: this.running,
|
|
2905
|
+
monitoredSwaps: this.monitored.size,
|
|
2906
|
+
finishedSwaps: this.finished.size,
|
|
2907
|
+
inProgress: this.inProgress.size,
|
|
2908
|
+
pollIntervalMs: this.config.pollIntervalMs
|
|
2909
|
+
};
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* Run one monitoring pass over every swap now.
|
|
2913
|
+
*
|
|
2914
|
+
* {@link start} calls this on an interval, but it is public on purpose: a
|
|
2915
|
+
* caller that sleeps its process (a mobile app resuming, a service worker
|
|
2916
|
+
* waking) wants a pass on that event rather than at the next tick. Passes
|
|
2917
|
+
* do not overlap per swap — the in-progress lock makes a concurrent call a
|
|
2918
|
+
* no-op for any swap already being worked on.
|
|
2919
|
+
*/
|
|
2920
|
+
async poll() {
|
|
2921
|
+
await Promise.allSettled([...this.monitored.values()].map((swap) => this.pollSwap(swap)));
|
|
2922
|
+
}
|
|
2923
|
+
/**
|
|
2924
|
+
* Resolve once this swap's PAYOUT is decided — which for onchain-send is
|
|
2925
|
+
* the L1 claim, not the end of the record's life: once `claimTxid` is set
|
|
2926
|
+
* the trader has the coins it swapped for, and what remains is the manager
|
|
2927
|
+
* watching the Arkade lockup close. That holds however the record is
|
|
2928
|
+
* labelled afterwards, `needs_counterparty` included. Lightning-send has no
|
|
2929
|
+
* such split and resolves at `settled`/`refunded`, and so does lightning
|
|
2930
|
+
* receive — see {@link isPayoutDecided} for why its own claim txid does not
|
|
2931
|
+
* decide it.
|
|
2932
|
+
*
|
|
2933
|
+
* Rejects only on `failed`. `refunded` resolves: on a send leg a refund is
|
|
2934
|
+
* an outcome the caller asked this manager to drive, not an exception. On a
|
|
2935
|
+
* receive leg it is the swap being lost, which is still an answer and not
|
|
2936
|
+
* an error — read `state`, do not infer success from resolution.
|
|
2937
|
+
*/
|
|
2938
|
+
async waitForSwapCompletion(rfqId) {
|
|
2939
|
+
const swap = this.monitored.get(rfqId) ?? this.finished.get(rfqId);
|
|
2940
|
+
if (!swap) throw new Error(`swap ${rfqId} is not monitored`);
|
|
2941
|
+
if (swap.state === "failed") throw new Error(swap.failure ?? `swap ${rfqId} failed`);
|
|
2942
|
+
if (isPayoutDecided(swap)) return outcomeOf(swap);
|
|
2943
|
+
return new Promise((resolve, reject) => {
|
|
2944
|
+
const set = this.waiters.get(rfqId) ?? /* @__PURE__ */ new Set();
|
|
2945
|
+
set.add({ resolve, reject });
|
|
2946
|
+
this.waiters.set(rfqId, set);
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
2950
|
+
track(swap) {
|
|
2951
|
+
this.monitored.set(swap.rfqId, swap);
|
|
2952
|
+
this.byLockupScript.set(import_base11.hex.encode(swap.lockupPkScript), swap);
|
|
2953
|
+
}
|
|
2954
|
+
/** Drops the swap from BOTH indexes. The event index is the one that stops
|
|
2955
|
+
* a late event finding a swap that is gone; `pollSwap`'s own
|
|
2956
|
+
* `monitored` check would also catch it, and deliberately still does —
|
|
2957
|
+
* either alone is sufficient, which is what keeps a future change to one of
|
|
2958
|
+
* them from silently re-driving a cancelled swap. */
|
|
2959
|
+
untrack(rfqId) {
|
|
2960
|
+
const swap = this.monitored.get(rfqId);
|
|
2961
|
+
if (swap) this.byLockupScript.delete(import_base11.hex.encode(swap.lockupPkScript));
|
|
2962
|
+
this.monitored.delete(rfqId);
|
|
2963
|
+
this.refundRefused.delete(rfqId);
|
|
2964
|
+
this.lastClaimError.delete(rfqId);
|
|
2965
|
+
this.claimedOutpoints.delete(rfqId);
|
|
2966
|
+
}
|
|
2967
|
+
/**
|
|
2968
|
+
* Turn the indexer's push into an extra reason to run a pass — and nothing
|
|
2969
|
+
* more.
|
|
2970
|
+
*
|
|
2971
|
+
* **This is deliberately not a source of truth.** An event names a script;
|
|
2972
|
+
* the reaction is to run the ordinary pass for the swap at that script, and
|
|
2973
|
+
* that pass re-reads the lockup through {@link readLockupFate} exactly as
|
|
2974
|
+
* the timer's pass does. So an event that is missed, duplicated, reordered
|
|
2975
|
+
* or outright FORGED can only cost or save latency — it can never change
|
|
2976
|
+
* what this manager believes about a swap, and it can never on its own
|
|
2977
|
+
* cause a claim or a refund. That property is what makes it safe to bolt a
|
|
2978
|
+
* best-effort stream onto a money path, and it must survive any future
|
|
2979
|
+
* change here: the moment an event is BELIEVED rather than merely acted on,
|
|
2980
|
+
* a relay outage becomes a correctness problem instead of a latency one.
|
|
2981
|
+
*
|
|
2982
|
+
* The timer stays armed regardless, and is the failsafe. Every deadline
|
|
2983
|
+
* that moves money — `refundLocktime`, the L1 claim window — is an absolute
|
|
2984
|
+
* timelock that passes whether or not a single event ever arrives.
|
|
2985
|
+
*/
|
|
2986
|
+
subscribe() {
|
|
2987
|
+
if (!this.deps.contracts || this.unsubscribeContracts) return;
|
|
2988
|
+
this.unsubscribeContracts = this.deps.contracts.onContractEvent((event) => {
|
|
2989
|
+
if (event.type === "connection_reset") {
|
|
2990
|
+
void this.poll().catch(() => {
|
|
2991
|
+
});
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
const swap = this.byLockupScript.get(event.contractScript);
|
|
2995
|
+
if (!swap) return;
|
|
2996
|
+
void this.pollSwap(swap).catch(() => {
|
|
2997
|
+
});
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
/**
|
|
3001
|
+
* Register this swap's lockup with the wallet's contract manager, once.
|
|
3002
|
+
*
|
|
3003
|
+
* The backstop, not the primary site: `requestLightningSend` /
|
|
3004
|
+
* `requestOnchainSend` register before the caller can fund, so this covers
|
|
3005
|
+
* swaps whose records predate that — and costs nothing when it does not,
|
|
3006
|
+
* since `createContract` is first-writer-wins.
|
|
3007
|
+
*
|
|
3008
|
+
* Best-effort by design: a failure here is reported and retried on the next
|
|
3009
|
+
* pass, and never aborts the pass it is part of. Registration buys latency
|
|
3010
|
+
* and puts the lockup in the wallet's contract set; it decides nothing. The
|
|
3011
|
+
* money path below it reads the indexer directly and is gated on timelocks
|
|
3012
|
+
* that a missing contract row has no bearing on, so failing the pass over
|
|
3013
|
+
* this would trade a real deadline for a bookkeeping one.
|
|
3014
|
+
*/
|
|
3015
|
+
async ensureRegistered(swap) {
|
|
3016
|
+
const contracts = this.deps.contracts;
|
|
3017
|
+
if (!contracts) return;
|
|
3018
|
+
if (this.registered.has(swap.rfqId)) return;
|
|
3019
|
+
const lockup = swap.lockup;
|
|
3020
|
+
if (!lockup) {
|
|
3021
|
+
try {
|
|
3022
|
+
const [existing] = await contracts.getContracts({
|
|
3023
|
+
script: import_base11.hex.encode(swap.lockupPkScript)
|
|
3024
|
+
});
|
|
3025
|
+
if (existing) {
|
|
3026
|
+
this.registered.set(swap.rfqId, true);
|
|
3027
|
+
return;
|
|
3028
|
+
}
|
|
3029
|
+
} catch (error) {
|
|
3030
|
+
this.emitFailed(swap, error);
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
this.registered.set(swap.rfqId, false);
|
|
3034
|
+
this.emitFailed(
|
|
3035
|
+
swap,
|
|
3036
|
+
new Error(
|
|
3037
|
+
`swap ${swap.rfqId} carries no lockup script and has no contract row, so it cannot be registered \u2014 pass \`lockup\` to subscribe instead of polling`
|
|
3038
|
+
)
|
|
3039
|
+
);
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
const script = import_base11.hex.encode(lockup.script.pkScript);
|
|
3043
|
+
if (script !== import_base11.hex.encode(swap.lockupPkScript)) {
|
|
3044
|
+
this.registered.set(swap.rfqId, false);
|
|
3045
|
+
this.emitFailed(
|
|
3046
|
+
swap,
|
|
3047
|
+
new Error(
|
|
3048
|
+
`swap ${swap.rfqId} lockup script ${script} does not match its lockupPkScript ${import_base11.hex.encode(swap.lockupPkScript)}`
|
|
3049
|
+
)
|
|
3050
|
+
);
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
try {
|
|
3054
|
+
await registerLockupContract(contracts, lockup.script, lockup.address);
|
|
3055
|
+
this.registered.set(swap.rfqId, true);
|
|
3056
|
+
} catch (error) {
|
|
3057
|
+
this.emitFailed(swap, error);
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
/** Stop watching a finished swap's lockup. Retained, not deleted: the row
|
|
3061
|
+
* is what keeps the lockup's own VTXOs annotatable and its history
|
|
3062
|
+
* readable, while `retained` is what drops it from the subscription and
|
|
3063
|
+
* the poll — a settled swap that stayed watched would cost the wallet a
|
|
3064
|
+
* script for its whole life. Best-effort — the swap is over either way. */
|
|
3065
|
+
retireContract(swap) {
|
|
3066
|
+
if (!this.deps.contracts || !this.registered.get(swap.rfqId)) return;
|
|
3067
|
+
void this.deps.contracts.setContractWatchState(import_base11.hex.encode(swap.lockupPkScript), "retained").catch((error) => this.emitFailed(swap, error));
|
|
3068
|
+
}
|
|
3069
|
+
arm() {
|
|
3070
|
+
if (!this.running) return;
|
|
3071
|
+
if (this.timer) clearTimeout(this.timer);
|
|
3072
|
+
this.timer = setTimeout(() => {
|
|
3073
|
+
this.timer = null;
|
|
3074
|
+
void this.poll().then(() => this.arm());
|
|
3075
|
+
}, this.config.pollIntervalMs);
|
|
3076
|
+
}
|
|
3077
|
+
async pollSwap(swap) {
|
|
3078
|
+
if (this.inProgress.has(swap.rfqId)) return;
|
|
3079
|
+
if (!this.monitored.has(swap.rfqId)) return;
|
|
3080
|
+
this.inProgress.add(swap.rfqId);
|
|
3081
|
+
try {
|
|
3082
|
+
await this.runPass(swap);
|
|
3083
|
+
} finally {
|
|
3084
|
+
const persisted = this.dirty.has(swap.rfqId) ? await this.save(swap) : true;
|
|
3085
|
+
if (persisted) {
|
|
3086
|
+
this.dirty.delete(swap.rfqId);
|
|
3087
|
+
this.settleWaiters(swap);
|
|
3088
|
+
if (isRfqSwapTerminal(swap.state)) this.finalize(swap);
|
|
3089
|
+
}
|
|
3090
|
+
this.inProgress.delete(swap.rfqId);
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
async runPass(swap) {
|
|
3094
|
+
await this.ensureRegistered(swap);
|
|
3095
|
+
let fate;
|
|
3096
|
+
try {
|
|
3097
|
+
fate = await readLockupFate(this.deps.indexer, {
|
|
3098
|
+
swapPkScript: swap.lockupPkScript,
|
|
3099
|
+
paymentHash: swap.paymentHash
|
|
3100
|
+
});
|
|
3101
|
+
} catch {
|
|
3102
|
+
fate = { fate: "unknown" };
|
|
3103
|
+
}
|
|
3104
|
+
if (fate.fate === "claimed" || fate.fate === "returned") {
|
|
3105
|
+
this.setState(swap, fate.fate === "claimed" ? "settled" : "refunded");
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
if (swap.kind === "lightning_receive") return this.driveReceiveClaim(swap);
|
|
3109
|
+
if (swap.kind === "onchain_send" && swap.state !== "claimed") {
|
|
3110
|
+
if (await this.driveOnchain(swap) === "handled") return;
|
|
3111
|
+
}
|
|
3112
|
+
await this.driveArkadeRefund(swap);
|
|
3113
|
+
}
|
|
3114
|
+
/**
|
|
3115
|
+
* The receive leg's whole state machine: claim the solver-funded lockup
|
|
3116
|
+
* while the window is open, and recognise the shapes in which it can be
|
|
3117
|
+
* lost.
|
|
3118
|
+
*
|
|
3119
|
+
* **The window closes at `refundLocktime`, on wall clock, with no margin.**
|
|
3120
|
+
* Both halves of that are deliberate. It closes there because publishing
|
|
3121
|
+
* `P` into the solver's live refund window risks losing the race and
|
|
3122
|
+
* handing over the preimage anyway — the hazard `ONCHAIN_CLAIM_MARGIN_SECONDS`
|
|
3123
|
+
* guards on the L1 side. It takes no margin because the two situations are
|
|
3124
|
+
* not alike: that one budgets for confirmation depth, while this claim is an
|
|
3125
|
+
* offchain spend that lands in seconds. Wall clock is already the
|
|
3126
|
+
* conservative reading — the solver's leaf is a CLTV, which matures against
|
|
3127
|
+
* median-time-past, and MTP trails wall clock — so the real window extends
|
|
3128
|
+
* PAST this deadline rather than ending before it. Every second of margin
|
|
3129
|
+
* subtracted here is a second of live claim window given away for nothing.
|
|
3130
|
+
*
|
|
3131
|
+
* **The trader has no move after it.** Nothing here can take the lockup
|
|
3132
|
+
* back, so once the window shuts the swap is the solver's to resolve and
|
|
3133
|
+
* this manager's job is to watch it happen and then stop.
|
|
3134
|
+
*/
|
|
3135
|
+
async driveReceiveClaim(swap) {
|
|
3136
|
+
const now = this.config.now();
|
|
3137
|
+
if (now < swap.refundLocktime) {
|
|
3138
|
+
let vtxos;
|
|
3139
|
+
try {
|
|
3140
|
+
vtxos = await findLockupVtxos(this.deps.indexer, swap.lockupPkScript);
|
|
3141
|
+
} catch (error) {
|
|
3142
|
+
this.emitFailed(swap, error);
|
|
3143
|
+
return;
|
|
3144
|
+
}
|
|
3145
|
+
return this.claimIfFunded(swap, vtxos);
|
|
3146
|
+
}
|
|
3147
|
+
if (now < swap.refundLocktime + REFUND_MTP_LAG_SECONDS) {
|
|
3148
|
+
if (swap.claimArkTxid) return;
|
|
3149
|
+
return this.block(
|
|
3150
|
+
swap,
|
|
3151
|
+
"the claim window closed with the lockup unclaimed \u2014 only the solver can act now"
|
|
3152
|
+
);
|
|
3153
|
+
}
|
|
3154
|
+
const failure = this.lastClaimError.get(swap.rfqId);
|
|
3155
|
+
if (failure && !swap.claimArkTxid) {
|
|
3156
|
+
return this.fail(swap, new Error(failure));
|
|
3157
|
+
}
|
|
3158
|
+
this.setState(swap, "refunded");
|
|
3159
|
+
}
|
|
3160
|
+
/**
|
|
3161
|
+
* Claim what the solver funded, once it is enough.
|
|
3162
|
+
*
|
|
3163
|
+
* The value gate here decides WHEN to act. `pushClaim`'s decides whether
|
|
3164
|
+
* `P` is published, and runs with nothing between it and the signature —
|
|
3165
|
+
* the check that matters is the inner one, and this is not a reason to
|
|
3166
|
+
* relax it.
|
|
3167
|
+
*/
|
|
3168
|
+
async claimIfFunded(swap, vtxos) {
|
|
3169
|
+
if (vtxos.length === 0) return this.unblock(swap);
|
|
3170
|
+
const partiallyClaimed = swap.claimArkTxid !== void 0;
|
|
3171
|
+
if (partiallyClaimed && !this.hasUnclaimedOutpoint(swap.rfqId, vtxos)) {
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
if (!partiallyClaimed) {
|
|
3175
|
+
if (!Number.isFinite(swap.expectedAmount)) {
|
|
3176
|
+
return this.block(
|
|
3177
|
+
swap,
|
|
3178
|
+
`expectedAmount is not a finite number (${String(swap.expectedAmount)}), so the funded value cannot be checked \u2014 refusing to publish the preimage`
|
|
3179
|
+
);
|
|
3180
|
+
}
|
|
3181
|
+
const locked = vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0);
|
|
3182
|
+
if (!Number.isFinite(locked) || locked < swap.expectedAmount) {
|
|
3183
|
+
return this.block(
|
|
3184
|
+
swap,
|
|
3185
|
+
`lockup holds ${locked} sats, below the agreed ${swap.expectedAmount} \u2014 refusing to publish the preimage`
|
|
3186
|
+
);
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
if (!this.callbacks) {
|
|
3190
|
+
return this.block(
|
|
3191
|
+
swap,
|
|
3192
|
+
"no callbacks are wired, so this wallet cannot claim the lockup"
|
|
3193
|
+
);
|
|
3194
|
+
}
|
|
3195
|
+
this.setState(swap, "claimable");
|
|
3196
|
+
if (!this.config.enableAutoActions) return;
|
|
3197
|
+
try {
|
|
3198
|
+
const { arkTxid } = await this.callbacks.claimLockup(swap, vtxos, { partiallyClaimed });
|
|
3199
|
+
this.lastClaimError.delete(swap.rfqId);
|
|
3200
|
+
this.rememberClaimed(swap.rfqId, vtxos);
|
|
3201
|
+
swap.claimArkTxid = arkTxid;
|
|
3202
|
+
this.touch(swap);
|
|
3203
|
+
this.setState(swap, "claimed");
|
|
3204
|
+
this.emitAction(swap, "claimLockup");
|
|
3205
|
+
} catch (error) {
|
|
3206
|
+
this.lastClaimError.set(swap.rfqId, errorMessage(error));
|
|
3207
|
+
this.emitFailed(swap, error);
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
/** `handled` ends the pass; `continue` falls through to the refund gate. */
|
|
3211
|
+
async driveOnchain(swap) {
|
|
3212
|
+
if (!this.deps.chain) {
|
|
3213
|
+
this.fail(
|
|
3214
|
+
swap,
|
|
3215
|
+
new Error(
|
|
3216
|
+
"onchain-send swap monitored without a ChainSource \u2014 the L1 fill cannot be seen or claimed"
|
|
3217
|
+
)
|
|
3218
|
+
);
|
|
3219
|
+
return "handled";
|
|
3220
|
+
}
|
|
3221
|
+
let phase;
|
|
3222
|
+
try {
|
|
3223
|
+
phase = await classifyOnchainHtlc(this.deps.chain, {
|
|
3224
|
+
htlc: swap.htlc,
|
|
3225
|
+
minConfirmations: swap.minConfirmations,
|
|
3226
|
+
funding: swap.funding
|
|
3227
|
+
});
|
|
3228
|
+
} catch {
|
|
3229
|
+
return "continue";
|
|
3230
|
+
}
|
|
3231
|
+
if ("utxo" in phase && !swap.funding) {
|
|
3232
|
+
swap.funding = { txid: phase.utxo.txid, vout: phase.utxo.vout };
|
|
3233
|
+
this.touch(swap);
|
|
3234
|
+
}
|
|
3235
|
+
const action = nextOnchainAction({
|
|
3236
|
+
phase,
|
|
3237
|
+
htlcLocktime: swap.htlc.refundLocktime,
|
|
3238
|
+
now: this.config.now()
|
|
3239
|
+
});
|
|
3240
|
+
if (action === "claim" && phase.phase === "claimable") {
|
|
3241
|
+
if (swap.claimTxid) return "continue";
|
|
3242
|
+
this.setOnchainState(swap, "claimable");
|
|
3243
|
+
if (!this.config.enableAutoActions || !this.callbacks) return "handled";
|
|
3244
|
+
try {
|
|
3245
|
+
const { txid } = await this.callbacks.claimOnchain(swap, phase.utxo);
|
|
3246
|
+
swap.claimTxid = txid;
|
|
3247
|
+
this.setOnchainState(swap, "claimed");
|
|
3248
|
+
this.emitAction(swap, "claimOnchain");
|
|
3249
|
+
} catch (error) {
|
|
3250
|
+
this.emitFailed(swap, error);
|
|
3251
|
+
}
|
|
3252
|
+
return "handled";
|
|
3253
|
+
}
|
|
3254
|
+
if (action === "claimed" && phase.phase === "claimed") {
|
|
3255
|
+
if (!swap.claimTxid) {
|
|
3256
|
+
swap.claimTxid = phase.txid;
|
|
3257
|
+
this.touch(swap);
|
|
3258
|
+
}
|
|
3259
|
+
this.setOnchainState(swap, "claimed");
|
|
3260
|
+
}
|
|
3261
|
+
return "continue";
|
|
3262
|
+
}
|
|
3263
|
+
async driveArkadeRefund(swap) {
|
|
3264
|
+
const now = this.config.now();
|
|
3265
|
+
const refusal = await this.probeRefusal(swap);
|
|
3266
|
+
if (refusal) {
|
|
3267
|
+
const claiming = swap.state === "claimable" || swap.state === "claimed";
|
|
3268
|
+
if (now < swap.refundLocktime && claiming) return;
|
|
3269
|
+
return this.block(swap, refusal);
|
|
3270
|
+
}
|
|
3271
|
+
if (this.refundRefused.has(swap.rfqId)) {
|
|
3272
|
+
if (!this.callbacks?.canRefundArkade) return;
|
|
3273
|
+
this.refundRefused.delete(swap.rfqId);
|
|
3274
|
+
}
|
|
3275
|
+
if (now < swap.refundLocktime) return this.unblock(swap);
|
|
3276
|
+
if (!this.config.enableAutoActions || !this.callbacks) {
|
|
3277
|
+
return this.block(
|
|
3278
|
+
swap,
|
|
3279
|
+
this.callbacks ? "automatic actions are disabled, so this wallet will not push the refund" : "no callbacks are wired, so this wallet cannot push the refund"
|
|
3280
|
+
);
|
|
3281
|
+
}
|
|
3282
|
+
this.unblock(swap);
|
|
3283
|
+
try {
|
|
3284
|
+
const pushed = await this.callbacks.refundArkade(swap);
|
|
3285
|
+
if (pushed) {
|
|
3286
|
+
swap.refundArkTxid = pushed.arkTxid;
|
|
3287
|
+
this.touch(swap);
|
|
3288
|
+
}
|
|
3289
|
+
this.setState(swap, "refunded");
|
|
3290
|
+
this.emitAction(swap, "refundArkade");
|
|
3291
|
+
} catch (error) {
|
|
3292
|
+
if (error instanceof RefundNotLocallyPossibleError) {
|
|
3293
|
+
this.refundRefused.add(swap.rfqId);
|
|
3294
|
+
return this.block(swap, error.message);
|
|
3295
|
+
}
|
|
3296
|
+
this.emitFailed(swap, error);
|
|
3297
|
+
if (now >= swap.refundLocktime + REFUND_MTP_LAG_SECONDS) {
|
|
3298
|
+
swap.failure = errorMessage(error);
|
|
3299
|
+
this.setState(swap, "failed");
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
/** Whether any of these outputs has never been handed to the claim
|
|
3304
|
+
* callback — the only reason to claim a lockup a second time. */
|
|
3305
|
+
hasUnclaimedOutpoint(rfqId, vtxos) {
|
|
3306
|
+
const claimed = this.claimedOutpoints.get(rfqId);
|
|
3307
|
+
if (!claimed) return true;
|
|
3308
|
+
return vtxos.some((vtxo) => !claimed.has(outpointKey(vtxo)));
|
|
3309
|
+
}
|
|
3310
|
+
rememberClaimed(rfqId, vtxos) {
|
|
3311
|
+
const claimed = this.claimedOutpoints.get(rfqId) ?? /* @__PURE__ */ new Set();
|
|
3312
|
+
for (const vtxo of vtxos) claimed.add(outpointKey(vtxo));
|
|
3313
|
+
this.claimedOutpoints.set(rfqId, claimed);
|
|
3314
|
+
}
|
|
3315
|
+
/**
|
|
3316
|
+
* L1 progress, which past the refund window must not overwrite a refusal.
|
|
3317
|
+
* The two halves are independent — a claimed fill says nothing about
|
|
3318
|
+
* whether this wallet can take the Arkade lockup back — and `claimed` is
|
|
3319
|
+
* re-asserted from chain on every pass, so without this a blocked swap
|
|
3320
|
+
* would flip between the two states forever. The claim itself always runs;
|
|
3321
|
+
* only the label defers, and only once the refund is the live half.
|
|
3322
|
+
*/
|
|
3323
|
+
setOnchainState(swap, state) {
|
|
3324
|
+
if (swap.state === "needs_counterparty" && this.config.now() >= swap.refundLocktime) return;
|
|
3325
|
+
this.setState(swap, state);
|
|
3326
|
+
}
|
|
3327
|
+
/** The probe's refusal reason, or `undefined` when a local refund is
|
|
3328
|
+
* possible as far as anyone here can tell. A probe that throws is treated
|
|
3329
|
+
* as a refusal: a capability check that cannot answer is not a yes. */
|
|
3330
|
+
async probeRefusal(swap) {
|
|
3331
|
+
const probe = this.callbacks?.canRefundArkade;
|
|
3332
|
+
if (!probe) return void 0;
|
|
3333
|
+
try {
|
|
3334
|
+
const answer = await probe(swap);
|
|
3335
|
+
return answer.ok ? void 0 : answer.reason;
|
|
3336
|
+
} catch (error) {
|
|
3337
|
+
return errorMessage(error);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
/** Report that no local refund will happen, without ending the swap. */
|
|
3341
|
+
block(swap, reason) {
|
|
3342
|
+
if (swap.blockedReason !== reason) {
|
|
3343
|
+
swap.blockedReason = reason;
|
|
3344
|
+
this.touch(swap);
|
|
3345
|
+
}
|
|
3346
|
+
this.setState(swap, "needs_counterparty");
|
|
3347
|
+
}
|
|
3348
|
+
/** The way back out, taken as soon as the swap becomes actionable again.
|
|
3349
|
+
* Back to what the record can prove, not to `pending` unconditionally: a
|
|
3350
|
+
* swap that already made its claim has a txid for it, and reporting that
|
|
3351
|
+
* swap as `pending` would un-say something true. */
|
|
3352
|
+
unblock(swap) {
|
|
3353
|
+
if (swap.state !== "needs_counterparty") return;
|
|
3354
|
+
this.setState(swap, traderClaimTxid(swap) ? "claimed" : "pending");
|
|
3355
|
+
}
|
|
3356
|
+
touch(swap) {
|
|
3357
|
+
swap.updatedAt = this.config.now();
|
|
3358
|
+
this.dirty.add(swap.rfqId);
|
|
3359
|
+
}
|
|
3360
|
+
setState(swap, state) {
|
|
3361
|
+
if (swap.state === state) return;
|
|
3362
|
+
const previous = swap.state;
|
|
3363
|
+
if (previous === "needs_counterparty") delete swap.blockedReason;
|
|
3364
|
+
swap.state = state;
|
|
3365
|
+
this.touch(swap);
|
|
3366
|
+
notify(this.swapUpdateListeners, (listener) => listener(swap, previous));
|
|
3367
|
+
}
|
|
3368
|
+
/** Terminal failure. The `onSwapFailed` emission is left to
|
|
3369
|
+
* {@link finalize}, so this does not double-report. */
|
|
3370
|
+
fail(swap, error) {
|
|
3371
|
+
swap.failure = error.message;
|
|
3372
|
+
this.setState(swap, "failed");
|
|
3373
|
+
}
|
|
3374
|
+
emitFailed(swap, error) {
|
|
3375
|
+
const wrapped = error instanceof Error ? error : new Error(errorMessage(error));
|
|
3376
|
+
notify(this.swapFailedListeners, (listener) => listener(swap, wrapped));
|
|
3377
|
+
}
|
|
3378
|
+
emitAction(swap, action) {
|
|
3379
|
+
notify(this.actionExecutedListeners, (listener) => listener(swap, action));
|
|
3380
|
+
}
|
|
3381
|
+
/** Whether the record is now persisted — false only when `saveSwap` threw. */
|
|
3382
|
+
async save(swap) {
|
|
3383
|
+
if (!this.callbacks) return true;
|
|
3384
|
+
try {
|
|
3385
|
+
await this.callbacks.saveSwap(swap);
|
|
3386
|
+
return true;
|
|
3387
|
+
} catch (error) {
|
|
3388
|
+
this.emitFailed(swap, error);
|
|
3389
|
+
return false;
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
/**
|
|
3393
|
+
* Drop a terminal swap from monitoring and report it exactly once.
|
|
3394
|
+
*
|
|
3395
|
+
* `onSwapCompleted` and `onSwapFailed` are mutually exclusive here, unlike
|
|
3396
|
+
* Boltz's manager, which fires completion for every swap that leaves
|
|
3397
|
+
* monitoring including the failed ones — a listener named "completed" that
|
|
3398
|
+
* also fires on failure is a trap worth not inheriting.
|
|
3399
|
+
*/
|
|
3400
|
+
finalize(swap) {
|
|
3401
|
+
if (!this.monitored.has(swap.rfqId)) return;
|
|
3402
|
+
this.untrack(swap.rfqId);
|
|
3403
|
+
this.finished.set(swap.rfqId, swap);
|
|
3404
|
+
this.retireContract(swap);
|
|
3405
|
+
if (swap.state === "failed") {
|
|
3406
|
+
notify(
|
|
3407
|
+
this.swapFailedListeners,
|
|
3408
|
+
(listener) => listener(swap, new Error(swap.failure ?? `swap ${swap.rfqId} failed`))
|
|
3409
|
+
);
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
notify(this.swapCompletedListeners, (listener) => listener(swap));
|
|
3413
|
+
}
|
|
3414
|
+
settleWaiters(swap) {
|
|
3415
|
+
const waiting = this.waiters.get(swap.rfqId);
|
|
3416
|
+
if (!waiting) return;
|
|
3417
|
+
if (swap.state === "failed") {
|
|
3418
|
+
const error = new Error(swap.failure ?? `swap ${swap.rfqId} failed`);
|
|
3419
|
+
for (const waiter of waiting) waiter.reject(error);
|
|
3420
|
+
} else if (isPayoutDecided(swap)) {
|
|
3421
|
+
const outcome = outcomeOf(swap);
|
|
3422
|
+
for (const waiter of waiting) waiter.resolve(outcome);
|
|
3423
|
+
} else {
|
|
3424
|
+
return;
|
|
3425
|
+
}
|
|
3426
|
+
this.waiters.delete(swap.rfqId);
|
|
3427
|
+
}
|
|
3428
|
+
};
|
|
3429
|
+
var traderClaimTxid = (swap) => {
|
|
3430
|
+
switch (swap.kind) {
|
|
3431
|
+
case "onchain_send":
|
|
3432
|
+
return swap.claimTxid;
|
|
3433
|
+
case "lightning_receive":
|
|
3434
|
+
return swap.claimArkTxid;
|
|
3435
|
+
default:
|
|
3436
|
+
return void 0;
|
|
3437
|
+
}
|
|
3438
|
+
};
|
|
3439
|
+
var isPayoutDecided = (swap) => swap.state === "settled" || swap.state === "refunded" || swap.kind === "onchain_send" && swap.claimTxid !== void 0;
|
|
3440
|
+
var outcomeOf = (swap) => {
|
|
3441
|
+
const lostReceive = swap.kind === "lightning_receive" && swap.state === "refunded";
|
|
3442
|
+
return {
|
|
3443
|
+
state: swap.state,
|
|
3444
|
+
txid: lostReceive ? swap.refundArkTxid : traderClaimTxid(swap) ?? swap.refundArkTxid
|
|
3445
|
+
};
|
|
3446
|
+
};
|
|
3447
|
+
var errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
3448
|
+
var outpointKey = (vtxo) => `${vtxo.txid}:${vtxo.vout}`;
|
|
3449
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3450
|
+
0 && (module.exports = {
|
|
3451
|
+
ARKADE_ASSET,
|
|
3452
|
+
ARKADE_BTC,
|
|
3453
|
+
AddressMismatch,
|
|
3454
|
+
BTC_ASSET_ID,
|
|
3455
|
+
InMemoryAssetSwapRepository,
|
|
3456
|
+
IndexedDbAssetSwapRepository,
|
|
3457
|
+
LIGHTNING_BTC,
|
|
3458
|
+
LIGHTNING_RECEIVE_PAIR,
|
|
3459
|
+
LIGHTNING_SEND_PAIR,
|
|
3460
|
+
LockupAmountMismatchError,
|
|
3461
|
+
LockupNeedsRecoveryError,
|
|
3462
|
+
LockupRegistrationFailed,
|
|
3463
|
+
MAX_MIN_CONFIRMATIONS,
|
|
3464
|
+
MIN_CLAIM_WINDOW_SECONDS,
|
|
3465
|
+
MIN_HEADROOM_SECONDS,
|
|
3466
|
+
OFFER_PACKET_TYPE,
|
|
3467
|
+
ONCHAIN_BTC,
|
|
3468
|
+
ONCHAIN_CLAIM_MARGIN_SECONDS,
|
|
3469
|
+
ONCHAIN_DUST_SATS,
|
|
3470
|
+
ONCHAIN_ORDER_MARGIN_SECONDS,
|
|
3471
|
+
ONCHAIN_RECEIVE_PAIR,
|
|
3472
|
+
ONCHAIN_SECONDS_PER_BLOCK,
|
|
3473
|
+
ONCHAIN_SEND_PAIR,
|
|
3474
|
+
QUOTE_OPTIONS,
|
|
3475
|
+
REFUND_MTP_LAG_SECONDS,
|
|
3476
|
+
RFQ_PREIMAGE_TAG,
|
|
3477
|
+
RFQ_RESOLVED_STATES,
|
|
3478
|
+
RFQ_SWAP_TERMINAL_STATES,
|
|
3479
|
+
RFQ_TERMINAL_STATES,
|
|
3480
|
+
RefundNotLocallyPossibleError,
|
|
3481
|
+
RfqSwapManager,
|
|
3482
|
+
SWAP_LOCKUP_CONTRACT_KIND,
|
|
3483
|
+
SWAP_LOCKUP_CONTRACT_LABEL,
|
|
3484
|
+
SWAP_LOCKUP_CONTRACT_TYPE,
|
|
3485
|
+
SwapRefusal,
|
|
3486
|
+
addAssetSwap,
|
|
3487
|
+
adoptSwapDescriptor,
|
|
3488
|
+
arkadeSwapRequest,
|
|
3489
|
+
assertFundable,
|
|
3490
|
+
assertReceivable,
|
|
3491
|
+
awaitLockupFunding,
|
|
3492
|
+
awaitOnchainFill,
|
|
3493
|
+
awaitRfqResolution,
|
|
3494
|
+
buildHtlcClaim,
|
|
3495
|
+
buildHtlcRefund,
|
|
3496
|
+
buildPreimageMessage,
|
|
3497
|
+
cancelOffer,
|
|
3498
|
+
claimOnchainFill,
|
|
3499
|
+
claimReceiveLockup,
|
|
3500
|
+
classifyDepositSpend,
|
|
3501
|
+
classifyOnchainHtlc,
|
|
3502
|
+
classifySpend,
|
|
3503
|
+
createOffer,
|
|
3504
|
+
decodeOffer,
|
|
3505
|
+
deriveLightningReceive,
|
|
3506
|
+
deriveOnchainReceive,
|
|
3507
|
+
deriveOnchainSend,
|
|
3508
|
+
derivePreimage,
|
|
3509
|
+
deriveSwapSecrets,
|
|
3510
|
+
discoverMarkets,
|
|
3511
|
+
encodeOffer,
|
|
3512
|
+
extractPreimage,
|
|
3513
|
+
findLockupVtxos,
|
|
3514
|
+
findMarket,
|
|
3515
|
+
getAssetSwaps,
|
|
3516
|
+
getAssetSwapsOrThrow,
|
|
3517
|
+
httpTransport,
|
|
3518
|
+
isDeterministicSigner,
|
|
3519
|
+
isRfqSwapTerminal,
|
|
3520
|
+
isRfqTerminal,
|
|
3521
|
+
lightningReceiveRequest,
|
|
3522
|
+
lightningSendRequest,
|
|
3523
|
+
lightningSendVtxoScript,
|
|
3524
|
+
makeCachedFeedFetch,
|
|
3525
|
+
newPreimage,
|
|
3526
|
+
newRfqId,
|
|
3527
|
+
nextOnchainAction,
|
|
3528
|
+
offerTermsFromQuote,
|
|
3529
|
+
offerVtxoScript,
|
|
3530
|
+
onchainHtlcScript,
|
|
3531
|
+
onchainReceiveRequest,
|
|
3532
|
+
onchainSendRequest,
|
|
3533
|
+
paymentHashOf,
|
|
3534
|
+
preimageForRfqSecrets,
|
|
3535
|
+
pushClaim,
|
|
3536
|
+
pushRefundWithoutReceiver,
|
|
3537
|
+
randomSwapSecrets,
|
|
3538
|
+
readLockupFate,
|
|
3539
|
+
receiveVtxoScript,
|
|
3540
|
+
refundIfUnresolved,
|
|
3541
|
+
registerLockupContract,
|
|
3542
|
+
relayTransport,
|
|
3543
|
+
requestLightningReceive,
|
|
3544
|
+
requestLightningSend,
|
|
3545
|
+
requestOnchainReceive,
|
|
3546
|
+
requestOnchainSend,
|
|
3547
|
+
restoreAssetSwaps,
|
|
3548
|
+
retireSettledOfferContracts,
|
|
3549
|
+
rfqPair,
|
|
3550
|
+
rfqSecretsOfRecord,
|
|
3551
|
+
rfqSecretsToRecord,
|
|
3552
|
+
sealClaimPacket,
|
|
3553
|
+
senderIdentityForRfqSecrets,
|
|
3554
|
+
senderIdentityForSwapRecord,
|
|
3555
|
+
senderPubkeyForRfqSecrets,
|
|
3556
|
+
spendTxidsOf,
|
|
3557
|
+
spendUpdate,
|
|
3558
|
+
swapPrograms,
|
|
3559
|
+
unilateralClaimDelay,
|
|
3560
|
+
unilateralRefundDelay,
|
|
3561
|
+
unilateralRefundWithoutReceiverDelay,
|
|
3562
|
+
updateAssetSwap,
|
|
3563
|
+
updateAssetSwapBestEffort,
|
|
3564
|
+
validatePlan,
|
|
3565
|
+
verifyLockupAddress,
|
|
3566
|
+
verifyReceiveInvoice,
|
|
3567
|
+
watchOfferSwaps
|
|
3568
|
+
});
|