@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/README.md CHANGED
@@ -28,12 +28,96 @@ nodes of different operators (Liquify's gateway and the IBS team's node, both ch
28
28
  operator; a local mock needs a second mock there). Node URLs must be `https:` (`allowInsecureNodeUrl` for local
29
29
  development).
30
30
 
31
+ ### How a second node confirms the vault
32
+
33
+ THORChain keeps several asgard vaults active and its inbound address moves among them every few minutes, so a node a
34
+ few blocks behind the quoting node often still names the previous one. A confirming node agrees when the quoted
35
+ vault is:
36
+
37
+ - its current inbound address for the source chain (`/thorchain/inbound_addresses`), or
38
+ - that chain's address of a vault it lists with status `ActiveVault` (`/thorchain/vaults/asgard`, chain matched
39
+ exactly; EVM addresses compare case-insensitively, others exactly) that its mimir (`/thorchain/mimir`) does not
40
+ flag `CompromisedVault-<pubkey>` or `SolvencyHaltVault-<pubkey>-<chain>`. THORNode leaves such vaults out of inbound
41
+ election and does not credit an inbound to a compromised vault.
42
+
43
+ A `RetiringVault` (or any other status) never confirms. The rest stays as strict: on EVM the router in the node's
44
+ `inbound_addresses` must be the quote's; a halt or trading pause on the source or the destination chain refuses; the
45
+ calldata must still pay exactly the quoted vault.
46
+
47
+ A confirming node is skipped for the next one when it trails the quoting node's THORChain height
48
+ (`/thorchain/lastblock`, the `thorchain` field) by more than `confirmMaxLagBlocks` (default 50 ≈ 5 minutes), does not
49
+ answer within `confirmTimeoutMs` (default 2500 ms), redirects (also when the runtime followed the redirect itself, as
50
+ React Native's fetch does: `res.url` must be the URL asked), or answers in another shape. When every node trails, the
51
+ refusal says so ("no confirming node is within 50 blocks …"); when none answers, it is "no second node answered to
52
+ confirm the inbound vault".
53
+
54
+ **Another shape** includes a field of another type. The type of every field read is checked (THORNode's own types):
55
+ in `inbound_addresses` each entry is an object with a string `chain`, `address`, `router` and `dust_threshold` are
56
+ strings and `halted`, `global_trading_paused` and `chain_trading_paused` booleans when present; in `vaults/asgard`
57
+ `status` is a string, `pub_key` a `thorpub1…` string, `addresses` a list of objects with string `chain` and `address`;
58
+ every `mimir` value is a number or a string (int64), and a vault flag an int64; `lastblock`'s `thorchain` is a
59
+ non-negative int64. One such field anywhere in an answer (another chain's entry or another vault included) skips
60
+ the confirming node, and `verifyBuild` skips it the same way. The quoting node's `inbound_addresses` and `lastblock`
61
+ are read the same way: another shape makes that node unavailable and the next `nodeUrl` host is asked, with the
62
+ existing refusals when none is left ("inbound addresses unavailable from every node", "THORChain height
63
+ (/thorchain/lastblock) unavailable from every node that answered the inbound addresses"; before broadcast "cannot
64
+ refresh inbound addresses before broadcast").
65
+
66
+ **Time.** The vault check has its own 8 s budget. It starts when the quoting node's quote is in hand (before
67
+ broadcast: when a node is asked to refresh the inbound snapshot) and covers that node's `inbound_addresses` and
68
+ `lastblock` reads and every confirming node, each getting `confirmTimeoutMs` or what is left; time a failed host took
69
+ before does not count, and no confirming node is asked with less than 100 ms left. When the budget runs out before
70
+ any confirming node was asked, the refusal is "the 8 s confirmation budget ran out before a second node could be asked to confirm the
71
+ inbound vault — retry". `confirmTimeoutMs` bounds confirming nodes only: each read of the quoting node (the quote,
72
+ `inbound_addresses`, `lastblock`) gets 8 s whatever it is. When a node answers `inbound_addresses` but not
73
+ `lastblock`, the refusal names it: "THORChain height (/thorchain/lastblock) unavailable from every node that answered
74
+ the inbound addresses" (before broadcast: "cannot read THORChain height (/thorchain/lastblock) before broadcast from
75
+ any node that answered the inbound addresses"). Worst case without a caller signal: each `nodeUrl` host takes at most
76
+ 8 s for the quote (16 s when a quote without a slippage is retried with a wider tolerance) plus 8 s for its snapshot,
77
+ and the host that quotes is confirmed within the 8 s that started when its quote was in hand: hosts × 16 s, or hosts ×
78
+ 24 s with the retry (32 s / 48 s with the two default hosts); `preflightDeposit` hosts × 8 s. A caller's AbortSignal
79
+ (the router passes one: 8 s per provider, 25 s when it is the only one) replaces the 8 s bound of the quote request;
80
+ when it aborts, the quote request, the quoting node's `lastblock` read and the confirming reads stop at once, and its
81
+ `inbound_addresses` read keeps its own 8 s.
82
+
83
+ **Requests a confirming node gets** (`THORNODE_CONFIRM_PATHS`), all `GET`, no query string, no body, headers
84
+ `accept` and `x-client-id` only. A proxy in front of a confirming node can pass exactly these four and refuse
85
+ anything else:
86
+
87
+ | Path | When |
88
+ | --- | --- |
89
+ | `/thorchain/lastblock` | every confirmation |
90
+ | `/thorchain/inbound_addresses` | every confirmation |
91
+ | `/thorchain/vaults/asgard` | when the node's current inbound is not the quoted vault |
92
+ | `/thorchain/mimir` | when the node's current inbound is not the quoted vault |
93
+
94
+ The quoting node (`nodeUrl`) gets `/thorchain/quote/swap?from_asset=…&to_asset=…&amount=…&destination=…` with
95
+ `tolerance_bps` or `liquidity_tolerance_bps`, `streaming_quantity=1` and, when set, `refund_address`, `affiliate`,
96
+ `affiliate_bps`; then `/thorchain/inbound_addresses` and `GET /thorchain/lastblock` (no query string, so a proxy in
97
+ front of a `nodeUrl` must pass it too); `trackIntent` reads `/thorchain/tx/status/<HASH>` and `/thorchain/tx/<HASH>`.
98
+ The quote's `inbound_addresses` read and the quote itself ask for `cache: "no-store"`, which React Native's fetch
99
+ sends as an extra `?_=<time>` query; `lastblock` and the confirming reads set no cache option.
100
+
31
101
  Behind `remoteProvider`, the app checks the backend's build itself: `remoteProvider({name: "thorchain", baseUrl,
32
102
  ...thorchainPins()})` pins the routers and runs `verifyBuild` on the client — the vault in the router call confirmed
33
103
  by two nodes, the source asset and exact input, and the memo's destination, refund and limit against the app's own
34
104
  request (pass the backend's `affiliate` / `affiliateBps`). The pins open the stub to the Tron deposit route
35
105
  (`depositAddressRoutes`): its vault is the one `verifyBuild` confirms.
36
106
 
107
+ `verifyBuild` confirms the vault by the same rule as the provider's confirming nodes, so an inbound rotation between
108
+ the backend's quote and the device's check does not refuse: current inbound or a non-flagged `ActiveVault` (never
109
+ `RetiringVault` or another status), both chains open, on EVM the node's router equal to the call's. It asks every
110
+ `nodeUrl` node (default: the two public nodes) in parallel on the four `THORNODE_CONFIRM_PATHS` (the same GETs, so the
111
+ same proxy rules), each within `confirmTimeoutMs` (default 2500 ms, so the check takes at most that); a node that
112
+ fails, redirects, answers another shape (a field of another type included, as above), is slower, or trails the
113
+ highest THORChain height the nodes report by more than `confirmMaxLagBlocks` (default 50) is skipped. In `nodeUrl`
114
+ order a node's refusal refuses and two confirming nodes pass. Refusals: "a THORNode reports another inbound vault
115
+ than `<vault>`", "a THORNode reports another router",
116
+ "`<CHAIN>` is halted / paused" (now also the destination chain), "a THORNode lists the inbound vault as `<Status>`, not
117
+ ActiveVault", "a THORNode's mimir flags the inbound vault as compromised" (or `insolvent`), "two THORNodes within 50
118
+ blocks of the highest THORChain height they report could not confirm the inbound vault (behind or unreachable)", "two
119
+ THORNodes could not confirm the inbound vault".
120
+
37
121
  ## Activity
38
122
 
39
123
  Routers and Asgard vaults rotate, so `describeActivity(tx, ctx)` matches no
@@ -10,6 +10,200 @@ import { decodeFunctionData, encodeFunctionData } from "viem";
10
10
  import { timeoutSignal } from "@anywalletkit/protocol-swap2-core/util";
11
11
  import { ccAsset, ccAssetByToken } from "@anywalletkit/swap2-assets";
12
12
  import { bigintToNumber } from "@anywalletkit/core";
13
+
14
+ // src/confirm.ts
15
+ var THORNODE_CONFIRM_PATHS = Object.freeze({
16
+ lastblock: "/thorchain/lastblock",
17
+ inboundAddresses: "/thorchain/inbound_addresses",
18
+ asgardVaults: "/thorchain/vaults/asgard",
19
+ mimir: "/thorchain/mimir"
20
+ });
21
+ var DEFAULT_CONFIRM_MAX_LAG_BLOCKS = 50;
22
+ var DEFAULT_CONFIRM_TIMEOUT_MS = 2500;
23
+ var MIN_CONFIRM_TIMEOUT_MS = 100;
24
+ var MAX_CONFIRM_TIMEOUT_MS = 8e3;
25
+ function confirmOptions(o) {
26
+ const maxLagBlocks = o.confirmMaxLagBlocks ?? DEFAULT_CONFIRM_MAX_LAG_BLOCKS;
27
+ if (!Number.isInteger(maxLagBlocks) || maxLagBlocks < 0 || maxLagBlocks > 1e5) {
28
+ throw new Error("thorchain: confirmMaxLagBlocks must be an integer between 0 and 100000");
29
+ }
30
+ const timeoutMs = o.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
31
+ if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_CONFIRM_TIMEOUT_MS || timeoutMs > MAX_CONFIRM_TIMEOUT_MS) {
32
+ throw new Error(`thorchain: confirmTimeoutMs must be an integer between ${MIN_CONFIRM_TIMEOUT_MS} and ${MAX_CONFIRM_TIMEOUT_MS}`);
33
+ }
34
+ return { maxLagBlocks, timeoutMs };
35
+ }
36
+ var HostUnavailable = class extends Error {
37
+ constructor(host, reason) {
38
+ super(`thorchain: ${host} unavailable (${reason})`);
39
+ }
40
+ };
41
+ function paused(entry) {
42
+ return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
43
+ }
44
+ function sameTarget(a, b) {
45
+ if (typeof a !== "string" || typeof b !== "string" || !a || !b) return false;
46
+ return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
47
+ }
48
+ function nodeDeadline(ms, outer) {
49
+ const ctrl = new AbortController();
50
+ const timer = setTimeout(() => ctrl.abort(), Math.max(0, ms));
51
+ const onAbort = () => ctrl.abort();
52
+ if (outer) {
53
+ if (outer.aborted) ctrl.abort();
54
+ else outer.addEventListener("abort", onAbort);
55
+ }
56
+ return {
57
+ signal: ctrl.signal,
58
+ clear() {
59
+ clearTimeout(timer);
60
+ outer?.removeEventListener("abort", onAbort);
61
+ if (!ctrl.signal.aborted) ctrl.abort();
62
+ }
63
+ };
64
+ }
65
+ function sameRequestUrl(actual, expected) {
66
+ if (actual === void 0 || actual === null || actual === "") return true;
67
+ if (typeof actual !== "string") return false;
68
+ try {
69
+ const a = new URL(actual);
70
+ const e = new URL(expected);
71
+ return a.origin === e.origin && a.pathname === e.pathname && a.search === "" && a.hash === "";
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+ async function nodeRead(host, path, clientId, signal, outer) {
77
+ const url = `${host}${path}`;
78
+ let res;
79
+ try {
80
+ res = await fetch(url, { method: "GET", headers: { accept: "application/json", "x-client-id": clientId }, redirect: "error", signal });
81
+ } catch (error) {
82
+ if (outer?.aborted) throw error;
83
+ throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
84
+ }
85
+ if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
86
+ if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`);
87
+ try {
88
+ return await res.json();
89
+ } catch (error) {
90
+ if (outer?.aborted) throw error;
91
+ throw new HostUnavailable(host, `${path} is not JSON`);
92
+ }
93
+ }
94
+ function int64(v) {
95
+ if (typeof v === "number") return Number.isSafeInteger(v) ? v : null;
96
+ if (typeof v === "string" && /^-?\d{1,15}$/.test(v)) return Number(v);
97
+ return null;
98
+ }
99
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
100
+ var INBOUND_STRINGS = ["address", "router", "dust_threshold"];
101
+ var INBOUND_FLAGS = ["halted", "global_trading_paused", "chain_trading_paused"];
102
+ function inboundAddresses(body) {
103
+ if (!Array.isArray(body)) return null;
104
+ const out = [];
105
+ for (const e of body) {
106
+ if (!isObject(e) || typeof e.chain !== "string") return null;
107
+ const entry = { chain: e.chain };
108
+ for (const k of INBOUND_STRINGS) {
109
+ const v = e[k];
110
+ if (v === void 0) continue;
111
+ if (typeof v !== "string") return null;
112
+ entry[k] = v;
113
+ }
114
+ for (const k of INBOUND_FLAGS) {
115
+ const v = e[k];
116
+ if (v === void 0) continue;
117
+ if (typeof v !== "boolean") return null;
118
+ entry[k] = v;
119
+ }
120
+ out.push(entry);
121
+ }
122
+ return out;
123
+ }
124
+ function lastblockHeight(body) {
125
+ if (!Array.isArray(body) || body.length === 0) return null;
126
+ let height = -1;
127
+ for (const entry of body) {
128
+ if (!isObject(entry)) return null;
129
+ const h = int64(entry.thorchain);
130
+ if (h === null || h < 0) return null;
131
+ if (h > height) height = h;
132
+ }
133
+ return height;
134
+ }
135
+ function asgardVaults(body) {
136
+ if (!Array.isArray(body)) return null;
137
+ const out = [];
138
+ for (const v of body) {
139
+ if (!isObject(v)) return null;
140
+ if (typeof v.status !== "string" || typeof v.pub_key !== "string" || !/^thorpub1[0-9a-z]{20,}$/.test(v.pub_key)) return null;
141
+ if (v.type !== void 0 && v.type !== "AsgardVault") return null;
142
+ if (!Array.isArray(v.addresses)) return null;
143
+ const addresses = [];
144
+ for (const a of v.addresses) {
145
+ if (!isObject(a) || typeof a.chain !== "string" || typeof a.address !== "string") return null;
146
+ addresses.push({ chain: a.chain, address: a.address });
147
+ }
148
+ out.push({ pubKey: v.pub_key, status: v.status, addresses });
149
+ }
150
+ return out;
151
+ }
152
+ function mimirFlags(body) {
153
+ if (!isObject(body) || Object.keys(body).length === 0) return null;
154
+ const keys = Object.keys(body).map((k) => [k.toUpperCase(), body[k]]);
155
+ if (keys.some(([, value]) => typeof value !== "number" && typeof value !== "string")) return null;
156
+ return (pubKey) => {
157
+ const pub = pubKey.toUpperCase();
158
+ const compromisedKey = `COMPROMISEDVAULT-${pub}`;
159
+ const insolventPrefix = `SOLVENCYHALTVAULT-${pub}-`;
160
+ let flag = null;
161
+ for (const [key, value] of keys) {
162
+ const kind = key === compromisedKey ? "compromised" : key.startsWith(insolventPrefix) ? "insolvent" : null;
163
+ if (!kind) continue;
164
+ const n = int64(value);
165
+ if (n === null) return "malformed";
166
+ if (n > 0 && flag !== "compromised") flag = kind;
167
+ }
168
+ return flag;
169
+ };
170
+ }
171
+ async function nodeHead(host, read) {
172
+ const [lastblock, inbound] = await Promise.all([read(THORNODE_CONFIRM_PATHS.lastblock), read(THORNODE_CONFIRM_PATHS.inboundAddresses)]);
173
+ const height = lastblockHeight(lastblock);
174
+ if (height === null) throw new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
175
+ return { height, inbound };
176
+ }
177
+ async function vaultVerdict(host, read, inbound, claim) {
178
+ if (!Array.isArray(inbound)) throw new HostUnavailable(host, "inbound_addresses is not a list");
179
+ const entries = inboundAddresses(inbound);
180
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
181
+ const entry = entries.find((e) => e.chain === claim.chain);
182
+ if (paused(entry)) return { ok: false, why: "halted", chain: claim.chain };
183
+ if (claim.destChain !== void 0 && paused(entries.find((e) => e.chain === claim.destChain))) {
184
+ return { ok: false, why: "halted", chain: claim.destChain };
185
+ }
186
+ if (!sameTarget(entry?.address, claim.vault)) {
187
+ const [asgardBody, mimirBody] = await Promise.all([read(THORNODE_CONFIRM_PATHS.asgardVaults), read(THORNODE_CONFIRM_PATHS.mimir)]);
188
+ const vaults = asgardVaults(asgardBody);
189
+ if (!vaults) throw new HostUnavailable(host, "vaults/asgard is not a THORNode vault list");
190
+ const flags = mimirFlags(mimirBody);
191
+ if (!flags) throw new HostUnavailable(host, "mimir is not a THORNode mimir map");
192
+ const holders = vaults.filter((v) => v.addresses.some((a) => a.chain === claim.chain && sameTarget(a.address, claim.vault)));
193
+ const active = holders.find((v) => v.status === "ActiveVault");
194
+ if (!active) {
195
+ if (!holders.length) return { ok: false, why: "another-vault" };
196
+ return { ok: false, why: "not-active", status: /^[A-Za-z]{1,32}$/.test(holders[0].status) ? holders[0].status : "an unknown status" };
197
+ }
198
+ const flag = flags(active.pubKey);
199
+ if (flag === "malformed") throw new HostUnavailable(host, "mimir holds a non-integer vault flag");
200
+ if (flag) return { ok: false, why: "flagged", flag };
201
+ }
202
+ if (claim.router !== void 0 && !sameTarget(entry?.router, claim.router)) return { ok: false, why: "another-router" };
203
+ return { ok: true };
204
+ }
205
+
206
+ // src/provider.ts
13
207
  var HOSTS = [
14
208
  "https://gateway.liquify.com/chain/thorchain_api",
15
209
  "https://thorchain.ibs.team/api"
@@ -25,6 +219,9 @@ var NATIVE = "0x0000000000000000000000000000000000000000";
25
219
  var TOLERANCE_BPS = "300";
26
220
  var MAX_TOLERANCE_BPS = 500n;
27
221
  var QUOTE_VALIDITY_MS = 5 * 6e4;
222
+ var NODE_READ_TIMEOUT_MS = 8e3;
223
+ var CONFIRM_BUDGET_MS = 8e3;
224
+ var LAGGING = /* @__PURE__ */ Symbol("lagging");
28
225
  var THOR_CHAIN = {
29
226
  "eip155:1": "ETH",
30
227
  "eip155:8453": "BASE",
@@ -105,18 +302,13 @@ function destAsset(destChain, destToken) {
105
302
  const asset = thorAsset(destChain, a.symbol);
106
303
  return asset ? { asset, decimals: a.decimals } : void 0;
107
304
  }
108
- var HostUnavailable = class extends Error {
109
- constructor(host, reason) {
110
- super(`thorchain: ${host} unavailable (${reason})`);
111
- }
112
- };
113
305
  async function hostGet(host, path, clientId, signal) {
114
306
  let res;
115
307
  try {
116
308
  res = await fetch(`${host}${path}`, {
117
309
  headers: { accept: "application/json", "x-client-id": clientId },
118
310
  cache: "no-store",
119
- signal: signal ?? timeoutSignal(8e3)
311
+ signal: signal ?? timeoutSignal(NODE_READ_TIMEOUT_MS)
120
312
  });
121
313
  } catch (error) {
122
314
  if (signal?.aborted) throw error;
@@ -151,14 +343,9 @@ function belowThorMinimums(amount1e8, inbound, quote) {
151
343
  async function inboundAddressesOn(host, clientId) {
152
344
  const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
153
345
  if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`);
154
- return res.body;
155
- }
156
- function paused(entry) {
157
- return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
158
- }
159
- function sameTarget(a, b) {
160
- if (!a || !b) return false;
161
- return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
346
+ const entries = inboundAddresses(res.body);
347
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
348
+ return entries;
162
349
  }
163
350
  function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
164
351
  if (memo.includes("|")) return null;
@@ -188,6 +375,25 @@ function thorchainPins(opts = {}) {
188
375
  const nodes = [...new Set((Array.isArray(opts.nodeUrl) ? opts.nodeUrl : opts.nodeUrl ? opts.nodeUrl.split(",") : HOSTS).map((h) => h.trim().replace(/\/$/, "")).filter(Boolean))];
189
376
  for (const n of nodes) if (!/^https:\/\//i.test(n)) throw new Error("thorchain: thorchainPins nodeUrl must use https");
190
377
  const clientId = opts.clientId?.trim() || DEFAULT_CLIENT_ID;
378
+ const { maxLagBlocks, timeoutMs } = confirmOptions(opts);
379
+ const askNode = async (host, claim) => {
380
+ const node = nodeDeadline(timeoutMs);
381
+ const read = (path) => nodeRead(host, path, clientId, node.signal);
382
+ try {
383
+ const head = await nodeHead(host, read);
384
+ try {
385
+ return { height: head.height, verdict: await vaultVerdict(host, read, head.inbound, claim) };
386
+ } catch (error) {
387
+ if (error instanceof HostUnavailable) return { height: head.height, verdict: null };
388
+ throw error;
389
+ }
390
+ } catch (error) {
391
+ if (error instanceof HostUnavailable) return null;
392
+ throw error;
393
+ } finally {
394
+ node.clear();
395
+ }
396
+ };
191
397
  const routersFor = (chain) => [...THORCHAIN_ROUTERS[chain] ?? [], ...opts.trustedRouters?.[chain] ?? []];
192
398
  return Object.freeze({
193
399
  callTargets: routersFor,
@@ -228,22 +434,36 @@ function thorchainPins(opts = {}) {
228
434
  if (!checked) return `the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`;
229
435
  if (memo !== checked.canonical) return `the memo is not the canonical ${checked.canonical}`;
230
436
  if (checked.limit * 10n ** BigInt(dst.decimals) / 100000000n < BigInt(quote.minDestAmount)) return `the memo's limit ${checked.limit} is below the quoted minimum`;
437
+ const sourceChain = THOR_CHAIN[params.sourceChain] ?? params.sourceChain;
438
+ const claim = { chain: sourceChain, destChain: THOR_CHAIN[params.destChain] ?? params.destChain, vault, ...router !== void 0 ? { router } : {} };
439
+ const views = await Promise.all(nodes.map((host) => askNode(host, claim)));
440
+ const top = views.reduce((max, v) => v && v.height > max ? v.height : max, -1);
231
441
  let agreeing = 0;
232
- for (const host of nodes) {
233
- let entries;
234
- try {
235
- entries = await inboundAddressesOn(host, clientId);
236
- } catch (error) {
237
- if (error instanceof HostUnavailable) continue;
238
- throw error;
442
+ let lagging = false;
443
+ for (const view of views) {
444
+ if (!view?.verdict) continue;
445
+ if (top - view.height > maxLagBlocks) {
446
+ lagging = true;
447
+ continue;
448
+ }
449
+ const v = view.verdict;
450
+ if (!v.ok) {
451
+ switch (v.why) {
452
+ case "halted":
453
+ return `${v.chain} is halted / paused`;
454
+ case "another-vault":
455
+ return `a THORNode reports another inbound vault than ${vault}`;
456
+ case "not-active":
457
+ return `a THORNode lists the inbound vault as ${v.status}, not ActiveVault`;
458
+ case "flagged":
459
+ return `a THORNode's mimir flags the inbound vault as ${v.flag}`;
460
+ case "another-router":
461
+ return "a THORNode reports another router";
462
+ }
239
463
  }
240
- const entry = entries.find((e) => e.chain === THOR_CHAIN[params.sourceChain]);
241
- if (paused(entry)) return `${THOR_CHAIN[params.sourceChain]} is halted / paused`;
242
- if (!sameTarget(entry?.address, vault)) return `a THORNode reports another inbound vault than ${vault}`;
243
- if (router !== void 0 && !sameTarget(entry?.router, router)) return "a THORNode reports another router";
244
464
  if (++agreeing >= 2) return null;
245
465
  }
246
- return "two THORNodes could not confirm the inbound vault";
466
+ 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";
247
467
  }
248
468
  });
249
469
  }
@@ -283,21 +503,100 @@ function thorchainProvider(config = {}) {
283
503
  routers.set(chain, set);
284
504
  }
285
505
  const pinnedRouter = (chain, router) => !!router && routers.get(chain)?.has(router.toLowerCase()) === true;
286
- const confirmedElsewhere = async (quoteHost, chain, vault, router) => {
506
+ const { maxLagBlocks: confirmMaxLagBlocks, timeoutMs: confirmTimeoutMs } = confirmOptions(config);
507
+ const nodeSnapshot = (host, outer) => new Promise((resolve, reject) => {
508
+ let inbounds;
509
+ let height;
510
+ let heightError;
511
+ let done = false;
512
+ const tip = nodeDeadline(NODE_READ_TIMEOUT_MS, outer);
513
+ const settle = () => {
514
+ if (done || inbounds === void 0 || height === void 0 && heightError === void 0) return;
515
+ done = true;
516
+ resolve(heightError ? { inbounds, heightError } : { inbounds, height });
517
+ };
518
+ const fail = (error) => {
519
+ if (done) return;
520
+ done = true;
521
+ tip.clear();
522
+ reject(error);
523
+ };
524
+ inboundAddressesOn(host, clientId).then((v) => {
525
+ inbounds = v;
526
+ settle();
527
+ }, fail);
528
+ nodeRead(host, THORNODE_CONFIRM_PATHS.lastblock, clientId, tip.signal, outer).then(
529
+ (body) => {
530
+ tip.clear();
531
+ const h = lastblockHeight(body);
532
+ if (h === null) heightError = new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
533
+ else height = h;
534
+ settle();
535
+ },
536
+ (error) => {
537
+ tip.clear();
538
+ if (!(error instanceof HostUnavailable)) return fail(error);
539
+ heightError = error;
540
+ settle();
541
+ }
542
+ );
543
+ });
544
+ const confirmText = (v) => {
545
+ switch (v.why) {
546
+ case "halted":
547
+ return `${v.chain} is halted / paused on the confirming node`;
548
+ case "another-vault":
549
+ return "a second node reports another inbound vault \u2014 refused until they agree";
550
+ case "not-active":
551
+ return `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`;
552
+ case "flagged":
553
+ return `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`;
554
+ case "another-router":
555
+ return "a second node reports another router \u2014 refused until they agree";
556
+ }
557
+ };
558
+ const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
559
+ const read = (path) => nodeRead(host, path, clientId, signal, outer);
560
+ const head = await nodeHead(host, read);
561
+ if (quoteHeight - head.height > confirmMaxLagBlocks) return LAGGING;
562
+ const verdict = await vaultVerdict(host, read, head.inbound, claim);
563
+ return verdict.ok ? null : confirmText(verdict);
564
+ };
565
+ const confirmedElsewhere = async (quoteHost, quoteHeight, chain, destChain, vault, router, deadlineAt, outer) => {
566
+ const claim = {
567
+ chain: THOR_CHAIN[chain] ?? chain,
568
+ ...destChain !== void 0 ? { destChain: THOR_CHAIN[destChain] ?? destChain } : {},
569
+ vault,
570
+ ...router !== void 0 ? { router } : {}
571
+ };
572
+ let lagging = false;
573
+ let asked = 0;
287
574
  for (const host of confirmHosts) {
288
575
  if (host === quoteHost) continue;
289
- let entries;
576
+ const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
577
+ if (budget < MIN_CONFIRM_TIMEOUT_MS) break;
578
+ asked++;
579
+ const node = nodeDeadline(budget, outer);
290
580
  try {
291
- entries = await inboundAddressesOn(host, clientId);
581
+ const verdict = await confirmOn(host, node.signal, outer, quoteHeight, claim);
582
+ if (verdict === LAGGING) {
583
+ lagging = true;
584
+ continue;
585
+ }
586
+ return verdict;
292
587
  } catch (error) {
588
+ if (outer?.aborted) throw error;
293
589
  if (error instanceof HostUnavailable) continue;
294
590
  throw error;
591
+ } finally {
592
+ node.clear();
295
593
  }
296
- const entry = entries.find((e) => e.chain === THOR_CHAIN[chain]);
297
- if (paused(entry)) return `${THOR_CHAIN[chain]} is halted / paused on the confirming node`;
298
- if (!sameTarget(entry?.address, vault)) return "a second node reports another inbound vault \u2014 refused until they agree";
299
- if (router !== void 0 && !sameTarget(entry?.router, router)) return "a second node reports another router \u2014 refused until they agree";
300
- return null;
594
+ }
595
+ if (lagging) {
596
+ return `no confirming node is within ${confirmMaxLagBlocks} blocks of the quoting node's THORChain height (behind or unreachable) \u2014 refused until one catches up`;
597
+ }
598
+ if (asked === 0) {
599
+ 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`;
301
600
  }
302
601
  return "no second node answered to confirm the inbound vault (configure confirmNodeUrl)";
303
602
  };
@@ -390,6 +689,9 @@ function thorchainProvider(config = {}) {
390
689
  let checkedMemo = null;
391
690
  let inbounds = null;
392
691
  let quoteHost = "";
692
+ let quoteHeight = 0;
693
+ let confirmDeadlineAt = 0;
694
+ let heightUnavailable = false;
393
695
  for (const host of hosts) {
394
696
  try {
395
697
  if (limitMode === "liquidity_tolerance_bps") {
@@ -434,8 +736,16 @@ function thorchainProvider(config = {}) {
434
736
  params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`);
435
737
  return null;
436
738
  }
437
- inbounds = await inboundAddressesOn(host, clientId);
739
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
740
+ const snapshot = await nodeSnapshot(host, signal);
741
+ if ("heightError" in snapshot) {
742
+ heightUnavailable = true;
743
+ throw snapshot.heightError;
744
+ }
745
+ inbounds = snapshot.inbounds;
746
+ quoteHeight = snapshot.height;
438
747
  quoteHost = host;
748
+ confirmDeadlineAt = deadlineAt;
439
749
  break;
440
750
  } catch (error) {
441
751
  if (error instanceof HostUnavailable) {
@@ -448,7 +758,9 @@ function thorchainProvider(config = {}) {
448
758
  }
449
759
  }
450
760
  if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
451
- params.onRefusal?.(!inbounds ? "thorchain: inbound addresses unavailable from every node" : "thorchain: no quote from any node");
761
+ params.onRefusal?.(
762
+ 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"
763
+ );
452
764
  return null;
453
765
  }
454
766
  const limit1e8 = checkedMemo.limit;
@@ -470,7 +782,16 @@ function thorchainProvider(config = {}) {
470
782
  params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`);
471
783
  return null;
472
784
  }
473
- const unconfirmed = await confirmedElsewhere(quoteHost, params.sourceChain, q.inbound_address, chainId ? q.router : void 0);
785
+ const unconfirmed = await confirmedElsewhere(
786
+ quoteHost,
787
+ quoteHeight,
788
+ params.sourceChain,
789
+ params.destChain,
790
+ q.inbound_address,
791
+ chainId ? q.router : void 0,
792
+ confirmDeadlineAt,
793
+ signal
794
+ );
474
795
  if (unconfirmed) {
475
796
  params.onRefusal?.(`thorchain: ${unconfirmed}`);
476
797
  return null;
@@ -585,18 +906,42 @@ function thorchainProvider(config = {}) {
585
906
  if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw new Error("thorchain: the quote's router is not one this module trusts");
586
907
  let inbounds = null;
587
908
  let snapshotHost = "";
909
+ let snapshotHeight = 0;
910
+ let confirmDeadlineAt = 0;
911
+ let heightUnavailable = false;
588
912
  for (const host of hosts) {
913
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
589
914
  try {
590
- inbounds = await inboundAddressesOn(host, clientId);
915
+ const snapshot = await nodeSnapshot(host);
916
+ if ("heightError" in snapshot) {
917
+ heightUnavailable = true;
918
+ continue;
919
+ }
920
+ inbounds = snapshot.inbounds;
921
+ snapshotHeight = snapshot.height;
591
922
  snapshotHost = host;
923
+ confirmDeadlineAt = deadlineAt;
592
924
  break;
593
925
  } catch (error) {
926
+ inbounds = null;
594
927
  if (!(error instanceof HostUnavailable)) throw error;
595
928
  }
596
929
  }
597
- if (!inbounds) throw new Error("thorchain: cannot refresh inbound addresses before broadcast");
930
+ if (!inbounds) {
931
+ throw new Error(
932
+ 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"
933
+ );
934
+ }
598
935
  const confirm = async () => {
599
- const unconfirmed = await confirmedElsewhere(snapshotHost, data.sourceChain, data.inboundAddress, evmSource ? data.router : void 0);
936
+ const unconfirmed = await confirmedElsewhere(
937
+ snapshotHost,
938
+ snapshotHeight,
939
+ data.sourceChain,
940
+ data.destChain,
941
+ data.inboundAddress,
942
+ evmSource ? data.router : void 0,
943
+ confirmDeadlineAt
944
+ );
600
945
  if (unconfirmed) throw new Error(`thorchain: ${unconfirmed}`);
601
946
  };
602
947
  const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);
@@ -688,8 +1033,9 @@ function thorchainProvider(config = {}) {
688
1033
  }
689
1034
 
690
1035
  export {
1036
+ THORNODE_CONFIRM_PATHS,
691
1037
  THORCHAIN_ROUTERS,
692
1038
  thorchainPins,
693
1039
  thorchainProvider
694
1040
  };
695
- //# sourceMappingURL=chunk-LYJOENYE.js.map
1041
+ //# sourceMappingURL=chunk-AYP7O2NT.js.map