@anywalletkit/protocol-swap2-thorchain 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/protocol.cjs CHANGED
@@ -69,6 +69,200 @@ var import_viem = require("viem");
69
69
  var import_util2 = require("@anywalletkit/protocol-swap2-core/util");
70
70
  var import_swap2_assets = require("@anywalletkit/swap2-assets");
71
71
  var import_core = require("@anywalletkit/core");
72
+
73
+ // src/confirm.ts
74
+ var THORNODE_CONFIRM_PATHS = Object.freeze({
75
+ lastblock: "/thorchain/lastblock",
76
+ inboundAddresses: "/thorchain/inbound_addresses",
77
+ asgardVaults: "/thorchain/vaults/asgard",
78
+ mimir: "/thorchain/mimir"
79
+ });
80
+ var DEFAULT_CONFIRM_MAX_LAG_BLOCKS = 50;
81
+ var DEFAULT_CONFIRM_TIMEOUT_MS = 2500;
82
+ var MIN_CONFIRM_TIMEOUT_MS = 100;
83
+ var MAX_CONFIRM_TIMEOUT_MS = 8e3;
84
+ function confirmOptions(o) {
85
+ const maxLagBlocks = o.confirmMaxLagBlocks ?? DEFAULT_CONFIRM_MAX_LAG_BLOCKS;
86
+ if (!Number.isInteger(maxLagBlocks) || maxLagBlocks < 0 || maxLagBlocks > 1e5) {
87
+ throw new Error("thorchain: confirmMaxLagBlocks must be an integer between 0 and 100000");
88
+ }
89
+ const timeoutMs = o.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
90
+ if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_CONFIRM_TIMEOUT_MS || timeoutMs > MAX_CONFIRM_TIMEOUT_MS) {
91
+ throw new Error(`thorchain: confirmTimeoutMs must be an integer between ${MIN_CONFIRM_TIMEOUT_MS} and ${MAX_CONFIRM_TIMEOUT_MS}`);
92
+ }
93
+ return { maxLagBlocks, timeoutMs };
94
+ }
95
+ var HostUnavailable = class extends Error {
96
+ constructor(host, reason) {
97
+ super(`thorchain: ${host} unavailable (${reason})`);
98
+ }
99
+ };
100
+ function paused(entry) {
101
+ return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
102
+ }
103
+ function sameTarget(a, b) {
104
+ if (typeof a !== "string" || typeof b !== "string" || !a || !b) return false;
105
+ return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
106
+ }
107
+ function nodeDeadline(ms, outer) {
108
+ const ctrl = new AbortController();
109
+ const timer = setTimeout(() => ctrl.abort(), Math.max(0, ms));
110
+ const onAbort = () => ctrl.abort();
111
+ if (outer) {
112
+ if (outer.aborted) ctrl.abort();
113
+ else outer.addEventListener("abort", onAbort);
114
+ }
115
+ return {
116
+ signal: ctrl.signal,
117
+ clear() {
118
+ clearTimeout(timer);
119
+ outer?.removeEventListener("abort", onAbort);
120
+ if (!ctrl.signal.aborted) ctrl.abort();
121
+ }
122
+ };
123
+ }
124
+ function sameRequestUrl(actual, expected) {
125
+ if (actual === void 0 || actual === null || actual === "") return true;
126
+ if (typeof actual !== "string") return false;
127
+ try {
128
+ const a = new URL(actual);
129
+ const e = new URL(expected);
130
+ return a.origin === e.origin && a.pathname === e.pathname && a.search === "" && a.hash === "";
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+ async function nodeRead(host, path, clientId, signal, outer) {
136
+ const url = `${host}${path}`;
137
+ let res;
138
+ try {
139
+ res = await fetch(url, { method: "GET", headers: { accept: "application/json", "x-client-id": clientId }, redirect: "error", signal });
140
+ } catch (error) {
141
+ if (outer?.aborted) throw error;
142
+ throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
143
+ }
144
+ if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
145
+ if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`);
146
+ try {
147
+ return await res.json();
148
+ } catch (error) {
149
+ if (outer?.aborted) throw error;
150
+ throw new HostUnavailable(host, `${path} is not JSON`);
151
+ }
152
+ }
153
+ function int64(v) {
154
+ if (typeof v === "number") return Number.isSafeInteger(v) ? v : null;
155
+ if (typeof v === "string" && /^-?\d{1,15}$/.test(v)) return Number(v);
156
+ return null;
157
+ }
158
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
159
+ var INBOUND_STRINGS = ["address", "router", "dust_threshold"];
160
+ var INBOUND_FLAGS = ["halted", "global_trading_paused", "chain_trading_paused"];
161
+ function inboundAddresses(body) {
162
+ if (!Array.isArray(body)) return null;
163
+ const out = [];
164
+ for (const e of body) {
165
+ if (!isObject(e) || typeof e.chain !== "string") return null;
166
+ const entry = { chain: e.chain };
167
+ for (const k of INBOUND_STRINGS) {
168
+ const v = e[k];
169
+ if (v === void 0) continue;
170
+ if (typeof v !== "string") return null;
171
+ entry[k] = v;
172
+ }
173
+ for (const k of INBOUND_FLAGS) {
174
+ const v = e[k];
175
+ if (v === void 0) continue;
176
+ if (typeof v !== "boolean") return null;
177
+ entry[k] = v;
178
+ }
179
+ out.push(entry);
180
+ }
181
+ return out;
182
+ }
183
+ function lastblockHeight(body) {
184
+ if (!Array.isArray(body) || body.length === 0) return null;
185
+ let height = -1;
186
+ for (const entry of body) {
187
+ if (!isObject(entry)) return null;
188
+ const h = int64(entry.thorchain);
189
+ if (h === null || h < 0) return null;
190
+ if (h > height) height = h;
191
+ }
192
+ return height;
193
+ }
194
+ function asgardVaults(body) {
195
+ if (!Array.isArray(body)) return null;
196
+ const out = [];
197
+ for (const v of body) {
198
+ if (!isObject(v)) return null;
199
+ if (typeof v.status !== "string" || typeof v.pub_key !== "string" || !/^thorpub1[0-9a-z]{20,}$/.test(v.pub_key)) return null;
200
+ if (v.type !== void 0 && v.type !== "AsgardVault") return null;
201
+ if (!Array.isArray(v.addresses)) return null;
202
+ const addresses = [];
203
+ for (const a of v.addresses) {
204
+ if (!isObject(a) || typeof a.chain !== "string" || typeof a.address !== "string") return null;
205
+ addresses.push({ chain: a.chain, address: a.address });
206
+ }
207
+ out.push({ pubKey: v.pub_key, status: v.status, addresses });
208
+ }
209
+ return out;
210
+ }
211
+ function mimirFlags(body) {
212
+ if (!isObject(body) || Object.keys(body).length === 0) return null;
213
+ const keys = Object.keys(body).map((k) => [k.toUpperCase(), body[k]]);
214
+ if (keys.some(([, value]) => typeof value !== "number" && typeof value !== "string")) return null;
215
+ return (pubKey) => {
216
+ const pub = pubKey.toUpperCase();
217
+ const compromisedKey = `COMPROMISEDVAULT-${pub}`;
218
+ const insolventPrefix = `SOLVENCYHALTVAULT-${pub}-`;
219
+ let flag = null;
220
+ for (const [key, value] of keys) {
221
+ const kind = key === compromisedKey ? "compromised" : key.startsWith(insolventPrefix) ? "insolvent" : null;
222
+ if (!kind) continue;
223
+ const n = int64(value);
224
+ if (n === null) return "malformed";
225
+ if (n > 0 && flag !== "compromised") flag = kind;
226
+ }
227
+ return flag;
228
+ };
229
+ }
230
+ async function nodeHead(host, read) {
231
+ const [lastblock, inbound] = await Promise.all([read(THORNODE_CONFIRM_PATHS.lastblock), read(THORNODE_CONFIRM_PATHS.inboundAddresses)]);
232
+ const height = lastblockHeight(lastblock);
233
+ if (height === null) throw new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
234
+ return { height, inbound };
235
+ }
236
+ async function vaultVerdict(host, read, inbound, claim) {
237
+ if (!Array.isArray(inbound)) throw new HostUnavailable(host, "inbound_addresses is not a list");
238
+ const entries = inboundAddresses(inbound);
239
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
240
+ const entry = entries.find((e) => e.chain === claim.chain);
241
+ if (paused(entry)) return { ok: false, why: "halted", chain: claim.chain };
242
+ if (claim.destChain !== void 0 && paused(entries.find((e) => e.chain === claim.destChain))) {
243
+ return { ok: false, why: "halted", chain: claim.destChain };
244
+ }
245
+ if (!sameTarget(entry?.address, claim.vault)) {
246
+ const [asgardBody, mimirBody] = await Promise.all([read(THORNODE_CONFIRM_PATHS.asgardVaults), read(THORNODE_CONFIRM_PATHS.mimir)]);
247
+ const vaults = asgardVaults(asgardBody);
248
+ if (!vaults) throw new HostUnavailable(host, "vaults/asgard is not a THORNode vault list");
249
+ const flags = mimirFlags(mimirBody);
250
+ if (!flags) throw new HostUnavailable(host, "mimir is not a THORNode mimir map");
251
+ const holders = vaults.filter((v) => v.addresses.some((a) => a.chain === claim.chain && sameTarget(a.address, claim.vault)));
252
+ const active = holders.find((v) => v.status === "ActiveVault");
253
+ if (!active) {
254
+ if (!holders.length) return { ok: false, why: "another-vault" };
255
+ return { ok: false, why: "not-active", status: /^[A-Za-z]{1,32}$/.test(holders[0].status) ? holders[0].status : "an unknown status" };
256
+ }
257
+ const flag = flags(active.pubKey);
258
+ if (flag === "malformed") throw new HostUnavailable(host, "mimir holds a non-integer vault flag");
259
+ if (flag) return { ok: false, why: "flagged", flag };
260
+ }
261
+ if (claim.router !== void 0 && !sameTarget(entry?.router, claim.router)) return { ok: false, why: "another-router" };
262
+ return { ok: true };
263
+ }
264
+
265
+ // src/provider.ts
72
266
  var HOSTS = [
73
267
  "https://gateway.liquify.com/chain/thorchain_api",
74
268
  "https://thorchain.ibs.team/api"
@@ -84,6 +278,9 @@ var NATIVE = "0x0000000000000000000000000000000000000000";
84
278
  var TOLERANCE_BPS = "300";
85
279
  var MAX_TOLERANCE_BPS = 500n;
86
280
  var QUOTE_VALIDITY_MS = 5 * 6e4;
281
+ var NODE_READ_TIMEOUT_MS = 8e3;
282
+ var CONFIRM_BUDGET_MS = 8e3;
283
+ var LAGGING = /* @__PURE__ */ Symbol("lagging");
87
284
  var THOR_CHAIN = {
88
285
  "eip155:1": "ETH",
89
286
  "eip155:8453": "BASE",
@@ -164,18 +361,13 @@ function destAsset(destChain, destToken) {
164
361
  const asset = thorAsset(destChain, a.symbol);
165
362
  return asset ? { asset, decimals: a.decimals } : void 0;
166
363
  }
167
- var HostUnavailable = class extends Error {
168
- constructor(host, reason) {
169
- super(`thorchain: ${host} unavailable (${reason})`);
170
- }
171
- };
172
364
  async function hostGet(host, path, clientId, signal) {
173
365
  let res;
174
366
  try {
175
367
  res = await fetch(`${host}${path}`, {
176
368
  headers: { accept: "application/json", "x-client-id": clientId },
177
369
  cache: "no-store",
178
- signal: signal ?? (0, import_util2.timeoutSignal)(8e3)
370
+ signal: signal ?? (0, import_util2.timeoutSignal)(NODE_READ_TIMEOUT_MS)
179
371
  });
180
372
  } catch (error) {
181
373
  if (signal?.aborted) throw error;
@@ -210,14 +402,9 @@ function belowThorMinimums(amount1e8, inbound, quote) {
210
402
  async function inboundAddressesOn(host, clientId) {
211
403
  const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
212
404
  if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`);
213
- return res.body;
214
- }
215
- function paused(entry) {
216
- return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
217
- }
218
- function sameTarget(a, b) {
219
- if (!a || !b) return false;
220
- return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
405
+ const entries = inboundAddresses(res.body);
406
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
407
+ return entries;
221
408
  }
222
409
  function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
223
410
  if (memo.includes("|")) return null;
@@ -279,21 +466,100 @@ function thorchainProvider(config = {}) {
279
466
  routers.set(chain, set);
280
467
  }
281
468
  const pinnedRouter = (chain, router) => !!router && routers.get(chain)?.has(router.toLowerCase()) === true;
282
- const confirmedElsewhere = async (quoteHost, chain, vault, router) => {
469
+ const { maxLagBlocks: confirmMaxLagBlocks, timeoutMs: confirmTimeoutMs } = confirmOptions(config);
470
+ const nodeSnapshot = (host, outer) => new Promise((resolve, reject) => {
471
+ let inbounds;
472
+ let height;
473
+ let heightError;
474
+ let done = false;
475
+ const tip = nodeDeadline(NODE_READ_TIMEOUT_MS, outer);
476
+ const settle = () => {
477
+ if (done || inbounds === void 0 || height === void 0 && heightError === void 0) return;
478
+ done = true;
479
+ resolve(heightError ? { inbounds, heightError } : { inbounds, height });
480
+ };
481
+ const fail = (error) => {
482
+ if (done) return;
483
+ done = true;
484
+ tip.clear();
485
+ reject(error);
486
+ };
487
+ inboundAddressesOn(host, clientId).then((v) => {
488
+ inbounds = v;
489
+ settle();
490
+ }, fail);
491
+ nodeRead(host, THORNODE_CONFIRM_PATHS.lastblock, clientId, tip.signal, outer).then(
492
+ (body) => {
493
+ tip.clear();
494
+ const h = lastblockHeight(body);
495
+ if (h === null) heightError = new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
496
+ else height = h;
497
+ settle();
498
+ },
499
+ (error) => {
500
+ tip.clear();
501
+ if (!(error instanceof HostUnavailable)) return fail(error);
502
+ heightError = error;
503
+ settle();
504
+ }
505
+ );
506
+ });
507
+ const confirmText = (v) => {
508
+ switch (v.why) {
509
+ case "halted":
510
+ return `${v.chain} is halted / paused on the confirming node`;
511
+ case "another-vault":
512
+ return "a second node reports another inbound vault \u2014 refused until they agree";
513
+ case "not-active":
514
+ return `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`;
515
+ case "flagged":
516
+ return `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`;
517
+ case "another-router":
518
+ return "a second node reports another router \u2014 refused until they agree";
519
+ }
520
+ };
521
+ const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
522
+ const read = (path) => nodeRead(host, path, clientId, signal, outer);
523
+ const head = await nodeHead(host, read);
524
+ if (quoteHeight - head.height > confirmMaxLagBlocks) return LAGGING;
525
+ const verdict = await vaultVerdict(host, read, head.inbound, claim);
526
+ return verdict.ok ? null : confirmText(verdict);
527
+ };
528
+ const confirmedElsewhere = async (quoteHost, quoteHeight, chain, destChain, vault, router, deadlineAt, outer) => {
529
+ const claim = {
530
+ chain: THOR_CHAIN[chain] ?? chain,
531
+ ...destChain !== void 0 ? { destChain: THOR_CHAIN[destChain] ?? destChain } : {},
532
+ vault,
533
+ ...router !== void 0 ? { router } : {}
534
+ };
535
+ let lagging = false;
536
+ let asked = 0;
283
537
  for (const host of confirmHosts) {
284
538
  if (host === quoteHost) continue;
285
- let entries;
539
+ const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
540
+ if (budget < MIN_CONFIRM_TIMEOUT_MS) break;
541
+ asked++;
542
+ const node = nodeDeadline(budget, outer);
286
543
  try {
287
- entries = await inboundAddressesOn(host, clientId);
544
+ const verdict = await confirmOn(host, node.signal, outer, quoteHeight, claim);
545
+ if (verdict === LAGGING) {
546
+ lagging = true;
547
+ continue;
548
+ }
549
+ return verdict;
288
550
  } catch (error) {
551
+ if (outer?.aborted) throw error;
289
552
  if (error instanceof HostUnavailable) continue;
290
553
  throw error;
554
+ } finally {
555
+ node.clear();
291
556
  }
292
- const entry = entries.find((e) => e.chain === THOR_CHAIN[chain]);
293
- if (paused(entry)) return `${THOR_CHAIN[chain]} is halted / paused on the confirming node`;
294
- if (!sameTarget(entry?.address, vault)) return "a second node reports another inbound vault \u2014 refused until they agree";
295
- if (router !== void 0 && !sameTarget(entry?.router, router)) return "a second node reports another router \u2014 refused until they agree";
296
- return null;
557
+ }
558
+ if (lagging) {
559
+ return `no confirming node is within ${confirmMaxLagBlocks} blocks of the quoting node's THORChain height (behind or unreachable) \u2014 refused until one catches up`;
560
+ }
561
+ if (asked === 0) {
562
+ return `the ${CONFIRM_BUDGET_MS / 1e3} s confirmation budget ran out before a second node could be asked to confirm the inbound vault \u2014 retry`;
297
563
  }
298
564
  return "no second node answered to confirm the inbound vault (configure confirmNodeUrl)";
299
565
  };
@@ -386,6 +652,9 @@ function thorchainProvider(config = {}) {
386
652
  let checkedMemo = null;
387
653
  let inbounds = null;
388
654
  let quoteHost = "";
655
+ let quoteHeight = 0;
656
+ let confirmDeadlineAt = 0;
657
+ let heightUnavailable = false;
389
658
  for (const host of hosts) {
390
659
  try {
391
660
  if (limitMode === "liquidity_tolerance_bps") {
@@ -430,8 +699,16 @@ function thorchainProvider(config = {}) {
430
699
  params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`);
431
700
  return null;
432
701
  }
433
- inbounds = await inboundAddressesOn(host, clientId);
702
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
703
+ const snapshot = await nodeSnapshot(host, signal);
704
+ if ("heightError" in snapshot) {
705
+ heightUnavailable = true;
706
+ throw snapshot.heightError;
707
+ }
708
+ inbounds = snapshot.inbounds;
709
+ quoteHeight = snapshot.height;
434
710
  quoteHost = host;
711
+ confirmDeadlineAt = deadlineAt;
435
712
  break;
436
713
  } catch (error) {
437
714
  if (error instanceof HostUnavailable) {
@@ -444,7 +721,9 @@ function thorchainProvider(config = {}) {
444
721
  }
445
722
  }
446
723
  if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
447
- params.onRefusal?.(!inbounds ? "thorchain: inbound addresses unavailable from every node" : "thorchain: no quote from any node");
724
+ params.onRefusal?.(
725
+ inbounds ? "thorchain: no quote from any node" : heightUnavailable ? "thorchain: THORChain height (/thorchain/lastblock) unavailable from every node that answered the inbound addresses" : "thorchain: inbound addresses unavailable from every node"
726
+ );
448
727
  return null;
449
728
  }
450
729
  const limit1e8 = checkedMemo.limit;
@@ -466,7 +745,16 @@ function thorchainProvider(config = {}) {
466
745
  params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`);
467
746
  return null;
468
747
  }
469
- const unconfirmed = await confirmedElsewhere(quoteHost, params.sourceChain, q.inbound_address, chainId ? q.router : void 0);
748
+ const unconfirmed = await confirmedElsewhere(
749
+ quoteHost,
750
+ quoteHeight,
751
+ params.sourceChain,
752
+ params.destChain,
753
+ q.inbound_address,
754
+ chainId ? q.router : void 0,
755
+ confirmDeadlineAt,
756
+ signal
757
+ );
470
758
  if (unconfirmed) {
471
759
  params.onRefusal?.(`thorchain: ${unconfirmed}`);
472
760
  return null;
@@ -581,18 +869,42 @@ function thorchainProvider(config = {}) {
581
869
  if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw new Error("thorchain: the quote's router is not one this module trusts");
582
870
  let inbounds = null;
583
871
  let snapshotHost = "";
872
+ let snapshotHeight = 0;
873
+ let confirmDeadlineAt = 0;
874
+ let heightUnavailable = false;
584
875
  for (const host of hosts) {
876
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
585
877
  try {
586
- inbounds = await inboundAddressesOn(host, clientId);
878
+ const snapshot = await nodeSnapshot(host);
879
+ if ("heightError" in snapshot) {
880
+ heightUnavailable = true;
881
+ continue;
882
+ }
883
+ inbounds = snapshot.inbounds;
884
+ snapshotHeight = snapshot.height;
587
885
  snapshotHost = host;
886
+ confirmDeadlineAt = deadlineAt;
588
887
  break;
589
888
  } catch (error) {
889
+ inbounds = null;
590
890
  if (!(error instanceof HostUnavailable)) throw error;
591
891
  }
592
892
  }
593
- if (!inbounds) throw new Error("thorchain: cannot refresh inbound addresses before broadcast");
893
+ if (!inbounds) {
894
+ throw new Error(
895
+ heightUnavailable ? "thorchain: cannot read THORChain height (/thorchain/lastblock) before broadcast from any node that answered the inbound addresses" : "thorchain: cannot refresh inbound addresses before broadcast"
896
+ );
897
+ }
594
898
  const confirm = async () => {
595
- const unconfirmed = await confirmedElsewhere(snapshotHost, data.sourceChain, data.inboundAddress, evmSource ? data.router : void 0);
899
+ const unconfirmed = await confirmedElsewhere(
900
+ snapshotHost,
901
+ snapshotHeight,
902
+ data.sourceChain,
903
+ data.destChain,
904
+ data.inboundAddress,
905
+ evmSource ? data.router : void 0,
906
+ confirmDeadlineAt
907
+ );
596
908
  if (unconfirmed) throw new Error(`thorchain: ${unconfirmed}`);
597
909
  };
598
910
  const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);