@openreceive/node 0.2.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/LICENSE +21 -0
- package/README.md +7 -0
- package/bin/openreceive.mjs +22 -0
- package/dist/chunk-6HALOJYF.js +1482 -0
- package/dist/cli.d.ts +63 -0
- package/dist/cli.js +1009 -0
- package/dist/index.d.ts +774 -0
- package/dist/index.js +2510 -0
- package/package.json +62 -0
|
@@ -0,0 +1,1482 @@
|
|
|
1
|
+
// src/swap/assets.ts
|
|
2
|
+
var OPENRECEIVE_SWAP_PAY_IN_ASSETS = [
|
|
3
|
+
"SOL_SOL",
|
|
4
|
+
"USDT_TRON",
|
|
5
|
+
"USDT_SOL",
|
|
6
|
+
"USDC_SOL",
|
|
7
|
+
"ETH_ETH",
|
|
8
|
+
"USDT_ETH",
|
|
9
|
+
"USDC_ETH"
|
|
10
|
+
];
|
|
11
|
+
var ASSET_INFO = {
|
|
12
|
+
SOL_SOL: {
|
|
13
|
+
pay_in_asset: "SOL_SOL",
|
|
14
|
+
label: "SOL",
|
|
15
|
+
network_label: "Solana",
|
|
16
|
+
coin: "SOL",
|
|
17
|
+
network: "SOL"
|
|
18
|
+
},
|
|
19
|
+
USDT_TRON: {
|
|
20
|
+
pay_in_asset: "USDT_TRON",
|
|
21
|
+
label: "USDT",
|
|
22
|
+
network_label: "Tron",
|
|
23
|
+
coin: "USDT",
|
|
24
|
+
network: "TRX"
|
|
25
|
+
},
|
|
26
|
+
USDT_SOL: {
|
|
27
|
+
pay_in_asset: "USDT_SOL",
|
|
28
|
+
label: "USDT",
|
|
29
|
+
network_label: "Solana",
|
|
30
|
+
coin: "USDT",
|
|
31
|
+
network: "SOL"
|
|
32
|
+
},
|
|
33
|
+
USDC_SOL: {
|
|
34
|
+
pay_in_asset: "USDC_SOL",
|
|
35
|
+
label: "USDC",
|
|
36
|
+
network_label: "Solana",
|
|
37
|
+
coin: "USDC",
|
|
38
|
+
network: "SOL"
|
|
39
|
+
},
|
|
40
|
+
ETH_ETH: {
|
|
41
|
+
pay_in_asset: "ETH_ETH",
|
|
42
|
+
label: "ETH",
|
|
43
|
+
network_label: "Ethereum",
|
|
44
|
+
coin: "ETH",
|
|
45
|
+
network: "ETH"
|
|
46
|
+
},
|
|
47
|
+
USDT_ETH: {
|
|
48
|
+
pay_in_asset: "USDT_ETH",
|
|
49
|
+
label: "USDT",
|
|
50
|
+
network_label: "Ethereum",
|
|
51
|
+
coin: "USDT",
|
|
52
|
+
network: "ETH"
|
|
53
|
+
},
|
|
54
|
+
USDC_ETH: {
|
|
55
|
+
pay_in_asset: "USDC_ETH",
|
|
56
|
+
label: "USDC",
|
|
57
|
+
network_label: "Ethereum",
|
|
58
|
+
coin: "USDC",
|
|
59
|
+
network: "ETH"
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
function isSwapPayInAsset(value) {
|
|
63
|
+
return typeof value === "string" && OPENRECEIVE_SWAP_PAY_IN_ASSETS.includes(value);
|
|
64
|
+
}
|
|
65
|
+
function getSwapAssetInfo(payInAsset) {
|
|
66
|
+
return ASSET_INFO[payInAsset];
|
|
67
|
+
}
|
|
68
|
+
function listSwapAssetInfo() {
|
|
69
|
+
return OPENRECEIVE_SWAP_PAY_IN_ASSETS.map((asset) => ASSET_INFO[asset]);
|
|
70
|
+
}
|
|
71
|
+
function normalizeSwapNetwork(value) {
|
|
72
|
+
return value.toUpperCase().replace(/[^A-Z0-9]+/g, "");
|
|
73
|
+
}
|
|
74
|
+
function swapNetworkMatches(expected, actual) {
|
|
75
|
+
const normalizedExpected = normalizeSwapNetwork(expected);
|
|
76
|
+
const normalizedActual = normalizeSwapNetwork(actual);
|
|
77
|
+
if (normalizedActual === normalizedExpected) return true;
|
|
78
|
+
if (normalizedExpected === "TRX") {
|
|
79
|
+
return normalizedActual === "TRON" || normalizedActual === "TRC20" || normalizedActual === "TRC";
|
|
80
|
+
}
|
|
81
|
+
if (normalizedExpected === "ETH") {
|
|
82
|
+
return normalizedActual === "ETHEREUM" || normalizedActual === "ERC20" || normalizedActual === "ERC";
|
|
83
|
+
}
|
|
84
|
+
if (normalizedExpected === "SOL") {
|
|
85
|
+
return normalizedActual === "SOLANA";
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
function isLightningNetwork(value) {
|
|
90
|
+
const normalized = normalizeSwapNetwork(value);
|
|
91
|
+
return normalized === "LN" || normalized === "LIGHTNING" || normalized === "LIGHTNINGNETWORK" || normalized === "BTCLN" || normalized === "BTCBOLT11";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/swap/fixedfloat.ts
|
|
95
|
+
import { ceilDiv as ceilDiv2, OPENRECEIVE_SATS_PER_BTC as OPENRECEIVE_SATS_PER_BTC2, recordOrEmpty as recordOrEmpty4, unixSeconds } from "@openreceive/core";
|
|
96
|
+
|
|
97
|
+
// src/swap/fixedfloat-currencies.ts
|
|
98
|
+
import { recordOrEmpty as recordOrEmpty2 } from "@openreceive/core";
|
|
99
|
+
|
|
100
|
+
// src/swap/fixedfloat-fields.ts
|
|
101
|
+
import { recordOrEmpty } from "@openreceive/core";
|
|
102
|
+
function optionalCoercedString(value) {
|
|
103
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
104
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
105
|
+
return void 0;
|
|
106
|
+
}
|
|
107
|
+
function optionalStringField(record, field) {
|
|
108
|
+
if (record === void 0) return void 0;
|
|
109
|
+
return optionalCoercedString(record[field]);
|
|
110
|
+
}
|
|
111
|
+
function optionalNestedString(value, path) {
|
|
112
|
+
let current = value;
|
|
113
|
+
for (const key of path) {
|
|
114
|
+
current = recordOrEmpty(current)[key];
|
|
115
|
+
}
|
|
116
|
+
return optionalCoercedString(current);
|
|
117
|
+
}
|
|
118
|
+
function optionalStringArrayField(record, field) {
|
|
119
|
+
if (record === void 0) return [];
|
|
120
|
+
const value = record[field];
|
|
121
|
+
if (Array.isArray(value)) {
|
|
122
|
+
return value.flatMap((item) => {
|
|
123
|
+
const string2 = optionalCoercedString(item);
|
|
124
|
+
return string2 === void 0 ? [] : [string2];
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const string = optionalCoercedString(value);
|
|
128
|
+
return string === void 0 ? [] : [string];
|
|
129
|
+
}
|
|
130
|
+
function requiredString(value, field) {
|
|
131
|
+
const string = optionalCoercedString(value);
|
|
132
|
+
if (string === void 0) {
|
|
133
|
+
throw new Error(`FixedFloat response missing ${field}.`);
|
|
134
|
+
}
|
|
135
|
+
return string;
|
|
136
|
+
}
|
|
137
|
+
function readUnixSeconds(value) {
|
|
138
|
+
const numeric = typeof value === "string" ? Number(value) : value;
|
|
139
|
+
return typeof numeric === "number" && Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : void 0;
|
|
140
|
+
}
|
|
141
|
+
function readDecimalAmountString(value, label) {
|
|
142
|
+
if (value === void 0) return void 0;
|
|
143
|
+
if (!/^[0-9]+(\.[0-9]+)?$/.test(value)) {
|
|
144
|
+
throw new Error(`FixedFloat ${label} is not a decimal amount.`);
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/swap/fixedfloat-rates.ts
|
|
150
|
+
import {
|
|
151
|
+
ceilDiv,
|
|
152
|
+
decimalScaleFactor,
|
|
153
|
+
formatDecimal,
|
|
154
|
+
OPENRECEIVE_SATS_PER_BTC,
|
|
155
|
+
parseDecimal
|
|
156
|
+
} from "@openreceive/core";
|
|
157
|
+
var PAY_AMOUNT_FRACTION_DIGITS = 8;
|
|
158
|
+
function fixedFloatRatesPairKey(from, to) {
|
|
159
|
+
return `${from.trim().toUpperCase()}:${to.trim().toUpperCase()}`;
|
|
160
|
+
}
|
|
161
|
+
function fixedFloatRatesXmlPath(rateType = "fixed") {
|
|
162
|
+
return `/rates/${rateType}.xml`;
|
|
163
|
+
}
|
|
164
|
+
async function fetchFixedFloatRatesIndex(input) {
|
|
165
|
+
const rateType = input.rateType ?? "fixed";
|
|
166
|
+
const url = `${input.baseUrl.replace(/\/+$/, "")}${fixedFloatRatesXmlPath(rateType)}`;
|
|
167
|
+
const timeoutMs = input.requestTimeoutMs ?? 1e4;
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
170
|
+
let response;
|
|
171
|
+
try {
|
|
172
|
+
response = await input.fetch(url, {
|
|
173
|
+
method: "GET",
|
|
174
|
+
redirect: "follow",
|
|
175
|
+
signal: controller.signal,
|
|
176
|
+
headers: { Accept: "application/xml, text/xml, */*" }
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const aborted = error instanceof Error && (error.name === "AbortError" || error.message.toLowerCase().includes("abort"));
|
|
180
|
+
throw new Error(
|
|
181
|
+
aborted ? `FixedFloat rates ${rateType}.xml request timed out.` : `FixedFloat rates ${rateType}.xml request failed before a response was received.`,
|
|
182
|
+
{ cause: error }
|
|
183
|
+
);
|
|
184
|
+
} finally {
|
|
185
|
+
clearTimeout(timeout);
|
|
186
|
+
}
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
throw new Error(`FixedFloat rates ${rateType}.xml failed with HTTP ${response.status}.`);
|
|
189
|
+
}
|
|
190
|
+
const xml = await response.text();
|
|
191
|
+
return {
|
|
192
|
+
fetched_at: input.now(),
|
|
193
|
+
// Provider dumps include thousands of non-LN market pairs; OpenReceive only
|
|
194
|
+
// ever pays out over Lightning, so drop everything else before caching.
|
|
195
|
+
pairs: retainFixedFloatLightningPayoutPairs(parseFixedFloatRatesXml(xml))
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function retainFixedFloatLightningPayoutPairs(pairs) {
|
|
199
|
+
const retained = {};
|
|
200
|
+
for (const [key, pair] of Object.entries(pairs)) {
|
|
201
|
+
if (isLightningNetwork(pair.to)) retained[key] = pair;
|
|
202
|
+
}
|
|
203
|
+
return retained;
|
|
204
|
+
}
|
|
205
|
+
function retainFixedFloatRatePairsForKeys(index, pairKeys) {
|
|
206
|
+
if (pairKeys.size === 0) {
|
|
207
|
+
return { fetched_at: index.fetched_at, pairs: {} };
|
|
208
|
+
}
|
|
209
|
+
const pairs = {};
|
|
210
|
+
for (const key of pairKeys) {
|
|
211
|
+
const pair = index.pairs[key];
|
|
212
|
+
if (pair !== void 0) pairs[key] = pair;
|
|
213
|
+
}
|
|
214
|
+
return { fetched_at: index.fetched_at, pairs };
|
|
215
|
+
}
|
|
216
|
+
function parseFixedFloatRatesXml(xml) {
|
|
217
|
+
const pairs = {};
|
|
218
|
+
for (const itemXml of matchTags(xml, "item")) {
|
|
219
|
+
const from = readTagText(itemXml, "from");
|
|
220
|
+
const to = readTagText(itemXml, "to");
|
|
221
|
+
const inAmount = readTagText(itemXml, "in");
|
|
222
|
+
const outAmount = readTagText(itemXml, "out");
|
|
223
|
+
const amount = readTagText(itemXml, "amount");
|
|
224
|
+
const minamount = readTagText(itemXml, "minamount");
|
|
225
|
+
const maxamount = readTagText(itemXml, "maxamount");
|
|
226
|
+
if (from === void 0 || to === void 0 || inAmount === void 0 || outAmount === void 0 || amount === void 0 || minamount === void 0 || maxamount === void 0) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const tofee = readTagText(itemXml, "tofee");
|
|
230
|
+
const pair = {
|
|
231
|
+
from: from.trim(),
|
|
232
|
+
to: to.trim(),
|
|
233
|
+
in: stripCurrencySuffix(inAmount),
|
|
234
|
+
out: stripCurrencySuffix(outAmount),
|
|
235
|
+
amount: stripCurrencySuffix(amount),
|
|
236
|
+
minamount: stripCurrencySuffix(minamount),
|
|
237
|
+
maxamount: stripCurrencySuffix(maxamount),
|
|
238
|
+
...tofee === void 0 ? {} : { tofee: tofee.trim() }
|
|
239
|
+
};
|
|
240
|
+
pairs[fixedFloatRatesPairKey(pair.from, pair.to)] = pair;
|
|
241
|
+
}
|
|
242
|
+
return pairs;
|
|
243
|
+
}
|
|
244
|
+
function serializeFixedFloatRatesIndex(index) {
|
|
245
|
+
return JSON.stringify({
|
|
246
|
+
fetched_at: index.fetched_at,
|
|
247
|
+
pairs: index.pairs
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function deserializeFixedFloatRatesIndex(value) {
|
|
251
|
+
const parsed = JSON.parse(value);
|
|
252
|
+
if (typeof parsed.fetched_at !== "number" || !Number.isSafeInteger(parsed.fetched_at) || parsed.pairs === null || typeof parsed.pairs !== "object" || Array.isArray(parsed.pairs)) {
|
|
253
|
+
throw new Error("Invalid FixedFloat rates cache blob.");
|
|
254
|
+
}
|
|
255
|
+
const pairs = {};
|
|
256
|
+
for (const [key, raw] of Object.entries(parsed.pairs)) {
|
|
257
|
+
const pair = readStoredPair(raw);
|
|
258
|
+
if (pair !== void 0) pairs[key] = pair;
|
|
259
|
+
}
|
|
260
|
+
return { fetched_at: parsed.fetched_at, pairs };
|
|
261
|
+
}
|
|
262
|
+
function quotePayAmountFromFixedFloatRate(input) {
|
|
263
|
+
if (!Number.isSafeInteger(input.invoiceAmountMsats) || input.invoiceAmountMsats <= 0) {
|
|
264
|
+
return void 0;
|
|
265
|
+
}
|
|
266
|
+
const rateIn = parsePositiveDecimal(input.pair.in);
|
|
267
|
+
const rateOut = parsePositiveDecimal(input.pair.out);
|
|
268
|
+
if (rateIn === void 0 || rateOut === void 0) return void 0;
|
|
269
|
+
const invoiceSats = ceilDiv(BigInt(input.invoiceAmountMsats), 1000n);
|
|
270
|
+
const tofeeSats = parseToFeeBtcSats(input.pair.tofee) ?? 0n;
|
|
271
|
+
const totalSats = invoiceSats + tofeeSats;
|
|
272
|
+
const payAt8Dp = ceilDiv(
|
|
273
|
+
totalSats * rateIn.units * decimalScaleFactor(rateOut.scale),
|
|
274
|
+
decimalScaleFactor(rateIn.scale) * rateOut.units
|
|
275
|
+
);
|
|
276
|
+
return formatPayAmount(payAt8Dp, PAY_AMOUNT_FRACTION_DIGITS, PAY_AMOUNT_FRACTION_DIGITS);
|
|
277
|
+
}
|
|
278
|
+
function invoiceLimitsFromFixedFloatRate(pair) {
|
|
279
|
+
const minimumPayAmount = pair.minamount;
|
|
280
|
+
const maximumPayAmount = pair.maxamount;
|
|
281
|
+
return {
|
|
282
|
+
minimum_pay_amount: minimumPayAmount,
|
|
283
|
+
maximum_pay_amount: maximumPayAmount,
|
|
284
|
+
...payAmountToInvoiceMsats(pair, minimumPayAmount, "ceil") === void 0 ? {} : {
|
|
285
|
+
minimum_invoice_amount_msats: payAmountToInvoiceMsats(pair, minimumPayAmount, "ceil")
|
|
286
|
+
},
|
|
287
|
+
...payAmountToInvoiceMsats(pair, maximumPayAmount, "floor") === void 0 ? {} : {
|
|
288
|
+
maximum_invoice_amount_msats: payAmountToInvoiceMsats(pair, maximumPayAmount, "floor")
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function compareFixedFloatDecimalAmounts(left, right) {
|
|
293
|
+
const a = parsePositiveDecimal(left);
|
|
294
|
+
const b = parsePositiveDecimal(right);
|
|
295
|
+
if (a === void 0 || b === void 0) return void 0;
|
|
296
|
+
const leftScaled = a.units * decimalScaleFactor(b.scale);
|
|
297
|
+
const rightScaled = b.units * decimalScaleFactor(a.scale);
|
|
298
|
+
if (leftScaled < rightScaled) return -1;
|
|
299
|
+
if (leftScaled > rightScaled) return 1;
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
function payAmountToInvoiceMsats(pair, payAmount, rounding) {
|
|
303
|
+
const pay = parsePositiveDecimal(payAmount);
|
|
304
|
+
const rateIn = parsePositiveDecimal(pair.in);
|
|
305
|
+
const rateOut = parsePositiveDecimal(pair.out);
|
|
306
|
+
if (pay === void 0 || rateIn === void 0 || rateOut === void 0) return void 0;
|
|
307
|
+
const numerator = pay.units * rateOut.units * OPENRECEIVE_SATS_PER_BTC * decimalScaleFactor(rateIn.scale);
|
|
308
|
+
const denominator = decimalScaleFactor(pay.scale) * decimalScaleFactor(rateOut.scale) * rateIn.units;
|
|
309
|
+
if (denominator <= 0n) return void 0;
|
|
310
|
+
const invoiceSats = rounding === "ceil" ? ceilDiv(numerator, denominator) : numerator / denominator;
|
|
311
|
+
if (invoiceSats <= 0n) return void 0;
|
|
312
|
+
const msats = invoiceSats * 1000n;
|
|
313
|
+
if (msats > BigInt(Number.MAX_SAFE_INTEGER)) return void 0;
|
|
314
|
+
return Number(msats);
|
|
315
|
+
}
|
|
316
|
+
function parseToFeeBtcSats(tofee) {
|
|
317
|
+
if (tofee === void 0) return void 0;
|
|
318
|
+
const match = tofee.trim().match(/^([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]+)?$/);
|
|
319
|
+
if (match === null) return void 0;
|
|
320
|
+
const amount = match[1];
|
|
321
|
+
const unit = (match[2] ?? "BTC").toUpperCase();
|
|
322
|
+
if (unit !== "BTC" && unit !== "BTCLN") return void 0;
|
|
323
|
+
const parsed = parsePositiveDecimal(amount);
|
|
324
|
+
if (parsed === void 0) return void 0;
|
|
325
|
+
return ceilDiv(parsed.units * OPENRECEIVE_SATS_PER_BTC, decimalScaleFactor(parsed.scale));
|
|
326
|
+
}
|
|
327
|
+
function parsePositiveDecimal(value) {
|
|
328
|
+
let parsed;
|
|
329
|
+
try {
|
|
330
|
+
parsed = parseDecimal(value);
|
|
331
|
+
} catch {
|
|
332
|
+
return void 0;
|
|
333
|
+
}
|
|
334
|
+
return parsed.units > 0n ? parsed : void 0;
|
|
335
|
+
}
|
|
336
|
+
function formatPayAmount(units, scale, maxFractionDigits) {
|
|
337
|
+
const target = decimalScaleFactor(maxFractionDigits);
|
|
338
|
+
const current = decimalScaleFactor(scale);
|
|
339
|
+
const rescaled = scale <= maxFractionDigits ? units * (target / current) : ceilDiv(units * target, current);
|
|
340
|
+
const text = formatDecimal(rescaled, maxFractionDigits);
|
|
341
|
+
return text.includes(".") ? text.replace(/0+$/, "").replace(/\.$/, "") : text;
|
|
342
|
+
}
|
|
343
|
+
function stripCurrencySuffix(value) {
|
|
344
|
+
const match = value.trim().match(/^([0-9]+(?:\.[0-9]+)?)/);
|
|
345
|
+
return match?.[1] ?? value.trim();
|
|
346
|
+
}
|
|
347
|
+
function matchTags(xml, tag) {
|
|
348
|
+
const pattern = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`, "gi");
|
|
349
|
+
const matches = [];
|
|
350
|
+
for (const match of xml.matchAll(pattern)) {
|
|
351
|
+
matches.push(match[1] ?? "");
|
|
352
|
+
}
|
|
353
|
+
return matches;
|
|
354
|
+
}
|
|
355
|
+
function readTagText(xml, tag) {
|
|
356
|
+
const pattern = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`, "i");
|
|
357
|
+
const match = xml.match(pattern);
|
|
358
|
+
if (match === null) return void 0;
|
|
359
|
+
const text = (match[1] ?? "").trim();
|
|
360
|
+
return text.length === 0 ? void 0 : text;
|
|
361
|
+
}
|
|
362
|
+
function readStoredPair(value) {
|
|
363
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
364
|
+
const record = value;
|
|
365
|
+
const from = typeof record.from === "string" ? record.from : void 0;
|
|
366
|
+
const to = typeof record.to === "string" ? record.to : void 0;
|
|
367
|
+
const inAmount = typeof record.in === "string" ? record.in : void 0;
|
|
368
|
+
const outAmount = typeof record.out === "string" ? record.out : void 0;
|
|
369
|
+
const amount = typeof record.amount === "string" ? record.amount : void 0;
|
|
370
|
+
const minamount = typeof record.minamount === "string" ? record.minamount : void 0;
|
|
371
|
+
const maxamount = typeof record.maxamount === "string" ? record.maxamount : void 0;
|
|
372
|
+
if (from === void 0 || to === void 0 || inAmount === void 0 || outAmount === void 0 || amount === void 0 || minamount === void 0 || maxamount === void 0) {
|
|
373
|
+
return void 0;
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
from,
|
|
377
|
+
to,
|
|
378
|
+
in: inAmount,
|
|
379
|
+
out: outAmount,
|
|
380
|
+
amount,
|
|
381
|
+
minamount,
|
|
382
|
+
maxamount,
|
|
383
|
+
...typeof record.tofee === "string" ? { tofee: record.tofee } : {}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/swap/fixedfloat-currencies.ts
|
|
388
|
+
function resolveFixedFloatCurrencies(data, input) {
|
|
389
|
+
const currencies = readFixedFloatCurrencies(data);
|
|
390
|
+
const payIn = /* @__PURE__ */ new Map();
|
|
391
|
+
for (const asset of listSwapAssetInfo()) {
|
|
392
|
+
const found = currencies.find(
|
|
393
|
+
(currency) => currency.coin.toUpperCase() === asset.coin && swapNetworkMatches(asset.network, currency.network) && // /ccies recv=false means FixedFloat will not accept deposits for this
|
|
394
|
+
// currency — omit it from the catalog rather than failing at /create.
|
|
395
|
+
currency.recv !== false
|
|
396
|
+
);
|
|
397
|
+
if (found !== void 0) payIn.set(asset.pay_in_asset, found);
|
|
398
|
+
}
|
|
399
|
+
const lightningCurrency = input.lightningCcy === void 0 ? currencies.find(
|
|
400
|
+
(currency) => currency.coin.toUpperCase() === "BTC" && isLightningNetwork(currency.network) && // Payout side must be sendable to the merchant's bolt11.
|
|
401
|
+
currency.send !== false
|
|
402
|
+
) : currencies.find(
|
|
403
|
+
(currency) => currency.code === input.lightningCcy && currency.send !== false
|
|
404
|
+
);
|
|
405
|
+
if (lightningCurrency === void 0) {
|
|
406
|
+
throw new Error("FixedFloat /ccies did not include a BTC Lightning payout currency.");
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
fetched_at: input.fetchedAt,
|
|
410
|
+
pay_in: payIn,
|
|
411
|
+
lightning: lightningCurrency
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function serializeFixedFloatCurrencyResolution(resolution) {
|
|
415
|
+
return JSON.stringify({
|
|
416
|
+
fetched_at: resolution.fetched_at,
|
|
417
|
+
pay_in: Array.from(resolution.pay_in.entries()),
|
|
418
|
+
lightning: resolution.lightning
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
function deserializeFixedFloatCurrencyResolution(value) {
|
|
422
|
+
const parsed = JSON.parse(value);
|
|
423
|
+
return {
|
|
424
|
+
fetched_at: parsed.fetched_at,
|
|
425
|
+
pay_in: new Map(parsed.pay_in),
|
|
426
|
+
lightning: parsed.lightning
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function requiredFixedFloatCurrency(resolution, payInAsset) {
|
|
430
|
+
const currency = resolution.pay_in.get(payInAsset);
|
|
431
|
+
if (currency === void 0) {
|
|
432
|
+
const label = getSwapAssetInfo(payInAsset).pay_in_asset;
|
|
433
|
+
throw new Error(`FixedFloat does not currently support ${label}.`);
|
|
434
|
+
}
|
|
435
|
+
return currency.code;
|
|
436
|
+
}
|
|
437
|
+
function fixedFloatRatePairKeys(resolution) {
|
|
438
|
+
const keys = /* @__PURE__ */ new Set();
|
|
439
|
+
for (const currency of resolution.pay_in.values()) {
|
|
440
|
+
keys.add(fixedFloatRatesPairKey(currency.code, resolution.lightning.code));
|
|
441
|
+
}
|
|
442
|
+
return keys;
|
|
443
|
+
}
|
|
444
|
+
function readFixedFloatCurrencies(data) {
|
|
445
|
+
const record = recordOrEmpty2(data);
|
|
446
|
+
const items = Array.isArray(data) ? data : Array.isArray(record.ccies) ? record.ccies : Array.isArray(record.currencies) ? record.currencies : [];
|
|
447
|
+
const currencies = [];
|
|
448
|
+
for (const item of items) {
|
|
449
|
+
const record2 = recordOrEmpty2(item);
|
|
450
|
+
const code = optionalStringField(record2, "code") ?? optionalStringField(record2, "ticker");
|
|
451
|
+
const coin = optionalStringField(record2, "coin") ?? optionalStringField(record2, "currency") ?? optionalStringField(record2, "symbol");
|
|
452
|
+
const network = optionalStringField(record2, "network") ?? optionalStringField(record2, "chain") ?? optionalStringField(record2, "networkName") ?? optionalStringField(record2, "name");
|
|
453
|
+
if (code !== void 0 && coin !== void 0 && network !== void 0) {
|
|
454
|
+
currencies.push({
|
|
455
|
+
code,
|
|
456
|
+
coin: coin.toUpperCase(),
|
|
457
|
+
network,
|
|
458
|
+
...typeof record2.recv === "boolean" ? { recv: record2.recv } : {},
|
|
459
|
+
...typeof record2.send === "boolean" ? { send: record2.send } : {}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return currencies;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// src/swap/fixedfloat-orders.ts
|
|
467
|
+
import { recordOrEmpty as recordOrEmpty3 } from "@openreceive/core";
|
|
468
|
+
function normalizeFixedFloatOrder(data, input) {
|
|
469
|
+
const fields = extractFixedFloatOrderFields(recordOrEmpty3(data), input);
|
|
470
|
+
const { attention, attention_reason } = fields.status;
|
|
471
|
+
return {
|
|
472
|
+
provider: input.provider,
|
|
473
|
+
provider_order_id: fields.providerOrderId,
|
|
474
|
+
provider_token: fields.providerToken,
|
|
475
|
+
pay_in_asset: input.payInAsset,
|
|
476
|
+
deposit_address: fields.depositAddress,
|
|
477
|
+
...fields.depositMemo === void 0 ? {} : { deposit_memo: fields.depositMemo },
|
|
478
|
+
deposit_amount: fields.depositAmount,
|
|
479
|
+
expires_at: fields.expiresAt,
|
|
480
|
+
state: fields.status.state,
|
|
481
|
+
...fields.depositTxId === void 0 ? {} : { deposit_tx_id: fields.depositTxId },
|
|
482
|
+
...fields.payoutTxId === void 0 ? {} : { payout_tx_id: fields.payoutTxId },
|
|
483
|
+
...fields.refundTxId === void 0 ? {} : { refund_tx_id: fields.refundTxId },
|
|
484
|
+
...attention === void 0 ? {} : { attention },
|
|
485
|
+
...attention_reason === void 0 ? {} : { attention_reason },
|
|
486
|
+
...fields.refundReason === void 0 ? {} : { refund_reason: fields.refundReason },
|
|
487
|
+
...fields.depositReceivedAmount === void 0 ? {} : { deposit_received_amount: fields.depositReceivedAmount },
|
|
488
|
+
...fields.refundAmount === void 0 ? {} : { refund_amount: fields.refundAmount },
|
|
489
|
+
...fields.emergencyRepeat === void 0 ? {} : { emergency_repeat: fields.emergencyRepeat },
|
|
490
|
+
...fields.fee === void 0 ? {} : { fee: fields.fee },
|
|
491
|
+
raw: data
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function readFixedFloatOrderFee(record) {
|
|
495
|
+
const payInFiat = optionalNestedString(record, ["from", "usd"]);
|
|
496
|
+
const payoutFiat = optionalNestedString(record, ["to", "usd"]);
|
|
497
|
+
if (payInFiat === void 0 || payoutFiat === void 0) return void 0;
|
|
498
|
+
return { currency: "USD", pay_in_fiat: payInFiat, payout_fiat: payoutFiat };
|
|
499
|
+
}
|
|
500
|
+
function requiredOrderField(record, field, fallback, label) {
|
|
501
|
+
return optionalStringField(record, field) ?? fallback ?? requiredString(record[field], label);
|
|
502
|
+
}
|
|
503
|
+
function requiredExpiresAt(expiresAt) {
|
|
504
|
+
if (expiresAt === void 0) {
|
|
505
|
+
throw new Error("FixedFloat order is missing time.expiration.");
|
|
506
|
+
}
|
|
507
|
+
return expiresAt;
|
|
508
|
+
}
|
|
509
|
+
function persistedStatus(fallback) {
|
|
510
|
+
return {
|
|
511
|
+
state: fallback.state,
|
|
512
|
+
...fallback.attention === void 0 ? {} : { attention: fallback.attention },
|
|
513
|
+
...fallback.attention_reason === void 0 ? {} : { attention_reason: fallback.attention_reason },
|
|
514
|
+
...fallback.refund_reason === void 0 ? {} : { refund_reason: fallback.refund_reason }
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
function extractFixedFloatOrderFields(record, input) {
|
|
518
|
+
const fallback = input.fallback;
|
|
519
|
+
const from = recordOrEmpty3(record.from);
|
|
520
|
+
const emergency = recordOrEmpty3(record.emergency);
|
|
521
|
+
const refundTxId = optionalNestedString(record, ["back", "tx", "id"]) ?? optionalNestedString(record, ["refund", "tx", "id"]) ?? fallback?.refund_tx_id;
|
|
522
|
+
const rawStatus = optionalStringField(record, "status");
|
|
523
|
+
const status = rawStatus === void 0 && fallback !== void 0 ? persistedStatus(fallback) : normalizeFixedFloatStatus(rawStatus ?? "NEW", emergency, refundTxId);
|
|
524
|
+
return {
|
|
525
|
+
status,
|
|
526
|
+
depositAddress: requiredOrderField(from, "address", fallback?.deposit_address, "from.address"),
|
|
527
|
+
refundTxId,
|
|
528
|
+
providerOrderId: requiredOrderField(record, "id", fallback?.provider_order_id, "id"),
|
|
529
|
+
providerToken: requiredOrderField(record, "token", fallback?.provider_token, "token"),
|
|
530
|
+
depositAmount: requiredOrderField(from, "amount", fallback?.deposit_amount, "from.amount"),
|
|
531
|
+
// No invented deadline: the provider states the expiry, and on a thin poll
|
|
532
|
+
// body the one we already persisted stands. A create body without either is
|
|
533
|
+
// a provider contract break, not something to paper over with now + 10min.
|
|
534
|
+
expiresAt: requiredExpiresAt(
|
|
535
|
+
readUnixSeconds(recordOrEmpty3(record.time).expiration) ?? fallback?.expires_at
|
|
536
|
+
),
|
|
537
|
+
depositMemo: optionalStringField(from, "tag") ?? fallback?.deposit_memo,
|
|
538
|
+
depositTxId: optionalNestedString(record, ["from", "tx", "id"]) ?? fallback?.deposit_tx_id,
|
|
539
|
+
payoutTxId: optionalNestedString(record, ["to", "tx", "id"]) ?? fallback?.payout_tx_id,
|
|
540
|
+
depositReceivedAmount: readDecimalAmountString(
|
|
541
|
+
optionalNestedString(record, ["from", "tx", "amount"]),
|
|
542
|
+
"from.tx.amount"
|
|
543
|
+
) ?? fallback?.deposit_received_amount,
|
|
544
|
+
refundAmount: readDecimalAmountString(optionalNestedString(record, ["back", "amount"]), "back.amount") ?? fallback?.refund_amount,
|
|
545
|
+
refundReason: status.refund_reason ?? (isRefundPathState(status.state) ? fallback?.refund_reason : void 0),
|
|
546
|
+
emergencyRepeat: readEmergencyRepeat(emergency) ?? fallback?.emergency_repeat,
|
|
547
|
+
fee: readFixedFloatOrderFee(record) ?? fallback?.fee
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function normalizeFixedFloatStatus(status, emergency, refundTxId) {
|
|
551
|
+
const normalized = status.toUpperCase();
|
|
552
|
+
if (refundTxId !== void 0 && (normalized === "DONE" || normalized === "FINISHED")) {
|
|
553
|
+
return { state: "refunded" };
|
|
554
|
+
}
|
|
555
|
+
if (normalized === "NEW") return { state: "awaiting_deposit" };
|
|
556
|
+
if (normalized === "PENDING") return { state: "confirming" };
|
|
557
|
+
if (normalized === "EXCHANGE") return { state: "exchanging" };
|
|
558
|
+
if (normalized === "WITHDRAW") return { state: "paying_invoice" };
|
|
559
|
+
if (normalized === "DONE") return { state: "completed" };
|
|
560
|
+
if (normalized === "EXPIRED") return { state: "expired" };
|
|
561
|
+
if (normalized === "EMERGENCY") {
|
|
562
|
+
const choice = optionalStringField(emergency, "choice")?.toUpperCase();
|
|
563
|
+
const emergencyStatuses = optionalStringArrayField(emergency, "status").map(
|
|
564
|
+
(item) => item.toUpperCase()
|
|
565
|
+
);
|
|
566
|
+
const refundReason = refundReasonFromEmergencyStatuses(emergencyStatuses);
|
|
567
|
+
if (choice === "REFUND" && refundTxId !== void 0) {
|
|
568
|
+
return {
|
|
569
|
+
state: "refunded",
|
|
570
|
+
...refundReason === void 0 ? {} : { refund_reason: refundReason }
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
if (choice === "REFUND") {
|
|
574
|
+
return {
|
|
575
|
+
state: "refund_pending",
|
|
576
|
+
...refundReason === void 0 ? {} : { refund_reason: refundReason }
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
if (choice === "EXCHANGE") {
|
|
580
|
+
return {
|
|
581
|
+
state: "attention",
|
|
582
|
+
attention: true,
|
|
583
|
+
attention_reason: "provider_reported_emergency"
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
if (emergencyStatuses.includes("MORE") || emergencyStatuses.includes("OVER") || emergencyStatuses.includes("OVERPAID")) {
|
|
587
|
+
return {
|
|
588
|
+
state: "attention",
|
|
589
|
+
attention: true,
|
|
590
|
+
attention_reason: "provider_reported_emergency"
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
state: "refund_required",
|
|
595
|
+
...refundReason === void 0 ? {} : { refund_reason: refundReason }
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
if (normalized.includes("FAIL")) return { state: "failed" };
|
|
599
|
+
return { state: "attention", attention: true, attention_reason: "provider_status_unrecognized" };
|
|
600
|
+
}
|
|
601
|
+
function refundReasonFromEmergencyStatuses(statuses) {
|
|
602
|
+
const less = statuses.includes("LESS");
|
|
603
|
+
const expired = statuses.includes("EXPIRED");
|
|
604
|
+
if (less && expired) return "underpaid_and_late";
|
|
605
|
+
if (less) return "underpaid";
|
|
606
|
+
if (expired) return "late_deposit";
|
|
607
|
+
return void 0;
|
|
608
|
+
}
|
|
609
|
+
function isRefundPathState(state) {
|
|
610
|
+
return state === "refund_required" || state === "refund_pending" || state === "refunded";
|
|
611
|
+
}
|
|
612
|
+
function readEmergencyRepeat(emergency) {
|
|
613
|
+
if (emergency === void 0) return void 0;
|
|
614
|
+
const value = emergency.repeat;
|
|
615
|
+
if (typeof value === "boolean") return value;
|
|
616
|
+
if (value === 0 || value === "0") return false;
|
|
617
|
+
if (value === 1 || value === "1") return true;
|
|
618
|
+
return void 0;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/swap/fixedfloat-quote.ts
|
|
622
|
+
function fixedFloatQuoteFromRatePair(input) {
|
|
623
|
+
const { pair } = input;
|
|
624
|
+
if (pair === void 0) {
|
|
625
|
+
return {
|
|
626
|
+
pay_asset: input.payInAsset,
|
|
627
|
+
available: false,
|
|
628
|
+
unavailable_reason: "pair_temporarily_unavailable",
|
|
629
|
+
unavailable_message: fixedFloatAvailabilityMessage("pair_temporarily_unavailable"),
|
|
630
|
+
provider: input.provider
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const limits = invoiceLimitsFromFixedFloatRate(pair);
|
|
634
|
+
const payAmount = quotePayAmountFromFixedFloatRate({
|
|
635
|
+
pair,
|
|
636
|
+
invoiceAmountMsats: input.invoiceAmountMsats
|
|
637
|
+
});
|
|
638
|
+
if (payAmount === void 0) {
|
|
639
|
+
return {
|
|
640
|
+
pay_asset: input.payInAsset,
|
|
641
|
+
available: false,
|
|
642
|
+
unavailable_reason: "pair_temporarily_unavailable",
|
|
643
|
+
unavailable_message: fixedFloatAvailabilityMessage("pair_temporarily_unavailable"),
|
|
644
|
+
provider: input.provider,
|
|
645
|
+
...limits
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
const payBelowMin = compareFixedFloatDecimalAmounts(payAmount, limits.minimum_pay_amount) === -1;
|
|
649
|
+
const payAboveMax = compareFixedFloatDecimalAmounts(payAmount, limits.maximum_pay_amount) === 1;
|
|
650
|
+
const amountTooSmall = payBelowMin || limits.minimum_invoice_amount_msats !== void 0 && input.invoiceAmountMsats < limits.minimum_invoice_amount_msats;
|
|
651
|
+
const amountTooLarge = payAboveMax || limits.maximum_invoice_amount_msats !== void 0 && input.invoiceAmountMsats > limits.maximum_invoice_amount_msats;
|
|
652
|
+
if (amountTooSmall || amountTooLarge) {
|
|
653
|
+
const reason = amountTooSmall ? "amount_too_small" : "amount_too_large";
|
|
654
|
+
return {
|
|
655
|
+
pay_asset: input.payInAsset,
|
|
656
|
+
available: false,
|
|
657
|
+
unavailable_reason: reason,
|
|
658
|
+
unavailable_message: fixedFloatAvailabilityMessage(reason),
|
|
659
|
+
provider: input.provider,
|
|
660
|
+
...limits
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
pay_amount: payAmount,
|
|
665
|
+
pay_asset: input.payInAsset,
|
|
666
|
+
available: true,
|
|
667
|
+
provider: input.provider,
|
|
668
|
+
...limits
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function fixedFloatAvailabilityMessage(reason) {
|
|
672
|
+
if (reason === "amount_too_small") return "This invoice is below the provider minimum.";
|
|
673
|
+
if (reason === "amount_too_large") return "This invoice is above the provider maximum.";
|
|
674
|
+
if (reason === "provider_rate_limited") return "The swap provider is rate limited.";
|
|
675
|
+
if (reason === "provider_unreachable") return "The swap provider is temporarily unreachable.";
|
|
676
|
+
return "This payment route is temporarily unavailable.";
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/swap/fixedfloat-transport.ts
|
|
680
|
+
import { createHmac } from "crypto";
|
|
681
|
+
var FixedFloatApiError = class _FixedFloatApiError extends Error {
|
|
682
|
+
path;
|
|
683
|
+
kind;
|
|
684
|
+
status;
|
|
685
|
+
fixedFloatCode;
|
|
686
|
+
fixedFloatMessage;
|
|
687
|
+
constructor(input) {
|
|
688
|
+
super(input.message, input.cause === void 0 ? void 0 : { cause: input.cause });
|
|
689
|
+
this.name = "FixedFloatApiError";
|
|
690
|
+
this.path = input.path;
|
|
691
|
+
this.kind = input.kind;
|
|
692
|
+
this.status = input.status;
|
|
693
|
+
this.fixedFloatCode = input.fixedFloatCode;
|
|
694
|
+
this.fixedFloatMessage = input.fixedFloatMessage;
|
|
695
|
+
}
|
|
696
|
+
static fromFetchError(path, error) {
|
|
697
|
+
const aborted = error instanceof Error && (error.name === "AbortError" || error.message.toLowerCase().includes("abort"));
|
|
698
|
+
return new _FixedFloatApiError({
|
|
699
|
+
path,
|
|
700
|
+
kind: aborted ? "timeout" : "network",
|
|
701
|
+
message: aborted ? `FixedFloat ${path} request timed out.` : `FixedFloat ${path} request failed before a response was received.`,
|
|
702
|
+
cause: error
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
var FixedFloatTransport = class {
|
|
707
|
+
/** Already stripped of trailing slashes; also the origin of the XML rates GET. */
|
|
708
|
+
baseUrl;
|
|
709
|
+
fetcher;
|
|
710
|
+
requestTimeoutMs;
|
|
711
|
+
provider;
|
|
712
|
+
key;
|
|
713
|
+
secret;
|
|
714
|
+
apiRequestLogger;
|
|
715
|
+
apiResponseLogger;
|
|
716
|
+
weightBudget;
|
|
717
|
+
constructor(input) {
|
|
718
|
+
this.provider = input.provider;
|
|
719
|
+
this.key = input.key;
|
|
720
|
+
this.secret = input.secret;
|
|
721
|
+
this.baseUrl = input.baseUrl;
|
|
722
|
+
this.fetcher = input.fetch;
|
|
723
|
+
this.requestTimeoutMs = input.requestTimeoutMs;
|
|
724
|
+
}
|
|
725
|
+
attachApiRequestLogger(log) {
|
|
726
|
+
this.apiRequestLogger = log;
|
|
727
|
+
}
|
|
728
|
+
attachApiResponseLogger(log) {
|
|
729
|
+
this.apiResponseLogger = log;
|
|
730
|
+
}
|
|
731
|
+
attachWeightBudget(budget) {
|
|
732
|
+
this.weightBudget = budget;
|
|
733
|
+
}
|
|
734
|
+
async post(path, body) {
|
|
735
|
+
if (this.weightBudget !== void 0) {
|
|
736
|
+
await this.weightBudget.reserve(path);
|
|
737
|
+
}
|
|
738
|
+
const bodyString = JSON.stringify(body);
|
|
739
|
+
this.logApiRequest(path, body);
|
|
740
|
+
const controller = new AbortController();
|
|
741
|
+
const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
|
|
742
|
+
let response;
|
|
743
|
+
try {
|
|
744
|
+
response = await this.fetcher(`${this.baseUrl}/api/v2/${path}`, {
|
|
745
|
+
method: "POST",
|
|
746
|
+
headers: {
|
|
747
|
+
"Content-Type": "application/json; charset=UTF-8",
|
|
748
|
+
"X-API-KEY": this.key,
|
|
749
|
+
"X-API-SIGN": createHmac("sha256", this.secret).update(bodyString).digest("hex")
|
|
750
|
+
},
|
|
751
|
+
body: bodyString,
|
|
752
|
+
signal: controller.signal
|
|
753
|
+
});
|
|
754
|
+
} catch (error) {
|
|
755
|
+
const apiError = FixedFloatApiError.fromFetchError(path, error);
|
|
756
|
+
this.logApiResponse({
|
|
757
|
+
path,
|
|
758
|
+
status: 0,
|
|
759
|
+
ok: false,
|
|
760
|
+
msg: apiError.message
|
|
761
|
+
});
|
|
762
|
+
throw apiError;
|
|
763
|
+
} finally {
|
|
764
|
+
clearTimeout(timeout);
|
|
765
|
+
}
|
|
766
|
+
const text = await response.text();
|
|
767
|
+
let parsed;
|
|
768
|
+
try {
|
|
769
|
+
parsed = text.length === 0 ? {} : JSON.parse(text);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
this.logApiResponse({
|
|
772
|
+
path,
|
|
773
|
+
status: response.status,
|
|
774
|
+
ok: false,
|
|
775
|
+
msg: `FixedFloat ${path} returned invalid JSON.`
|
|
776
|
+
});
|
|
777
|
+
throw new FixedFloatApiError({
|
|
778
|
+
path,
|
|
779
|
+
kind: "invalid_json",
|
|
780
|
+
status: response.status,
|
|
781
|
+
message: `FixedFloat ${path} returned invalid JSON.`,
|
|
782
|
+
cause: error
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
this.logApiResponse({
|
|
786
|
+
path,
|
|
787
|
+
status: response.status,
|
|
788
|
+
ok: response.ok,
|
|
789
|
+
code: parsed.code,
|
|
790
|
+
msg: parsed.msg,
|
|
791
|
+
data: parsed.data
|
|
792
|
+
});
|
|
793
|
+
if (!response.ok) {
|
|
794
|
+
if (response.status === 429 && this.weightBudget !== void 0) {
|
|
795
|
+
await this.weightBudget.markRateLimited();
|
|
796
|
+
}
|
|
797
|
+
throw new FixedFloatApiError({
|
|
798
|
+
path,
|
|
799
|
+
kind: response.status === 429 ? "rate_limited" : "http",
|
|
800
|
+
status: response.status,
|
|
801
|
+
fixedFloatMessage: optionalCoercedString(parsed.msg),
|
|
802
|
+
message: formatFixedFloatApiErrorMessage(path, response.status, parsed.msg)
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
if (parsed.code !== 0) {
|
|
806
|
+
throw new FixedFloatApiError({
|
|
807
|
+
path,
|
|
808
|
+
kind: "api",
|
|
809
|
+
fixedFloatCode: parsed.code,
|
|
810
|
+
fixedFloatMessage: optionalCoercedString(parsed.msg),
|
|
811
|
+
message: typeof parsed.msg === "string" ? parsed.msg : `FixedFloat ${path} failed.`
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
return parsed.data;
|
|
815
|
+
}
|
|
816
|
+
logApiRequest(path, body = {}) {
|
|
817
|
+
this.apiRequestLogger?.({
|
|
818
|
+
provider: this.provider,
|
|
819
|
+
path,
|
|
820
|
+
body
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
logApiResponse(input) {
|
|
824
|
+
this.apiResponseLogger?.({
|
|
825
|
+
provider: this.provider,
|
|
826
|
+
path: input.path,
|
|
827
|
+
status: input.status,
|
|
828
|
+
ok: input.ok,
|
|
829
|
+
code: input.code,
|
|
830
|
+
msg: input.msg,
|
|
831
|
+
data: input.data
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
function formatFixedFloatApiErrorMessage(path, status, msg) {
|
|
836
|
+
const fixedFloatMessage = optionalCoercedString(msg);
|
|
837
|
+
return fixedFloatMessage === void 0 ? `FixedFloat ${path} failed with HTTP ${status}.` : `FixedFloat ${path} failed with HTTP ${status}: ${fixedFloatMessage}`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/swap/limits-cache.ts
|
|
841
|
+
var SWAP_LIMITS_MAX_STALE_SECONDS = 48 * 60 * 60;
|
|
842
|
+
var SWAP_LIMITS_REFRESH_CLAIM_SECONDS = 60;
|
|
843
|
+
function swapLimitsMetaKey(providerName) {
|
|
844
|
+
return `swap_limits:${providerName}`;
|
|
845
|
+
}
|
|
846
|
+
var TransientSwapCache = class {
|
|
847
|
+
#states = /* @__PURE__ */ new Map();
|
|
848
|
+
#inflight = /* @__PURE__ */ new Map();
|
|
849
|
+
#clock;
|
|
850
|
+
#warn;
|
|
851
|
+
constructor(clock, options = {}) {
|
|
852
|
+
this.#clock = clock;
|
|
853
|
+
this.#warn = options.warn ?? (() => {
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
async resolve(key, options) {
|
|
857
|
+
const now = this.#clock();
|
|
858
|
+
const state = this.#states.get(key);
|
|
859
|
+
if (state?.value !== void 0 && state.fetchedAt !== void 0 && now - state.fetchedAt < options.refreshSeconds) {
|
|
860
|
+
return options.deserialize(state.value);
|
|
861
|
+
}
|
|
862
|
+
const claimSeconds = options.claimSeconds ?? SWAP_LIMITS_REFRESH_CLAIM_SECONDS;
|
|
863
|
+
if (state?.failedAt !== void 0 && now - state.failedAt < claimSeconds) {
|
|
864
|
+
return this.#staleOrThrow(key, state, now, options);
|
|
865
|
+
}
|
|
866
|
+
const active = this.#inflight.get(key);
|
|
867
|
+
if (active !== void 0) return await active;
|
|
868
|
+
const refresh = this.#refresh(key, state, now, options);
|
|
869
|
+
this.#inflight.set(key, refresh);
|
|
870
|
+
try {
|
|
871
|
+
return await refresh;
|
|
872
|
+
} finally {
|
|
873
|
+
if (this.#inflight.get(key) === refresh) this.#inflight.delete(key);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
async #refresh(key, previous, now, options) {
|
|
877
|
+
try {
|
|
878
|
+
const value = await options.fetch();
|
|
879
|
+
this.#states.set(key, { value: options.serialize(value), fetchedAt: now });
|
|
880
|
+
return value;
|
|
881
|
+
} catch (error) {
|
|
882
|
+
const failed = {
|
|
883
|
+
...previous?.value === void 0 ? {} : { value: previous.value },
|
|
884
|
+
...previous?.fetchedAt === void 0 ? {} : { fetchedAt: previous.fetchedAt },
|
|
885
|
+
failedAt: now,
|
|
886
|
+
error: error instanceof Error ? error.message : String(error)
|
|
887
|
+
};
|
|
888
|
+
this.#states.set(key, failed);
|
|
889
|
+
return this.#staleOrThrow(key, failed, now, options, error);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
#staleOrThrow(key, state, now, options, cause) {
|
|
893
|
+
if (options.serveStaleOnFailure !== false && state.value !== void 0 && state.fetchedAt !== void 0 && now - state.fetchedAt < options.maxStaleSeconds) {
|
|
894
|
+
this.#warn("Serving stale swap provider data after refresh failed.", {
|
|
895
|
+
key,
|
|
896
|
+
error: state.error
|
|
897
|
+
});
|
|
898
|
+
return options.deserialize(state.value);
|
|
899
|
+
}
|
|
900
|
+
if (cause instanceof Error) throw cause;
|
|
901
|
+
throw new Error(state.error ?? "Swap provider cache refresh failed.");
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
// src/swap/rates-cache.ts
|
|
906
|
+
var SWAP_RATES_REFRESH_SECONDS = 15;
|
|
907
|
+
var SWAP_RATES_MAX_STALE_SECONDS = SWAP_RATES_REFRESH_SECONDS;
|
|
908
|
+
function swapRatesMetaKey(providerName, rateType = "fixed") {
|
|
909
|
+
return `swap_rates:${providerName}:${rateType}`;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// src/swap/fixedfloat.ts
|
|
913
|
+
var DEFAULT_FIXED_FLOAT_BASE_URL = "https://ff.io";
|
|
914
|
+
var DEFAULT_CCIES_CACHE_SECONDS = 24 * 60 * 60;
|
|
915
|
+
var DEFAULT_RATES_CACHE_SECONDS = SWAP_RATES_REFRESH_SECONDS;
|
|
916
|
+
var DEFAULT_FIXED_FLOAT_REQUEST_TIMEOUT_MS = 1e4;
|
|
917
|
+
var DEFAULT_FIXED_FLOAT_DEPOSIT_WINDOW_SECONDS = 10 * 60;
|
|
918
|
+
var DEFAULT_FIXED_FLOAT_SETTLEMENT_SLA_SECONDS = 15 * 60;
|
|
919
|
+
var DEFAULT_FIXED_FLOAT_INVOICE_EXPIRY_MARGIN_SECONDS = 5 * 60;
|
|
920
|
+
function fixedFloatProvider(options) {
|
|
921
|
+
return fixedFloatCompatibleSwapProvider({
|
|
922
|
+
...options,
|
|
923
|
+
id: "fixedfloat"
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
function fixedFloatCompatibleSwapProvider(options) {
|
|
927
|
+
return new FixedFloatProvider(options);
|
|
928
|
+
}
|
|
929
|
+
var FixedFloatProvider = class {
|
|
930
|
+
name;
|
|
931
|
+
lightningCcy;
|
|
932
|
+
now;
|
|
933
|
+
cacheSeconds;
|
|
934
|
+
ratesCacheSeconds;
|
|
935
|
+
invoiceExpirySecondsValue;
|
|
936
|
+
transport;
|
|
937
|
+
cache;
|
|
938
|
+
constructor(options) {
|
|
939
|
+
this.name = readFixedFloatCompatibleProviderId(options.id);
|
|
940
|
+
if (options.key.trim().length === 0) {
|
|
941
|
+
throw new TypeError("FixedFloat-compatible API key must not be empty.");
|
|
942
|
+
}
|
|
943
|
+
if (options.secret.trim().length === 0) {
|
|
944
|
+
throw new TypeError("FixedFloat-compatible API secret must not be empty.");
|
|
945
|
+
}
|
|
946
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
947
|
+
if (fetcher === void 0) {
|
|
948
|
+
throw new TypeError("FixedFloat-compatible provider requires fetch.");
|
|
949
|
+
}
|
|
950
|
+
const lightningCcy = options.lightningCcy?.trim();
|
|
951
|
+
this.lightningCcy = lightningCcy === void 0 || lightningCcy.length === 0 ? void 0 : lightningCcy;
|
|
952
|
+
this.now = options.now ?? unixSeconds;
|
|
953
|
+
this.cacheSeconds = options.cacheSeconds ?? DEFAULT_CCIES_CACHE_SECONDS;
|
|
954
|
+
this.ratesCacheSeconds = options.ratesCacheSeconds ?? DEFAULT_RATES_CACHE_SECONDS;
|
|
955
|
+
if (!Number.isSafeInteger(this.ratesCacheSeconds) || this.ratesCacheSeconds <= 0) {
|
|
956
|
+
throw new TypeError("FixedFloat ratesCacheSeconds must be a positive safe integer.");
|
|
957
|
+
}
|
|
958
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_FIXED_FLOAT_REQUEST_TIMEOUT_MS;
|
|
959
|
+
if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
|
960
|
+
throw new TypeError("FixedFloat requestTimeoutMs must be a positive safe integer.");
|
|
961
|
+
}
|
|
962
|
+
this.transport = new FixedFloatTransport({
|
|
963
|
+
provider: this.name,
|
|
964
|
+
key: options.key,
|
|
965
|
+
secret: options.secret,
|
|
966
|
+
baseUrl: (options.baseUrl ?? DEFAULT_FIXED_FLOAT_BASE_URL).replace(/\/+$/, ""),
|
|
967
|
+
fetch: fetcher,
|
|
968
|
+
requestTimeoutMs
|
|
969
|
+
});
|
|
970
|
+
const depositWindowSeconds = options.depositWindowSeconds ?? DEFAULT_FIXED_FLOAT_DEPOSIT_WINDOW_SECONDS;
|
|
971
|
+
const settlementSlaSeconds = options.settlementSlaSeconds ?? DEFAULT_FIXED_FLOAT_SETTLEMENT_SLA_SECONDS;
|
|
972
|
+
const invoiceExpiryMarginSeconds = options.invoiceExpiryMarginSeconds ?? DEFAULT_FIXED_FLOAT_INVOICE_EXPIRY_MARGIN_SECONDS;
|
|
973
|
+
for (const [name, value] of [
|
|
974
|
+
["FixedFloat depositWindowSeconds", depositWindowSeconds],
|
|
975
|
+
["FixedFloat settlementSlaSeconds", settlementSlaSeconds],
|
|
976
|
+
["FixedFloat invoiceExpiryMarginSeconds", invoiceExpiryMarginSeconds]
|
|
977
|
+
]) {
|
|
978
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
979
|
+
throw new TypeError(`${name} must be a non-negative safe integer.`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
const minimumInvoiceExpirySeconds = depositWindowSeconds + settlementSlaSeconds + invoiceExpiryMarginSeconds;
|
|
983
|
+
this.invoiceExpirySecondsValue = options.invoiceExpirySeconds ?? minimumInvoiceExpirySeconds;
|
|
984
|
+
if (!Number.isSafeInteger(this.invoiceExpirySecondsValue) || this.invoiceExpirySecondsValue < minimumInvoiceExpirySeconds) {
|
|
985
|
+
throw new TypeError(
|
|
986
|
+
`FixedFloat provider ${JSON.stringify(this.name)}: invoice_expiry_seconds (${this.invoiceExpirySecondsValue}) must be at least ${minimumInvoiceExpirySeconds} = deposit_window(${depositWindowSeconds}) + settlement_sla(${settlementSlaSeconds}) + margin(${invoiceExpiryMarginSeconds}). Omit invoice_expiry_seconds to auto-derive it, or raise it above that floor.`
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
attachSwapCache(cache) {
|
|
991
|
+
this.cache = cache;
|
|
992
|
+
}
|
|
993
|
+
attachApiRequestLogger(log) {
|
|
994
|
+
this.transport.attachApiRequestLogger(log);
|
|
995
|
+
}
|
|
996
|
+
attachApiResponseLogger(log) {
|
|
997
|
+
this.transport.attachApiResponseLogger(log);
|
|
998
|
+
}
|
|
999
|
+
attachWeightBudget(budget) {
|
|
1000
|
+
this.transport.attachWeightBudget(budget);
|
|
1001
|
+
}
|
|
1002
|
+
async supportedPayInAssets() {
|
|
1003
|
+
const resolution = await this.resolveCurrencies();
|
|
1004
|
+
return new Set(resolution.pay_in.keys());
|
|
1005
|
+
}
|
|
1006
|
+
async payInAssetCatalog() {
|
|
1007
|
+
const resolution = await this.resolveCurrencies();
|
|
1008
|
+
const rates = await this.resolveRatesIndex(resolution);
|
|
1009
|
+
return Array.from(resolution.pay_in.entries(), ([payInAsset, currency]) => {
|
|
1010
|
+
const pair = rates.pairs[fixedFloatRatesPairKey(currency.code, resolution.lightning.code)];
|
|
1011
|
+
if (pair === void 0) {
|
|
1012
|
+
return {
|
|
1013
|
+
pay_asset: payInAsset,
|
|
1014
|
+
available: false,
|
|
1015
|
+
unavailable_reason: "pair_temporarily_unavailable",
|
|
1016
|
+
unavailable_message: fixedFloatAvailabilityMessage("pair_temporarily_unavailable")
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
pay_asset: payInAsset,
|
|
1021
|
+
...invoiceLimitsFromFixedFloatRate(pair)
|
|
1022
|
+
};
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
invoiceExpirySeconds() {
|
|
1026
|
+
return this.invoiceExpirySecondsValue;
|
|
1027
|
+
}
|
|
1028
|
+
async quote(input) {
|
|
1029
|
+
const resolution = await this.resolveCurrencies();
|
|
1030
|
+
const fromCcy = requiredFixedFloatCurrency(resolution, input.payInAsset);
|
|
1031
|
+
const rates = await this.resolveRatesIndex(resolution);
|
|
1032
|
+
return fixedFloatQuoteFromRatePair({
|
|
1033
|
+
pair: rates.pairs[fixedFloatRatesPairKey(fromCcy, resolution.lightning.code)],
|
|
1034
|
+
payInAsset: input.payInAsset,
|
|
1035
|
+
invoiceAmountMsats: input.invoiceAmountMsats,
|
|
1036
|
+
provider: this.name
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
async createSwap(input) {
|
|
1040
|
+
const resolution = await this.resolveCurrencies();
|
|
1041
|
+
const fromCcy = requiredFixedFloatCurrency(resolution, input.payInAsset);
|
|
1042
|
+
const data = await this.transport.post("create", {
|
|
1043
|
+
type: "fixed",
|
|
1044
|
+
fromCcy,
|
|
1045
|
+
toCcy: resolution.lightning.code,
|
|
1046
|
+
direction: "to",
|
|
1047
|
+
amount: amountMsatsToBtcString(input.invoiceAmountMsats),
|
|
1048
|
+
toAddress: input.bolt11
|
|
1049
|
+
});
|
|
1050
|
+
const order = normalizeFixedFloatOrder(data, {
|
|
1051
|
+
provider: this.name,
|
|
1052
|
+
payInAsset: input.payInAsset
|
|
1053
|
+
});
|
|
1054
|
+
if (order.fee !== void 0) return order;
|
|
1055
|
+
const fee = await this.fetchFixedFloatOrderFee(
|
|
1056
|
+
fromCcy,
|
|
1057
|
+
resolution.lightning.code,
|
|
1058
|
+
input.invoiceAmountMsats
|
|
1059
|
+
);
|
|
1060
|
+
return fee === void 0 ? order : { ...order, fee };
|
|
1061
|
+
}
|
|
1062
|
+
async fetchFixedFloatOrderFee(fromCcy, toCcy, invoiceAmountMsats) {
|
|
1063
|
+
try {
|
|
1064
|
+
const data = await this.transport.post("price", {
|
|
1065
|
+
type: "fixed",
|
|
1066
|
+
fromCcy,
|
|
1067
|
+
toCcy,
|
|
1068
|
+
direction: "to",
|
|
1069
|
+
amount: amountMsatsToBtcString(invoiceAmountMsats)
|
|
1070
|
+
});
|
|
1071
|
+
return readFixedFloatOrderFee(recordOrEmpty4(data));
|
|
1072
|
+
} catch {
|
|
1073
|
+
return void 0;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async getStatus(order) {
|
|
1077
|
+
const data = await this.transport.post("order", {
|
|
1078
|
+
id: order.provider_order_id,
|
|
1079
|
+
token: order.provider_token
|
|
1080
|
+
});
|
|
1081
|
+
return {
|
|
1082
|
+
...order,
|
|
1083
|
+
...normalizeFixedFloatOrder(data, {
|
|
1084
|
+
provider: this.name,
|
|
1085
|
+
payInAsset: order.pay_in_asset,
|
|
1086
|
+
fallback: order
|
|
1087
|
+
})
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
async requestRefund(order, refundAddress) {
|
|
1091
|
+
await this.transport.post("emergency", {
|
|
1092
|
+
id: order.provider_order_id,
|
|
1093
|
+
token: order.provider_token,
|
|
1094
|
+
choice: "REFUND",
|
|
1095
|
+
address: refundAddress
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
async resolveCurrencies() {
|
|
1099
|
+
const cache = this.cache;
|
|
1100
|
+
if (cache === void 0) {
|
|
1101
|
+
return await this.fetchCurrencyResolution();
|
|
1102
|
+
}
|
|
1103
|
+
return await cache.resolve(swapLimitsMetaKey(this.name), {
|
|
1104
|
+
refreshSeconds: this.cacheSeconds,
|
|
1105
|
+
maxStaleSeconds: Math.max(SWAP_LIMITS_MAX_STALE_SECONDS, this.cacheSeconds),
|
|
1106
|
+
fetch: () => this.fetchCurrencyResolution(),
|
|
1107
|
+
serialize: serializeFixedFloatCurrencyResolution,
|
|
1108
|
+
deserialize: deserializeFixedFloatCurrencyResolution
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
async resolveRatesIndex(resolution) {
|
|
1112
|
+
const cache = this.cache;
|
|
1113
|
+
if (cache === void 0) {
|
|
1114
|
+
return await this.fetchRatesIndex(resolution);
|
|
1115
|
+
}
|
|
1116
|
+
return await cache.resolve(swapRatesMetaKey(this.name, "fixed"), {
|
|
1117
|
+
refreshSeconds: this.ratesCacheSeconds,
|
|
1118
|
+
maxStaleSeconds: Math.max(SWAP_RATES_MAX_STALE_SECONDS, this.ratesCacheSeconds),
|
|
1119
|
+
// Crypto rates must not linger after a failed refresh — fail closed so the
|
|
1120
|
+
// service can skip this provider and try the next configured LSC connection.
|
|
1121
|
+
serveStaleOnFailure: false,
|
|
1122
|
+
fetch: () => this.fetchRatesIndex(resolution),
|
|
1123
|
+
serialize: serializeFixedFloatRatesIndex,
|
|
1124
|
+
deserialize: deserializeFixedFloatRatesIndex
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
async fetchRatesIndex(resolution) {
|
|
1128
|
+
const path = fixedFloatRatesXmlPath("fixed").replace(/^\//, "");
|
|
1129
|
+
this.transport.logApiRequest(path);
|
|
1130
|
+
try {
|
|
1131
|
+
const fetched = await fetchFixedFloatRatesIndex({
|
|
1132
|
+
baseUrl: this.transport.baseUrl,
|
|
1133
|
+
rateType: "fixed",
|
|
1134
|
+
fetch: this.transport.fetcher,
|
|
1135
|
+
now: this.now,
|
|
1136
|
+
requestTimeoutMs: this.transport.requestTimeoutMs
|
|
1137
|
+
});
|
|
1138
|
+
const index = retainFixedFloatRatePairsForKeys(fetched, fixedFloatRatePairKeys(resolution));
|
|
1139
|
+
this.transport.logApiResponse({
|
|
1140
|
+
path,
|
|
1141
|
+
status: 200,
|
|
1142
|
+
ok: true,
|
|
1143
|
+
data: { pair_count: Object.keys(index.pairs).length }
|
|
1144
|
+
});
|
|
1145
|
+
return index;
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
this.transport.logApiResponse({
|
|
1148
|
+
path,
|
|
1149
|
+
status: 0,
|
|
1150
|
+
ok: false,
|
|
1151
|
+
msg: error instanceof Error ? error.message : String(error)
|
|
1152
|
+
});
|
|
1153
|
+
throw error;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
async fetchCurrencyResolution() {
|
|
1157
|
+
const now = this.now();
|
|
1158
|
+
const data = await this.transport.post("ccies", {});
|
|
1159
|
+
return resolveFixedFloatCurrencies(data, { fetchedAt: now, lightningCcy: this.lightningCcy });
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
function readFixedFloatCompatibleProviderId(id) {
|
|
1163
|
+
const normalized = id.trim();
|
|
1164
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(normalized)) {
|
|
1165
|
+
throw new TypeError(
|
|
1166
|
+
"FixedFloat-compatible provider id must use lowercase letters, numbers, underscores, or hyphens."
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
return normalized;
|
|
1170
|
+
}
|
|
1171
|
+
function amountMsatsToBtcString(amountMsats) {
|
|
1172
|
+
if (!Number.isSafeInteger(amountMsats) || amountMsats <= 0) {
|
|
1173
|
+
throw new RangeError("invoiceAmountMsats must be a positive safe integer.");
|
|
1174
|
+
}
|
|
1175
|
+
const sats = ceilDiv2(BigInt(amountMsats), 1000n);
|
|
1176
|
+
const wholeBtc = sats / OPENRECEIVE_SATS_PER_BTC2;
|
|
1177
|
+
const fractional = String(sats % OPENRECEIVE_SATS_PER_BTC2).padStart(8, "0").replace(/0+$/, "");
|
|
1178
|
+
return fractional.length === 0 ? String(wholeBtc) : `${wholeBtc}.${fractional}`;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// src/lsc-uri.ts
|
|
1182
|
+
import { compact } from "@openreceive/core";
|
|
1183
|
+
var LSC_URI_PROTOCOL = "lightning+swapconnect:";
|
|
1184
|
+
var LSC_ENV_NAMES = ["LSC_URI_PRIMARY", "LSC_URI_BACKUP"];
|
|
1185
|
+
var LSC_QUERY_PARAMETERS = /* @__PURE__ */ new Set(["key", "secret"]);
|
|
1186
|
+
var MAX_LSC_URI_LENGTH = 8192;
|
|
1187
|
+
var MAX_LSC_CREDENTIAL_LENGTH = 2048;
|
|
1188
|
+
function parseLscUri(value) {
|
|
1189
|
+
const input = requiredCredential(value, "LSC URI", MAX_LSC_URI_LENGTH);
|
|
1190
|
+
let uri;
|
|
1191
|
+
try {
|
|
1192
|
+
uri = new URL(input);
|
|
1193
|
+
} catch {
|
|
1194
|
+
throw new TypeError("LSC URI is not a valid absolute URI.");
|
|
1195
|
+
}
|
|
1196
|
+
if (uri.protocol !== LSC_URI_PROTOCOL) {
|
|
1197
|
+
throw new TypeError(`LSC URI must use ${LSC_URI_PROTOCOL}//.`);
|
|
1198
|
+
}
|
|
1199
|
+
if (uri.username !== "" || uri.password !== "") {
|
|
1200
|
+
throw new TypeError("LSC URI must not use URI userinfo.");
|
|
1201
|
+
}
|
|
1202
|
+
if (uri.hostname === "") {
|
|
1203
|
+
throw new TypeError("LSC URI requires a provider hostname.");
|
|
1204
|
+
}
|
|
1205
|
+
if (uri.hash !== "") {
|
|
1206
|
+
throw new TypeError("LSC URI must not contain a fragment.");
|
|
1207
|
+
}
|
|
1208
|
+
if (/%(?![0-9a-f]{2})/i.test(uri.search)) {
|
|
1209
|
+
throw new TypeError("LSC URI query encoding is invalid.");
|
|
1210
|
+
}
|
|
1211
|
+
for (const parameter of uri.searchParams.keys()) {
|
|
1212
|
+
if (!LSC_QUERY_PARAMETERS.has(parameter)) {
|
|
1213
|
+
throw new TypeError("LSC URI contains an unsupported query parameter.");
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
const key = requiredCredential(
|
|
1217
|
+
singleParameter(uri, "key"),
|
|
1218
|
+
"LSC URI key",
|
|
1219
|
+
MAX_LSC_CREDENTIAL_LENGTH
|
|
1220
|
+
);
|
|
1221
|
+
const secret = requiredCredential(
|
|
1222
|
+
singleParameter(uri, "secret"),
|
|
1223
|
+
"LSC URI secret",
|
|
1224
|
+
MAX_LSC_CREDENTIAL_LENGTH
|
|
1225
|
+
);
|
|
1226
|
+
const baseUrl = `https://${uri.host}${normalizeEndpointPath(uri.pathname)}`;
|
|
1227
|
+
return {
|
|
1228
|
+
uriProtocol: LSC_URI_PROTOCOL,
|
|
1229
|
+
baseUrl,
|
|
1230
|
+
providerId: providerIdFromUri(uri),
|
|
1231
|
+
key,
|
|
1232
|
+
secret
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
function formatLscUri(input) {
|
|
1236
|
+
const endpoint = parseHttpsEndpoint(input.baseUrl);
|
|
1237
|
+
const key = requiredCredential(input.key, "LSC key", MAX_LSC_CREDENTIAL_LENGTH);
|
|
1238
|
+
const secret = requiredCredential(input.secret, "LSC secret", MAX_LSC_CREDENTIAL_LENGTH);
|
|
1239
|
+
const uri = new URL(
|
|
1240
|
+
`${LSC_URI_PROTOCOL}//${endpoint.host}${normalizeEndpointPath(endpoint.pathname)}`
|
|
1241
|
+
);
|
|
1242
|
+
uri.searchParams.set("key", key);
|
|
1243
|
+
uri.searchParams.set("secret", secret);
|
|
1244
|
+
return uri.toString();
|
|
1245
|
+
}
|
|
1246
|
+
function readLscConnectionsFromEnvironment(environment = process.env) {
|
|
1247
|
+
const connections = [];
|
|
1248
|
+
const providerIds = /* @__PURE__ */ new Set();
|
|
1249
|
+
for (const name of LSC_ENV_NAMES) {
|
|
1250
|
+
const value = environment[name]?.trim();
|
|
1251
|
+
if (!value) continue;
|
|
1252
|
+
let connection;
|
|
1253
|
+
try {
|
|
1254
|
+
connection = parseLscUri(value);
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
throw new TypeError(
|
|
1257
|
+
`${name} is invalid: ${error instanceof Error ? error.message : "invalid LSC URI"}`
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
if (providerIds.has(connection.providerId)) {
|
|
1261
|
+
throw new TypeError(`${name} duplicates another LSC provider id.`);
|
|
1262
|
+
}
|
|
1263
|
+
providerIds.add(connection.providerId);
|
|
1264
|
+
connections.push(connection);
|
|
1265
|
+
}
|
|
1266
|
+
return connections;
|
|
1267
|
+
}
|
|
1268
|
+
function createLscSwapProvidersFromEnvironment(environment = process.env, options = {}) {
|
|
1269
|
+
return readLscConnectionsFromEnvironment(environment).map(
|
|
1270
|
+
(connection) => fixedFloatCompatibleSwapProvider(
|
|
1271
|
+
compact({
|
|
1272
|
+
id: connection.providerId,
|
|
1273
|
+
baseUrl: connection.baseUrl,
|
|
1274
|
+
key: connection.key,
|
|
1275
|
+
secret: connection.secret,
|
|
1276
|
+
fetch: options.fetch,
|
|
1277
|
+
now: options.now
|
|
1278
|
+
})
|
|
1279
|
+
)
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
function singleParameter(uri, name) {
|
|
1283
|
+
const values = uri.searchParams.getAll(name);
|
|
1284
|
+
if (values.length !== 1) {
|
|
1285
|
+
throw new TypeError(`LSC URI requires exactly one ${name} parameter.`);
|
|
1286
|
+
}
|
|
1287
|
+
return values[0] ?? "";
|
|
1288
|
+
}
|
|
1289
|
+
function requiredCredential(value, label, maximumLength) {
|
|
1290
|
+
const normalized = value.trim();
|
|
1291
|
+
if (normalized.length === 0) throw new TypeError(`${label} must not be empty.`);
|
|
1292
|
+
if (normalized.length > maximumLength) throw new TypeError(`${label} is too long.`);
|
|
1293
|
+
return normalized;
|
|
1294
|
+
}
|
|
1295
|
+
function parseHttpsEndpoint(value) {
|
|
1296
|
+
let endpoint;
|
|
1297
|
+
try {
|
|
1298
|
+
endpoint = new URL(value);
|
|
1299
|
+
} catch {
|
|
1300
|
+
throw new TypeError("LSC baseUrl must be a valid HTTPS URL.");
|
|
1301
|
+
}
|
|
1302
|
+
if (endpoint.protocol !== "https:" || endpoint.hostname === "" || endpoint.username !== "" || endpoint.password !== "" || endpoint.search !== "" || endpoint.hash !== "") {
|
|
1303
|
+
throw new TypeError(
|
|
1304
|
+
"LSC baseUrl must be an HTTPS URL without userinfo, query parameters, or a fragment."
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
return endpoint;
|
|
1308
|
+
}
|
|
1309
|
+
function normalizeEndpointPath(pathname) {
|
|
1310
|
+
if (pathname === "" || pathname === "/") return "/";
|
|
1311
|
+
return pathname.endsWith("/") ? pathname : `${pathname}/`;
|
|
1312
|
+
}
|
|
1313
|
+
function providerIdFromUri(uri) {
|
|
1314
|
+
const path = uri.pathname.split("/").filter(Boolean).join("-");
|
|
1315
|
+
const raw = `${uri.hostname}${uri.port ? `-${uri.port}` : ""}${path ? `-${path}` : ""}`.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
1316
|
+
if (!raw) throw new TypeError("LSC URI could not derive a provider id.");
|
|
1317
|
+
return raw;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// src/service/logging.ts
|
|
1321
|
+
import { compact as compact2, isRecord } from "@openreceive/core";
|
|
1322
|
+
function emitLog(options, level, event, message, fields = {}) {
|
|
1323
|
+
emitEvent(options, {
|
|
1324
|
+
level,
|
|
1325
|
+
event,
|
|
1326
|
+
message,
|
|
1327
|
+
...fields
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
function emitEvent(options, event) {
|
|
1331
|
+
if (options.onEvent === void 0 && options.logger === void 0) return;
|
|
1332
|
+
const sanitized = sanitizeEvent(event);
|
|
1333
|
+
try {
|
|
1334
|
+
options.onEvent?.(sanitized);
|
|
1335
|
+
} catch {
|
|
1336
|
+
}
|
|
1337
|
+
try {
|
|
1338
|
+
options.logger?.(sanitized);
|
|
1339
|
+
} catch {
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
function createNwcEndpointLogger(options) {
|
|
1343
|
+
if (options.onEvent === void 0 && options.logger === void 0) return void 0;
|
|
1344
|
+
return (entry) => emitEvent(options, entry);
|
|
1345
|
+
}
|
|
1346
|
+
function sanitizeEvent(entry) {
|
|
1347
|
+
const clean = {};
|
|
1348
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
1349
|
+
if (isSensitiveLogKey(key)) {
|
|
1350
|
+
clean[key] = "[REDACTED]";
|
|
1351
|
+
} else {
|
|
1352
|
+
clean[key] = sanitizeLogValue(value);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return clean;
|
|
1356
|
+
}
|
|
1357
|
+
function sanitizeLogValue(value) {
|
|
1358
|
+
if (typeof value === "string") return redactSecrets(value);
|
|
1359
|
+
if (Array.isArray(value)) return value.map(sanitizeLogValue);
|
|
1360
|
+
if (typeof value !== "object" || value === null) return value;
|
|
1361
|
+
const clean = {};
|
|
1362
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
1363
|
+
if (isSensitiveLogKey(key)) {
|
|
1364
|
+
clean[key] = "[REDACTED]";
|
|
1365
|
+
} else {
|
|
1366
|
+
clean[key] = sanitizeLogValue(nested);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return clean;
|
|
1370
|
+
}
|
|
1371
|
+
function isSensitiveLogKey(key) {
|
|
1372
|
+
if (/_present$/i.test(key)) return false;
|
|
1373
|
+
return /secret|token|authorization|cookie|nwc|dsn|preimage|invoice|bolt11|swap_?data|(?:private|api)[_-]?key|^key$|api[_-]?sign/i.test(
|
|
1374
|
+
key
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
function redactSecrets(value) {
|
|
1378
|
+
return value.replace(/nostr\+walletconnect:[^\s"'`<>]+/g, "[REDACTED_NWC]").replace(/lightning\+swapconnect:[^\s"'`<>]+/g, "[REDACTED_LSC]").replace(/([?&](?:token|secret|key)=)[^&\s"'`<>]+/gi, "$1[REDACTED]");
|
|
1379
|
+
}
|
|
1380
|
+
function summarizeSwapProviderApiRequest(entry) {
|
|
1381
|
+
const body = isRecord(entry.body) ? entry.body : void 0;
|
|
1382
|
+
return compact2({
|
|
1383
|
+
provider: entry.provider,
|
|
1384
|
+
path: entry.path,
|
|
1385
|
+
reference: optionalLogString(body?.id),
|
|
1386
|
+
choice: optionalLogString(body?.choice),
|
|
1387
|
+
from_ccy: optionalLogString(body?.fromCcy),
|
|
1388
|
+
to_ccy: optionalLogString(body?.toCcy),
|
|
1389
|
+
amount: optionalLogString(body?.amount) ?? optionalLogNumber(body?.amount)
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
function summarizeSwapProviderApiResponse(entry) {
|
|
1393
|
+
const summary = {
|
|
1394
|
+
provider: entry.provider,
|
|
1395
|
+
path: entry.path,
|
|
1396
|
+
status: entry.status,
|
|
1397
|
+
ok: entry.ok
|
|
1398
|
+
};
|
|
1399
|
+
if (entry.code !== void 0 && entry.code !== null) summary.code = entry.code;
|
|
1400
|
+
const msg = optionalLogString(entry.msg);
|
|
1401
|
+
if (msg !== void 0 && msg !== "OK") summary.msg = msg;
|
|
1402
|
+
const data = entry.data;
|
|
1403
|
+
if (Array.isArray(data)) {
|
|
1404
|
+
summary.items = data.length;
|
|
1405
|
+
return summary;
|
|
1406
|
+
}
|
|
1407
|
+
if (!isRecord(data)) return summary;
|
|
1408
|
+
const pairCount = optionalLogNumber(data.pair_count);
|
|
1409
|
+
if (pairCount !== void 0) {
|
|
1410
|
+
summary.pair_count = pairCount;
|
|
1411
|
+
return summary;
|
|
1412
|
+
}
|
|
1413
|
+
const reference = optionalLogString(data.id);
|
|
1414
|
+
const orderStatus = optionalLogString(data.status);
|
|
1415
|
+
if (reference !== void 0) summary.reference = reference;
|
|
1416
|
+
if (orderStatus !== void 0) summary.order_status = orderStatus;
|
|
1417
|
+
const from = summarizeSwapProviderSide(data.from);
|
|
1418
|
+
const to = summarizeSwapProviderSide(data.to);
|
|
1419
|
+
if (from !== void 0) summary.from = from;
|
|
1420
|
+
if (to !== void 0) summary.to = to;
|
|
1421
|
+
if (isRecord(data.time)) {
|
|
1422
|
+
const left = optionalLogNumber(data.time.left);
|
|
1423
|
+
if (left !== void 0) summary.left = left;
|
|
1424
|
+
}
|
|
1425
|
+
if (isRecord(data.emergency)) {
|
|
1426
|
+
const choice = optionalLogString(data.emergency.choice);
|
|
1427
|
+
if (choice !== void 0 && choice !== "NONE") summary.emergency = choice;
|
|
1428
|
+
const statuses = Array.isArray(data.emergency.status) ? data.emergency.status.filter((item) => typeof item === "string" && item.length > 0).map((item) => item.toUpperCase()) : [];
|
|
1429
|
+
if (statuses.length > 0) summary.emergency_status = statuses.join(",");
|
|
1430
|
+
const repeat = data.emergency.repeat;
|
|
1431
|
+
if (repeat === true || repeat === "1" || repeat === 1) summary.emergency_repeat = true;
|
|
1432
|
+
}
|
|
1433
|
+
if (isRecord(data.from) && isRecord(data.from.tx)) {
|
|
1434
|
+
const received = optionalLogString(data.from.tx.amount);
|
|
1435
|
+
if (received !== void 0) summary.deposit_received = received;
|
|
1436
|
+
}
|
|
1437
|
+
if (isRecord(data.back)) {
|
|
1438
|
+
const refundAmount = optionalLogString(data.back.amount);
|
|
1439
|
+
if (refundAmount !== void 0) summary.refund_amount = refundAmount;
|
|
1440
|
+
}
|
|
1441
|
+
if (reference === void 0) {
|
|
1442
|
+
const fromRecord = isRecord(data.from) ? data.from : void 0;
|
|
1443
|
+
const toRecord = isRecord(data.to) ? data.to : void 0;
|
|
1444
|
+
const fromAmount = optionalLogString(fromRecord?.amount) ?? optionalLogString(data.fromAmount);
|
|
1445
|
+
const toAmount = optionalLogString(toRecord?.amount) ?? optionalLogString(data.toAmount);
|
|
1446
|
+
if (fromAmount !== void 0) summary.from_amount = fromAmount;
|
|
1447
|
+
if (toAmount !== void 0) summary.to_amount = toAmount;
|
|
1448
|
+
}
|
|
1449
|
+
return summary;
|
|
1450
|
+
}
|
|
1451
|
+
function summarizeSwapProviderSide(side) {
|
|
1452
|
+
if (!isRecord(side)) return void 0;
|
|
1453
|
+
const code = optionalLogString(side.code) ?? optionalLogString(side.coin);
|
|
1454
|
+
const amount = optionalLogString(side.amount);
|
|
1455
|
+
if (code === void 0 && amount === void 0) return void 0;
|
|
1456
|
+
if (code !== void 0 && amount !== void 0) return `${code} ${amount}`;
|
|
1457
|
+
return code ?? amount;
|
|
1458
|
+
}
|
|
1459
|
+
function optionalLogString(value) {
|
|
1460
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1461
|
+
}
|
|
1462
|
+
function optionalLogNumber(value) {
|
|
1463
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
export {
|
|
1467
|
+
OPENRECEIVE_SWAP_PAY_IN_ASSETS,
|
|
1468
|
+
isSwapPayInAsset,
|
|
1469
|
+
listSwapAssetInfo,
|
|
1470
|
+
TransientSwapCache,
|
|
1471
|
+
fixedFloatProvider,
|
|
1472
|
+
fixedFloatCompatibleSwapProvider,
|
|
1473
|
+
formatLscUri,
|
|
1474
|
+
readLscConnectionsFromEnvironment,
|
|
1475
|
+
createLscSwapProvidersFromEnvironment,
|
|
1476
|
+
emitLog,
|
|
1477
|
+
createNwcEndpointLogger,
|
|
1478
|
+
sanitizeEvent,
|
|
1479
|
+
redactSecrets,
|
|
1480
|
+
summarizeSwapProviderApiRequest,
|
|
1481
|
+
summarizeSwapProviderApiResponse
|
|
1482
|
+
};
|