@anywalletkit/protocol-swap2-thorchain 0.3.5 → 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/index.cjs CHANGED
@@ -25,6 +25,7 @@ __export(src_exports, {
25
25
  THORCHAIN_ROUTERS: () => THORCHAIN_ROUTERS,
26
26
  THORCHAIN_TRANSFER_OUT: () => THORCHAIN_TRANSFER_OUT,
27
27
  THORCHAIN_TRANSFER_OUT_AND_CALL: () => THORCHAIN_TRANSFER_OUT_AND_CALL,
28
+ THORNODE_CONFIRM_PATHS: () => THORNODE_CONFIRM_PATHS,
28
29
  ThorchainSwap2Protocol: () => ThorchainSwap2Protocol,
29
30
  default: () => ThorchainSwap2Protocol,
30
31
  describeThorchainActivity: () => describeThorchainActivity,
@@ -78,6 +79,200 @@ var import_viem = require("viem");
78
79
  var import_util2 = require("@anywalletkit/protocol-swap2-core/util");
79
80
  var import_swap2_assets = require("@anywalletkit/swap2-assets");
80
81
  var import_core = require("@anywalletkit/core");
82
+
83
+ // src/confirm.ts
84
+ var THORNODE_CONFIRM_PATHS = Object.freeze({
85
+ lastblock: "/thorchain/lastblock",
86
+ inboundAddresses: "/thorchain/inbound_addresses",
87
+ asgardVaults: "/thorchain/vaults/asgard",
88
+ mimir: "/thorchain/mimir"
89
+ });
90
+ var DEFAULT_CONFIRM_MAX_LAG_BLOCKS = 50;
91
+ var DEFAULT_CONFIRM_TIMEOUT_MS = 2500;
92
+ var MIN_CONFIRM_TIMEOUT_MS = 100;
93
+ var MAX_CONFIRM_TIMEOUT_MS = 8e3;
94
+ function confirmOptions(o) {
95
+ const maxLagBlocks = o.confirmMaxLagBlocks ?? DEFAULT_CONFIRM_MAX_LAG_BLOCKS;
96
+ if (!Number.isInteger(maxLagBlocks) || maxLagBlocks < 0 || maxLagBlocks > 1e5) {
97
+ throw new Error("thorchain: confirmMaxLagBlocks must be an integer between 0 and 100000");
98
+ }
99
+ const timeoutMs = o.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
100
+ if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_CONFIRM_TIMEOUT_MS || timeoutMs > MAX_CONFIRM_TIMEOUT_MS) {
101
+ throw new Error(`thorchain: confirmTimeoutMs must be an integer between ${MIN_CONFIRM_TIMEOUT_MS} and ${MAX_CONFIRM_TIMEOUT_MS}`);
102
+ }
103
+ return { maxLagBlocks, timeoutMs };
104
+ }
105
+ var HostUnavailable = class extends Error {
106
+ constructor(host, reason) {
107
+ super(`thorchain: ${host} unavailable (${reason})`);
108
+ }
109
+ };
110
+ function paused(entry) {
111
+ return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
112
+ }
113
+ function sameTarget(a, b) {
114
+ if (typeof a !== "string" || typeof b !== "string" || !a || !b) return false;
115
+ return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
116
+ }
117
+ function nodeDeadline(ms, outer) {
118
+ const ctrl = new AbortController();
119
+ const timer = setTimeout(() => ctrl.abort(), Math.max(0, ms));
120
+ const onAbort = () => ctrl.abort();
121
+ if (outer) {
122
+ if (outer.aborted) ctrl.abort();
123
+ else outer.addEventListener("abort", onAbort);
124
+ }
125
+ return {
126
+ signal: ctrl.signal,
127
+ clear() {
128
+ clearTimeout(timer);
129
+ outer?.removeEventListener("abort", onAbort);
130
+ if (!ctrl.signal.aborted) ctrl.abort();
131
+ }
132
+ };
133
+ }
134
+ function sameRequestUrl(actual, expected) {
135
+ if (actual === void 0 || actual === null || actual === "") return true;
136
+ if (typeof actual !== "string") return false;
137
+ try {
138
+ const a = new URL(actual);
139
+ const e = new URL(expected);
140
+ return a.origin === e.origin && a.pathname === e.pathname && a.search === "" && a.hash === "";
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+ async function nodeRead(host, path, clientId, signal, outer) {
146
+ const url = `${host}${path}`;
147
+ let res;
148
+ try {
149
+ res = await fetch(url, { method: "GET", headers: { accept: "application/json", "x-client-id": clientId }, redirect: "error", signal });
150
+ } catch (error) {
151
+ if (outer?.aborted) throw error;
152
+ throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
153
+ }
154
+ if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
155
+ if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`);
156
+ try {
157
+ return await res.json();
158
+ } catch (error) {
159
+ if (outer?.aborted) throw error;
160
+ throw new HostUnavailable(host, `${path} is not JSON`);
161
+ }
162
+ }
163
+ function int64(v) {
164
+ if (typeof v === "number") return Number.isSafeInteger(v) ? v : null;
165
+ if (typeof v === "string" && /^-?\d{1,15}$/.test(v)) return Number(v);
166
+ return null;
167
+ }
168
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
169
+ var INBOUND_STRINGS = ["address", "router", "dust_threshold"];
170
+ var INBOUND_FLAGS = ["halted", "global_trading_paused", "chain_trading_paused"];
171
+ function inboundAddresses(body) {
172
+ if (!Array.isArray(body)) return null;
173
+ const out = [];
174
+ for (const e of body) {
175
+ if (!isObject(e) || typeof e.chain !== "string") return null;
176
+ const entry = { chain: e.chain };
177
+ for (const k of INBOUND_STRINGS) {
178
+ const v = e[k];
179
+ if (v === void 0) continue;
180
+ if (typeof v !== "string") return null;
181
+ entry[k] = v;
182
+ }
183
+ for (const k of INBOUND_FLAGS) {
184
+ const v = e[k];
185
+ if (v === void 0) continue;
186
+ if (typeof v !== "boolean") return null;
187
+ entry[k] = v;
188
+ }
189
+ out.push(entry);
190
+ }
191
+ return out;
192
+ }
193
+ function lastblockHeight(body) {
194
+ if (!Array.isArray(body) || body.length === 0) return null;
195
+ let height = -1;
196
+ for (const entry of body) {
197
+ if (!isObject(entry)) return null;
198
+ const h = int64(entry.thorchain);
199
+ if (h === null || h < 0) return null;
200
+ if (h > height) height = h;
201
+ }
202
+ return height;
203
+ }
204
+ function asgardVaults(body) {
205
+ if (!Array.isArray(body)) return null;
206
+ const out = [];
207
+ for (const v of body) {
208
+ if (!isObject(v)) return null;
209
+ if (typeof v.status !== "string" || typeof v.pub_key !== "string" || !/^thorpub1[0-9a-z]{20,}$/.test(v.pub_key)) return null;
210
+ if (v.type !== void 0 && v.type !== "AsgardVault") return null;
211
+ if (!Array.isArray(v.addresses)) return null;
212
+ const addresses = [];
213
+ for (const a of v.addresses) {
214
+ if (!isObject(a) || typeof a.chain !== "string" || typeof a.address !== "string") return null;
215
+ addresses.push({ chain: a.chain, address: a.address });
216
+ }
217
+ out.push({ pubKey: v.pub_key, status: v.status, addresses });
218
+ }
219
+ return out;
220
+ }
221
+ function mimirFlags(body) {
222
+ if (!isObject(body) || Object.keys(body).length === 0) return null;
223
+ const keys = Object.keys(body).map((k) => [k.toUpperCase(), body[k]]);
224
+ if (keys.some(([, value]) => typeof value !== "number" && typeof value !== "string")) return null;
225
+ return (pubKey) => {
226
+ const pub = pubKey.toUpperCase();
227
+ const compromisedKey = `COMPROMISEDVAULT-${pub}`;
228
+ const insolventPrefix = `SOLVENCYHALTVAULT-${pub}-`;
229
+ let flag = null;
230
+ for (const [key, value] of keys) {
231
+ const kind = key === compromisedKey ? "compromised" : key.startsWith(insolventPrefix) ? "insolvent" : null;
232
+ if (!kind) continue;
233
+ const n = int64(value);
234
+ if (n === null) return "malformed";
235
+ if (n > 0 && flag !== "compromised") flag = kind;
236
+ }
237
+ return flag;
238
+ };
239
+ }
240
+ async function nodeHead(host, read) {
241
+ const [lastblock, inbound] = await Promise.all([read(THORNODE_CONFIRM_PATHS.lastblock), read(THORNODE_CONFIRM_PATHS.inboundAddresses)]);
242
+ const height = lastblockHeight(lastblock);
243
+ if (height === null) throw new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
244
+ return { height, inbound };
245
+ }
246
+ async function vaultVerdict(host, read, inbound, claim) {
247
+ if (!Array.isArray(inbound)) throw new HostUnavailable(host, "inbound_addresses is not a list");
248
+ const entries = inboundAddresses(inbound);
249
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
250
+ const entry = entries.find((e) => e.chain === claim.chain);
251
+ if (paused(entry)) return { ok: false, why: "halted", chain: claim.chain };
252
+ if (claim.destChain !== void 0 && paused(entries.find((e) => e.chain === claim.destChain))) {
253
+ return { ok: false, why: "halted", chain: claim.destChain };
254
+ }
255
+ if (!sameTarget(entry?.address, claim.vault)) {
256
+ const [asgardBody, mimirBody] = await Promise.all([read(THORNODE_CONFIRM_PATHS.asgardVaults), read(THORNODE_CONFIRM_PATHS.mimir)]);
257
+ const vaults = asgardVaults(asgardBody);
258
+ if (!vaults) throw new HostUnavailable(host, "vaults/asgard is not a THORNode vault list");
259
+ const flags = mimirFlags(mimirBody);
260
+ if (!flags) throw new HostUnavailable(host, "mimir is not a THORNode mimir map");
261
+ const holders = vaults.filter((v) => v.addresses.some((a) => a.chain === claim.chain && sameTarget(a.address, claim.vault)));
262
+ const active = holders.find((v) => v.status === "ActiveVault");
263
+ if (!active) {
264
+ if (!holders.length) return { ok: false, why: "another-vault" };
265
+ return { ok: false, why: "not-active", status: /^[A-Za-z]{1,32}$/.test(holders[0].status) ? holders[0].status : "an unknown status" };
266
+ }
267
+ const flag = flags(active.pubKey);
268
+ if (flag === "malformed") throw new HostUnavailable(host, "mimir holds a non-integer vault flag");
269
+ if (flag) return { ok: false, why: "flagged", flag };
270
+ }
271
+ if (claim.router !== void 0 && !sameTarget(entry?.router, claim.router)) return { ok: false, why: "another-router" };
272
+ return { ok: true };
273
+ }
274
+
275
+ // src/provider.ts
81
276
  var HOSTS = [
82
277
  "https://gateway.liquify.com/chain/thorchain_api",
83
278
  "https://thorchain.ibs.team/api"
@@ -93,6 +288,9 @@ var NATIVE = "0x0000000000000000000000000000000000000000";
93
288
  var TOLERANCE_BPS = "300";
94
289
  var MAX_TOLERANCE_BPS = 500n;
95
290
  var QUOTE_VALIDITY_MS = 5 * 6e4;
291
+ var NODE_READ_TIMEOUT_MS = 8e3;
292
+ var CONFIRM_BUDGET_MS = 8e3;
293
+ var LAGGING = /* @__PURE__ */ Symbol("lagging");
96
294
  var THOR_CHAIN = {
97
295
  "eip155:1": "ETH",
98
296
  "eip155:8453": "BASE",
@@ -173,18 +371,13 @@ function destAsset(destChain, destToken) {
173
371
  const asset = thorAsset(destChain, a.symbol);
174
372
  return asset ? { asset, decimals: a.decimals } : void 0;
175
373
  }
176
- var HostUnavailable = class extends Error {
177
- constructor(host, reason) {
178
- super(`thorchain: ${host} unavailable (${reason})`);
179
- }
180
- };
181
374
  async function hostGet(host, path, clientId, signal) {
182
375
  let res;
183
376
  try {
184
377
  res = await fetch(`${host}${path}`, {
185
378
  headers: { accept: "application/json", "x-client-id": clientId },
186
379
  cache: "no-store",
187
- signal: signal ?? (0, import_util2.timeoutSignal)(8e3)
380
+ signal: signal ?? (0, import_util2.timeoutSignal)(NODE_READ_TIMEOUT_MS)
188
381
  });
189
382
  } catch (error) {
190
383
  if (signal?.aborted) throw error;
@@ -219,14 +412,9 @@ function belowThorMinimums(amount1e8, inbound, quote) {
219
412
  async function inboundAddressesOn(host, clientId) {
220
413
  const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
221
414
  if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`);
222
- return res.body;
223
- }
224
- function paused(entry) {
225
- return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
226
- }
227
- function sameTarget(a, b) {
228
- if (!a || !b) return false;
229
- return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
415
+ const entries = inboundAddresses(res.body);
416
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
417
+ return entries;
230
418
  }
231
419
  function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
232
420
  if (memo.includes("|")) return null;
@@ -256,6 +444,25 @@ function thorchainPins(opts = {}) {
256
444
  const nodes = [...new Set((Array.isArray(opts.nodeUrl) ? opts.nodeUrl : opts.nodeUrl ? opts.nodeUrl.split(",") : HOSTS).map((h) => h.trim().replace(/\/$/, "")).filter(Boolean))];
257
445
  for (const n of nodes) if (!/^https:\/\//i.test(n)) throw new Error("thorchain: thorchainPins nodeUrl must use https");
258
446
  const clientId = opts.clientId?.trim() || DEFAULT_CLIENT_ID;
447
+ const { maxLagBlocks, timeoutMs } = confirmOptions(opts);
448
+ const askNode = async (host, claim) => {
449
+ const node = nodeDeadline(timeoutMs);
450
+ const read = (path) => nodeRead(host, path, clientId, node.signal);
451
+ try {
452
+ const head = await nodeHead(host, read);
453
+ try {
454
+ return { height: head.height, verdict: await vaultVerdict(host, read, head.inbound, claim) };
455
+ } catch (error) {
456
+ if (error instanceof HostUnavailable) return { height: head.height, verdict: null };
457
+ throw error;
458
+ }
459
+ } catch (error) {
460
+ if (error instanceof HostUnavailable) return null;
461
+ throw error;
462
+ } finally {
463
+ node.clear();
464
+ }
465
+ };
259
466
  const routersFor = (chain) => [...THORCHAIN_ROUTERS[chain] ?? [], ...opts.trustedRouters?.[chain] ?? []];
260
467
  return Object.freeze({
261
468
  callTargets: routersFor,
@@ -296,22 +503,36 @@ function thorchainPins(opts = {}) {
296
503
  if (!checked) return `the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`;
297
504
  if (memo !== checked.canonical) return `the memo is not the canonical ${checked.canonical}`;
298
505
  if (checked.limit * 10n ** BigInt(dst.decimals) / 100000000n < BigInt(quote.minDestAmount)) return `the memo's limit ${checked.limit} is below the quoted minimum`;
506
+ const sourceChain = THOR_CHAIN[params.sourceChain] ?? params.sourceChain;
507
+ const claim = { chain: sourceChain, destChain: THOR_CHAIN[params.destChain] ?? params.destChain, vault, ...router !== void 0 ? { router } : {} };
508
+ const views = await Promise.all(nodes.map((host) => askNode(host, claim)));
509
+ const top = views.reduce((max, v) => v && v.height > max ? v.height : max, -1);
299
510
  let agreeing = 0;
300
- for (const host of nodes) {
301
- let entries;
302
- try {
303
- entries = await inboundAddressesOn(host, clientId);
304
- } catch (error) {
305
- if (error instanceof HostUnavailable) continue;
306
- throw error;
511
+ let lagging = false;
512
+ for (const view of views) {
513
+ if (!view?.verdict) continue;
514
+ if (top - view.height > maxLagBlocks) {
515
+ lagging = true;
516
+ continue;
517
+ }
518
+ const v = view.verdict;
519
+ if (!v.ok) {
520
+ switch (v.why) {
521
+ case "halted":
522
+ return `${v.chain} is halted / paused`;
523
+ case "another-vault":
524
+ return `a THORNode reports another inbound vault than ${vault}`;
525
+ case "not-active":
526
+ return `a THORNode lists the inbound vault as ${v.status}, not ActiveVault`;
527
+ case "flagged":
528
+ return `a THORNode's mimir flags the inbound vault as ${v.flag}`;
529
+ case "another-router":
530
+ return "a THORNode reports another router";
531
+ }
307
532
  }
308
- const entry = entries.find((e) => e.chain === THOR_CHAIN[params.sourceChain]);
309
- if (paused(entry)) return `${THOR_CHAIN[params.sourceChain]} is halted / paused`;
310
- if (!sameTarget(entry?.address, vault)) return `a THORNode reports another inbound vault than ${vault}`;
311
- if (router !== void 0 && !sameTarget(entry?.router, router)) return "a THORNode reports another router";
312
533
  if (++agreeing >= 2) return null;
313
534
  }
314
- return "two THORNodes could not confirm the inbound vault";
535
+ return lagging ? `two THORNodes within ${maxLagBlocks} blocks of the highest THORChain height they report could not confirm the inbound vault (behind or unreachable)` : "two THORNodes could not confirm the inbound vault";
315
536
  }
316
537
  });
317
538
  }
@@ -351,21 +572,100 @@ function thorchainProvider(config = {}) {
351
572
  routers.set(chain, set);
352
573
  }
353
574
  const pinnedRouter = (chain, router) => !!router && routers.get(chain)?.has(router.toLowerCase()) === true;
354
- const confirmedElsewhere = async (quoteHost, chain, vault, router) => {
575
+ const { maxLagBlocks: confirmMaxLagBlocks, timeoutMs: confirmTimeoutMs } = confirmOptions(config);
576
+ const nodeSnapshot = (host, outer) => new Promise((resolve, reject) => {
577
+ let inbounds;
578
+ let height;
579
+ let heightError;
580
+ let done = false;
581
+ const tip = nodeDeadline(NODE_READ_TIMEOUT_MS, outer);
582
+ const settle = () => {
583
+ if (done || inbounds === void 0 || height === void 0 && heightError === void 0) return;
584
+ done = true;
585
+ resolve(heightError ? { inbounds, heightError } : { inbounds, height });
586
+ };
587
+ const fail = (error) => {
588
+ if (done) return;
589
+ done = true;
590
+ tip.clear();
591
+ reject(error);
592
+ };
593
+ inboundAddressesOn(host, clientId).then((v) => {
594
+ inbounds = v;
595
+ settle();
596
+ }, fail);
597
+ nodeRead(host, THORNODE_CONFIRM_PATHS.lastblock, clientId, tip.signal, outer).then(
598
+ (body) => {
599
+ tip.clear();
600
+ const h = lastblockHeight(body);
601
+ if (h === null) heightError = new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
602
+ else height = h;
603
+ settle();
604
+ },
605
+ (error) => {
606
+ tip.clear();
607
+ if (!(error instanceof HostUnavailable)) return fail(error);
608
+ heightError = error;
609
+ settle();
610
+ }
611
+ );
612
+ });
613
+ const confirmText = (v) => {
614
+ switch (v.why) {
615
+ case "halted":
616
+ return `${v.chain} is halted / paused on the confirming node`;
617
+ case "another-vault":
618
+ return "a second node reports another inbound vault \u2014 refused until they agree";
619
+ case "not-active":
620
+ return `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`;
621
+ case "flagged":
622
+ return `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`;
623
+ case "another-router":
624
+ return "a second node reports another router \u2014 refused until they agree";
625
+ }
626
+ };
627
+ const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
628
+ const read = (path) => nodeRead(host, path, clientId, signal, outer);
629
+ const head = await nodeHead(host, read);
630
+ if (quoteHeight - head.height > confirmMaxLagBlocks) return LAGGING;
631
+ const verdict = await vaultVerdict(host, read, head.inbound, claim);
632
+ return verdict.ok ? null : confirmText(verdict);
633
+ };
634
+ const confirmedElsewhere = async (quoteHost, quoteHeight, chain, destChain, vault, router, deadlineAt, outer) => {
635
+ const claim = {
636
+ chain: THOR_CHAIN[chain] ?? chain,
637
+ ...destChain !== void 0 ? { destChain: THOR_CHAIN[destChain] ?? destChain } : {},
638
+ vault,
639
+ ...router !== void 0 ? { router } : {}
640
+ };
641
+ let lagging = false;
642
+ let asked = 0;
355
643
  for (const host of confirmHosts) {
356
644
  if (host === quoteHost) continue;
357
- let entries;
645
+ const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
646
+ if (budget < MIN_CONFIRM_TIMEOUT_MS) break;
647
+ asked++;
648
+ const node = nodeDeadline(budget, outer);
358
649
  try {
359
- entries = await inboundAddressesOn(host, clientId);
650
+ const verdict = await confirmOn(host, node.signal, outer, quoteHeight, claim);
651
+ if (verdict === LAGGING) {
652
+ lagging = true;
653
+ continue;
654
+ }
655
+ return verdict;
360
656
  } catch (error) {
657
+ if (outer?.aborted) throw error;
361
658
  if (error instanceof HostUnavailable) continue;
362
659
  throw error;
660
+ } finally {
661
+ node.clear();
363
662
  }
364
- const entry = entries.find((e) => e.chain === THOR_CHAIN[chain]);
365
- if (paused(entry)) return `${THOR_CHAIN[chain]} is halted / paused on the confirming node`;
366
- if (!sameTarget(entry?.address, vault)) return "a second node reports another inbound vault \u2014 refused until they agree";
367
- if (router !== void 0 && !sameTarget(entry?.router, router)) return "a second node reports another router \u2014 refused until they agree";
368
- return null;
663
+ }
664
+ if (lagging) {
665
+ return `no confirming node is within ${confirmMaxLagBlocks} blocks of the quoting node's THORChain height (behind or unreachable) \u2014 refused until one catches up`;
666
+ }
667
+ if (asked === 0) {
668
+ 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`;
369
669
  }
370
670
  return "no second node answered to confirm the inbound vault (configure confirmNodeUrl)";
371
671
  };
@@ -458,6 +758,9 @@ function thorchainProvider(config = {}) {
458
758
  let checkedMemo = null;
459
759
  let inbounds = null;
460
760
  let quoteHost = "";
761
+ let quoteHeight = 0;
762
+ let confirmDeadlineAt = 0;
763
+ let heightUnavailable = false;
461
764
  for (const host of hosts) {
462
765
  try {
463
766
  if (limitMode === "liquidity_tolerance_bps") {
@@ -502,8 +805,16 @@ function thorchainProvider(config = {}) {
502
805
  params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`);
503
806
  return null;
504
807
  }
505
- inbounds = await inboundAddressesOn(host, clientId);
808
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
809
+ const snapshot = await nodeSnapshot(host, signal);
810
+ if ("heightError" in snapshot) {
811
+ heightUnavailable = true;
812
+ throw snapshot.heightError;
813
+ }
814
+ inbounds = snapshot.inbounds;
815
+ quoteHeight = snapshot.height;
506
816
  quoteHost = host;
817
+ confirmDeadlineAt = deadlineAt;
507
818
  break;
508
819
  } catch (error) {
509
820
  if (error instanceof HostUnavailable) {
@@ -516,7 +827,9 @@ function thorchainProvider(config = {}) {
516
827
  }
517
828
  }
518
829
  if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
519
- params.onRefusal?.(!inbounds ? "thorchain: inbound addresses unavailable from every node" : "thorchain: no quote from any node");
830
+ params.onRefusal?.(
831
+ 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"
832
+ );
520
833
  return null;
521
834
  }
522
835
  const limit1e8 = checkedMemo.limit;
@@ -538,7 +851,16 @@ function thorchainProvider(config = {}) {
538
851
  params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`);
539
852
  return null;
540
853
  }
541
- const unconfirmed = await confirmedElsewhere(quoteHost, params.sourceChain, q.inbound_address, chainId ? q.router : void 0);
854
+ const unconfirmed = await confirmedElsewhere(
855
+ quoteHost,
856
+ quoteHeight,
857
+ params.sourceChain,
858
+ params.destChain,
859
+ q.inbound_address,
860
+ chainId ? q.router : void 0,
861
+ confirmDeadlineAt,
862
+ signal
863
+ );
542
864
  if (unconfirmed) {
543
865
  params.onRefusal?.(`thorchain: ${unconfirmed}`);
544
866
  return null;
@@ -653,18 +975,42 @@ function thorchainProvider(config = {}) {
653
975
  if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw new Error("thorchain: the quote's router is not one this module trusts");
654
976
  let inbounds = null;
655
977
  let snapshotHost = "";
978
+ let snapshotHeight = 0;
979
+ let confirmDeadlineAt = 0;
980
+ let heightUnavailable = false;
656
981
  for (const host of hosts) {
982
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
657
983
  try {
658
- inbounds = await inboundAddressesOn(host, clientId);
984
+ const snapshot = await nodeSnapshot(host);
985
+ if ("heightError" in snapshot) {
986
+ heightUnavailable = true;
987
+ continue;
988
+ }
989
+ inbounds = snapshot.inbounds;
990
+ snapshotHeight = snapshot.height;
659
991
  snapshotHost = host;
992
+ confirmDeadlineAt = deadlineAt;
660
993
  break;
661
994
  } catch (error) {
995
+ inbounds = null;
662
996
  if (!(error instanceof HostUnavailable)) throw error;
663
997
  }
664
998
  }
665
- if (!inbounds) throw new Error("thorchain: cannot refresh inbound addresses before broadcast");
999
+ if (!inbounds) {
1000
+ throw new Error(
1001
+ 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"
1002
+ );
1003
+ }
666
1004
  const confirm = async () => {
667
- const unconfirmed = await confirmedElsewhere(snapshotHost, data.sourceChain, data.inboundAddress, evmSource ? data.router : void 0);
1005
+ const unconfirmed = await confirmedElsewhere(
1006
+ snapshotHost,
1007
+ snapshotHeight,
1008
+ data.sourceChain,
1009
+ data.destChain,
1010
+ data.inboundAddress,
1011
+ evmSource ? data.router : void 0,
1012
+ confirmDeadlineAt
1013
+ );
668
1014
  if (unconfirmed) throw new Error(`thorchain: ${unconfirmed}`);
669
1015
  };
670
1016
  const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);