@anywalletkit/protocol-swap2-thorchain 0.3.5 → 0.4.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.
@@ -8,8 +8,212 @@ import {
8
8
  // src/provider.ts
9
9
  import { decodeFunctionData, encodeFunctionData } from "viem";
10
10
  import { timeoutSignal } from "@anywalletkit/protocol-swap2-core/util";
11
+ import { combinedRefusal, httpRefusal as httpRefusal2, refusal, refusalNotes, refusalOf, withReason as withReason2 } from "@anywalletkit/protocol-swap2-core/errors";
11
12
  import { ccAsset, ccAssetByToken } from "@anywalletkit/swap2-assets";
12
13
  import { bigintToNumber } from "@anywalletkit/core";
14
+
15
+ // src/confirm.ts
16
+ import { httpRefusal, withReason } from "@anywalletkit/protocol-swap2-core/errors";
17
+ var THORNODE_CONFIRM_PATHS = Object.freeze({
18
+ lastblock: "/thorchain/lastblock",
19
+ inboundAddresses: "/thorchain/inbound_addresses",
20
+ asgardVaults: "/thorchain/vaults/asgard",
21
+ mimir: "/thorchain/mimir"
22
+ });
23
+ var DEFAULT_CONFIRM_MAX_LAG_BLOCKS = 50;
24
+ var DEFAULT_CONFIRM_TIMEOUT_MS = 2500;
25
+ var MIN_CONFIRM_TIMEOUT_MS = 100;
26
+ var MAX_CONFIRM_TIMEOUT_MS = 8e3;
27
+ function confirmOptions(o) {
28
+ const maxLagBlocks = o.confirmMaxLagBlocks ?? DEFAULT_CONFIRM_MAX_LAG_BLOCKS;
29
+ if (!Number.isInteger(maxLagBlocks) || maxLagBlocks < 0 || maxLagBlocks > 1e5) {
30
+ throw new Error("thorchain: confirmMaxLagBlocks must be an integer between 0 and 100000");
31
+ }
32
+ const timeoutMs = o.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
33
+ if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_CONFIRM_TIMEOUT_MS || timeoutMs > MAX_CONFIRM_TIMEOUT_MS) {
34
+ throw new Error(`thorchain: confirmTimeoutMs must be an integer between ${MIN_CONFIRM_TIMEOUT_MS} and ${MAX_CONFIRM_TIMEOUT_MS}`);
35
+ }
36
+ return { maxLagBlocks, timeoutMs };
37
+ }
38
+ var HostUnavailable = class extends Error {
39
+ /**
40
+ * `info` classifies the failure (`refusalOf` reads it back): what the HTTP status says (429 `rate_limited`, 401 / 403
41
+ * `key_rejected`), else `upstream_unavailable` — unreachable, timed out, a 5xx, redirected or another shape.
42
+ */
43
+ constructor(host, reason, info) {
44
+ super(`thorchain: ${host} unavailable (${reason})`);
45
+ withReason(this, info ?? "upstream_unavailable");
46
+ }
47
+ };
48
+ function statusRefusal(status, retryAfter) {
49
+ return httpRefusal(status, retryAfter) ?? { reason: "upstream_unavailable", httpStatus: status };
50
+ }
51
+ function paused(entry) {
52
+ return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
53
+ }
54
+ function sameTarget(a, b) {
55
+ if (typeof a !== "string" || typeof b !== "string" || !a || !b) return false;
56
+ return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
57
+ }
58
+ function nodeDeadline(ms, outer) {
59
+ const ctrl = new AbortController();
60
+ const timer = setTimeout(() => ctrl.abort(), Math.max(0, ms));
61
+ const onAbort = () => ctrl.abort();
62
+ if (outer) {
63
+ if (outer.aborted) ctrl.abort();
64
+ else outer.addEventListener("abort", onAbort);
65
+ }
66
+ return {
67
+ signal: ctrl.signal,
68
+ clear() {
69
+ clearTimeout(timer);
70
+ outer?.removeEventListener("abort", onAbort);
71
+ if (!ctrl.signal.aborted) ctrl.abort();
72
+ }
73
+ };
74
+ }
75
+ function sameRequestUrl(actual, expected) {
76
+ if (actual === void 0 || actual === null || actual === "") return true;
77
+ if (typeof actual !== "string") return false;
78
+ try {
79
+ const a = new URL(actual);
80
+ const e = new URL(expected);
81
+ return a.origin === e.origin && a.pathname === e.pathname && a.search === "" && a.hash === "";
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+ async function nodeRead(host, path, clientId, signal, outer) {
87
+ const url = `${host}${path}`;
88
+ let res;
89
+ try {
90
+ res = await fetch(url, { method: "GET", headers: { accept: "application/json", "x-client-id": clientId }, redirect: "error", signal });
91
+ } catch (error) {
92
+ if (outer?.aborted) throw error;
93
+ throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
94
+ }
95
+ if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
96
+ if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
97
+ try {
98
+ return await res.json();
99
+ } catch (error) {
100
+ if (outer?.aborted) throw error;
101
+ throw new HostUnavailable(host, `${path} is not JSON`);
102
+ }
103
+ }
104
+ function int64(v) {
105
+ if (typeof v === "number") return Number.isSafeInteger(v) ? v : null;
106
+ if (typeof v === "string" && /^-?\d{1,15}$/.test(v)) return Number(v);
107
+ return null;
108
+ }
109
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
110
+ var INBOUND_STRINGS = ["address", "router", "dust_threshold"];
111
+ var INBOUND_FLAGS = ["halted", "global_trading_paused", "chain_trading_paused"];
112
+ function inboundAddresses(body) {
113
+ if (!Array.isArray(body)) return null;
114
+ const out = [];
115
+ for (const e of body) {
116
+ if (!isObject(e) || typeof e.chain !== "string") return null;
117
+ const entry = { chain: e.chain };
118
+ for (const k of INBOUND_STRINGS) {
119
+ const v = e[k];
120
+ if (v === void 0) continue;
121
+ if (typeof v !== "string") return null;
122
+ entry[k] = v;
123
+ }
124
+ for (const k of INBOUND_FLAGS) {
125
+ const v = e[k];
126
+ if (v === void 0) continue;
127
+ if (typeof v !== "boolean") return null;
128
+ entry[k] = v;
129
+ }
130
+ out.push(entry);
131
+ }
132
+ return out;
133
+ }
134
+ function lastblockHeight(body) {
135
+ if (!Array.isArray(body) || body.length === 0) return null;
136
+ let height = -1;
137
+ for (const entry of body) {
138
+ if (!isObject(entry)) return null;
139
+ const h = int64(entry.thorchain);
140
+ if (h === null || h < 0) return null;
141
+ if (h > height) height = h;
142
+ }
143
+ return height;
144
+ }
145
+ function asgardVaults(body) {
146
+ if (!Array.isArray(body)) return null;
147
+ const out = [];
148
+ for (const v of body) {
149
+ if (!isObject(v)) return null;
150
+ if (typeof v.status !== "string" || typeof v.pub_key !== "string" || !/^thorpub1[0-9a-z]{20,}$/.test(v.pub_key)) return null;
151
+ if (v.type !== void 0 && v.type !== "AsgardVault") return null;
152
+ if (!Array.isArray(v.addresses)) return null;
153
+ const addresses = [];
154
+ for (const a of v.addresses) {
155
+ if (!isObject(a) || typeof a.chain !== "string" || typeof a.address !== "string") return null;
156
+ addresses.push({ chain: a.chain, address: a.address });
157
+ }
158
+ out.push({ pubKey: v.pub_key, status: v.status, addresses });
159
+ }
160
+ return out;
161
+ }
162
+ function mimirFlags(body) {
163
+ if (!isObject(body) || Object.keys(body).length === 0) return null;
164
+ const keys = Object.keys(body).map((k) => [k.toUpperCase(), body[k]]);
165
+ if (keys.some(([, value]) => typeof value !== "number" && typeof value !== "string")) return null;
166
+ return (pubKey) => {
167
+ const pub = pubKey.toUpperCase();
168
+ const compromisedKey = `COMPROMISEDVAULT-${pub}`;
169
+ const insolventPrefix = `SOLVENCYHALTVAULT-${pub}-`;
170
+ let flag = null;
171
+ for (const [key, value] of keys) {
172
+ const kind = key === compromisedKey ? "compromised" : key.startsWith(insolventPrefix) ? "insolvent" : null;
173
+ if (!kind) continue;
174
+ const n = int64(value);
175
+ if (n === null) return "malformed";
176
+ if (n > 0 && flag !== "compromised") flag = kind;
177
+ }
178
+ return flag;
179
+ };
180
+ }
181
+ async function nodeHead(host, read) {
182
+ const [lastblock, inbound] = await Promise.all([read(THORNODE_CONFIRM_PATHS.lastblock), read(THORNODE_CONFIRM_PATHS.inboundAddresses)]);
183
+ const height = lastblockHeight(lastblock);
184
+ if (height === null) throw new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
185
+ return { height, inbound };
186
+ }
187
+ async function vaultVerdict(host, read, inbound, claim) {
188
+ if (!Array.isArray(inbound)) throw new HostUnavailable(host, "inbound_addresses is not a list");
189
+ const entries = inboundAddresses(inbound);
190
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
191
+ const entry = entries.find((e) => e.chain === claim.chain);
192
+ if (paused(entry)) return { ok: false, why: "halted", chain: claim.chain };
193
+ if (claim.destChain !== void 0 && paused(entries.find((e) => e.chain === claim.destChain))) {
194
+ return { ok: false, why: "halted", chain: claim.destChain };
195
+ }
196
+ if (!sameTarget(entry?.address, claim.vault)) {
197
+ const [asgardBody, mimirBody] = await Promise.all([read(THORNODE_CONFIRM_PATHS.asgardVaults), read(THORNODE_CONFIRM_PATHS.mimir)]);
198
+ const vaults = asgardVaults(asgardBody);
199
+ if (!vaults) throw new HostUnavailable(host, "vaults/asgard is not a THORNode vault list");
200
+ const flags = mimirFlags(mimirBody);
201
+ if (!flags) throw new HostUnavailable(host, "mimir is not a THORNode mimir map");
202
+ const holders = vaults.filter((v) => v.addresses.some((a) => a.chain === claim.chain && sameTarget(a.address, claim.vault)));
203
+ const active = holders.find((v) => v.status === "ActiveVault");
204
+ if (!active) {
205
+ if (!holders.length) return { ok: false, why: "another-vault" };
206
+ return { ok: false, why: "not-active", status: /^[A-Za-z]{1,32}$/.test(holders[0].status) ? holders[0].status : "an unknown status" };
207
+ }
208
+ const flag = flags(active.pubKey);
209
+ if (flag === "malformed") throw new HostUnavailable(host, "mimir holds a non-integer vault flag");
210
+ if (flag) return { ok: false, why: "flagged", flag };
211
+ }
212
+ if (claim.router !== void 0 && !sameTarget(entry?.router, claim.router)) return { ok: false, why: "another-router" };
213
+ return { ok: true };
214
+ }
215
+
216
+ // src/provider.ts
13
217
  var HOSTS = [
14
218
  "https://gateway.liquify.com/chain/thorchain_api",
15
219
  "https://thorchain.ibs.team/api"
@@ -25,6 +229,9 @@ var NATIVE = "0x0000000000000000000000000000000000000000";
25
229
  var TOLERANCE_BPS = "300";
26
230
  var MAX_TOLERANCE_BPS = 500n;
27
231
  var QUOTE_VALIDITY_MS = 5 * 6e4;
232
+ var NODE_READ_TIMEOUT_MS = 8e3;
233
+ var CONFIRM_BUDGET_MS = 8e3;
234
+ var LAGGING = /* @__PURE__ */ Symbol("lagging");
28
235
  var THOR_CHAIN = {
29
236
  "eip155:1": "ETH",
30
237
  "eip155:8453": "BASE",
@@ -105,25 +312,20 @@ function destAsset(destChain, destToken) {
105
312
  const asset = thorAsset(destChain, a.symbol);
106
313
  return asset ? { asset, decimals: a.decimals } : void 0;
107
314
  }
108
- var HostUnavailable = class extends Error {
109
- constructor(host, reason) {
110
- super(`thorchain: ${host} unavailable (${reason})`);
111
- }
112
- };
113
315
  async function hostGet(host, path, clientId, signal) {
114
316
  let res;
115
317
  try {
116
318
  res = await fetch(`${host}${path}`, {
117
319
  headers: { accept: "application/json", "x-client-id": clientId },
118
320
  cache: "no-store",
119
- signal: signal ?? timeoutSignal(8e3)
321
+ signal: signal ?? timeoutSignal(NODE_READ_TIMEOUT_MS)
120
322
  });
121
323
  } catch (error) {
122
324
  if (signal?.aborted) throw error;
123
325
  throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
124
326
  }
125
327
  if (res.status >= 500 || res.status === 429 || res.status === 403) {
126
- throw new HostUnavailable(host, `HTTP ${res.status}`);
328
+ throw new HostUnavailable(host, `HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
127
329
  }
128
330
  const body = await res.json().catch(() => null);
129
331
  return { ok: res.ok, status: res.status, body };
@@ -148,35 +350,53 @@ function belowThorMinimums(amount1e8, inbound, quote) {
148
350
  if (min !== void 0 && (!/^\d+$/.test(String(min)) || amount1e8 < BigInt(min))) return "amount below THORChain recommended minimum";
149
351
  return null;
150
352
  }
151
- async function inboundAddressesOn(host, clientId) {
152
- const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
153
- if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`);
154
- return res.body;
353
+ function nodesFailed(failures) {
354
+ const known = failures.filter((f) => f !== void 0);
355
+ if (!known.length || known.length !== failures.length) return { reason: "upstream_unavailable" };
356
+ const combined = combinedRefusal(known.map((f) => ({ message: "", ...f })));
357
+ return combined.reason === "no_route" ? { reason: "upstream_unavailable" } : combined;
155
358
  }
156
- function paused(entry) {
157
- return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
359
+ function quoteRejection(text, status, bodyless) {
360
+ if (/emit asset 0 less than price limit/.test(text)) return { reason: "amount_too_small" };
361
+ if (/emit asset \d+ less than price limit/.test(text)) return { reason: "price_check_failed" };
362
+ if (/halt|paused/i.test(text)) return { reason: "chain_halted" };
363
+ if (/dust|too small|not enough [a-z ]*(?:to pay|for) (?:the )?fee/i.test(text)) return { reason: "amount_too_small" };
364
+ if (/insufficient liquidity|not enough liquidity/i.test(text)) return { reason: "insufficient_liquidity" };
365
+ if (/pool [^ ]* ?(?:does not exist|doesn't exist|not exist|not found|not available|is not available)|no pool/i.test(text)) return { reason: "unsupported_pair" };
366
+ if (/rate limit|too many requests/i.test(text)) return { reason: "rate_limited" };
367
+ if (/api key|unauthori[sz]ed/i.test(text)) return { reason: "key_rejected" };
368
+ const byStatus = status >= 400 ? httpRefusal2(status) : void 0;
369
+ if (byStatus) return byStatus;
370
+ return bodyless ? { reason: "upstream_unavailable", httpStatus: status } : { reason: "no_route" };
158
371
  }
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;
372
+ async function inboundAddressesOn(host, clientId) {
373
+ const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
374
+ if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`, res.ok ? void 0 : statusRefusal(res.status));
375
+ const entries = inboundAddresses(res.body);
376
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
377
+ return entries;
162
378
  }
163
- function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
164
- if (memo.includes("|")) return null;
379
+ function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps, why) {
380
+ const refused = (reason) => {
381
+ why?.(reason);
382
+ return null;
383
+ };
384
+ if (memo.includes("|")) return refused("recipient_unverifiable");
165
385
  const parts = memo.split(":");
166
- if (parts.length !== 4 && parts.length !== 6) return null;
386
+ if (parts.length !== 4 && parts.length !== 6) return refused("policy_refused");
167
387
  const [op, asset, destination, limit, affiliate, bps] = parts;
168
- if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return null;
388
+ if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return refused("policy_refused");
169
389
  const expectedTarget = expectedRefund ? `${expectedDestination}/${expectedRefund}` : expectedDestination;
170
- if (!sameTarget(destination, expectedTarget)) return null;
390
+ if (!sameTarget(destination, expectedTarget)) return refused("recipient_mismatch");
171
391
  if (parts.length === 6) {
172
- if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return null;
392
+ if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return refused("policy_refused");
173
393
  } else if (expectedAffiliate || expectedAffiliateBps) {
174
- return null;
394
+ return refused("policy_refused");
175
395
  }
176
396
  const stream = (limit ?? "").split("/");
177
- if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return null;
397
+ if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return refused("price_check_failed");
178
398
  const floor = BigInt(stream[0] ?? "0");
179
- if (floor <= 0n) return null;
399
+ if (floor <= 0n) return refused("price_check_failed");
180
400
  const canonical = `=:${expectedAsset}:${expectedTarget}:${stream.join("/")}${expectedAffiliate ? `:${expectedAffiliate}:${expectedAffiliateBps}` : ""}`;
181
401
  return { limit: floor, canonical };
182
402
  }
@@ -188,62 +408,107 @@ function thorchainPins(opts = {}) {
188
408
  const nodes = [...new Set((Array.isArray(opts.nodeUrl) ? opts.nodeUrl : opts.nodeUrl ? opts.nodeUrl.split(",") : HOSTS).map((h) => h.trim().replace(/\/$/, "")).filter(Boolean))];
189
409
  for (const n of nodes) if (!/^https:\/\//i.test(n)) throw new Error("thorchain: thorchainPins nodeUrl must use https");
190
410
  const clientId = opts.clientId?.trim() || DEFAULT_CLIENT_ID;
411
+ const { maxLagBlocks, timeoutMs } = confirmOptions(opts);
412
+ const askNode = async (host, claim, failures) => {
413
+ const node = nodeDeadline(timeoutMs);
414
+ const read = (path) => nodeRead(host, path, clientId, node.signal);
415
+ try {
416
+ const head = await nodeHead(host, read);
417
+ try {
418
+ return { height: head.height, verdict: await vaultVerdict(host, read, head.inbound, claim) };
419
+ } catch (error) {
420
+ if (error instanceof HostUnavailable) {
421
+ failures.push(refusalOf(error));
422
+ return { height: head.height, verdict: null };
423
+ }
424
+ throw error;
425
+ }
426
+ } catch (error) {
427
+ if (error instanceof HostUnavailable) {
428
+ failures.push(refusalOf(error));
429
+ return null;
430
+ }
431
+ throw error;
432
+ } finally {
433
+ node.clear();
434
+ }
435
+ };
191
436
  const routersFor = (chain) => [...THORCHAIN_ROUTERS[chain] ?? [], ...opts.trustedRouters?.[chain] ?? []];
437
+ const { note, reasonOf } = refusalNotes();
192
438
  return Object.freeze({
193
439
  callTargets: routersFor,
194
440
  spenders: routersFor,
441
+ reasonOf,
195
442
  // A Tron source is a send to the inbound vault; verifyBuild confirms that vault with two THORNodes (scan 2026-10-28 F9).
196
443
  depositAddressRoutes: true,
197
444
  async verifyBuild(quote, tx, params) {
198
- if (!params) return "the request this quote answered is unknown";
445
+ if (!params) return note("the request this quote answered is unknown", "policy_refused");
199
446
  const src = ccAsset(params.sourceChain, params.sourceToken);
200
447
  const dst = destAsset(params.destChain, params.destToken);
201
- if (!src || !dst) return "unknown source or destination asset";
448
+ if (!src || !dst) return note("unknown source or destination asset", "unsupported_pair");
202
449
  const chainId = CHAIN_ID[params.sourceChain];
203
450
  let vault, memo, router;
204
451
  if (chainId) {
205
- if (!tx.data) return "no router calldata";
452
+ if (!tx.data) return note("no router calldata", "policy_refused");
206
453
  let args;
207
454
  try {
208
455
  const d = decodeFunctionData({ abi: ROUTER_ABI, data: tx.data });
209
- if (d.functionName !== "depositWithExpiry") return `${d.functionName} is not depositWithExpiry`;
456
+ if (d.functionName !== "depositWithExpiry") return note(`${d.functionName} is not depositWithExpiry`, "policy_refused");
210
457
  args = d.args;
211
458
  } catch {
212
- return "the router calldata does not decode";
459
+ return note("the router calldata does not decode", "policy_refused");
213
460
  }
214
- if (!args || args.length !== 5) return "the router calldata does not decode";
461
+ if (!args || args.length !== 5) return note("the router calldata does not decode", "policy_refused");
215
462
  router = tx.to;
216
- if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return `router ${tx.to} is not pinned`;
463
+ if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return note(`router ${tx.to} is not pinned`, "vault_mismatch");
217
464
  vault = String(args[0]);
218
465
  const asset = String(args[1]);
219
- if (!sameTarget(asset, src.contract ?? NATIVE)) return `the router call moves ${asset}, not the source asset`;
220
- if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return `the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input`;
466
+ if (!sameTarget(asset, src.contract ?? NATIVE)) return note(`the router call moves ${asset}, not the source asset`, "policy_refused");
467
+ if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return note(`the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input`, "policy_refused");
221
468
  memo = String(args[3]);
222
469
  } else {
223
- if (!tx.memo || tx.value !== quote.sourceAmount) return "the deposit is not a send of the input with a memo";
470
+ if (!tx.memo || tx.value !== quote.sourceAmount) return note("the deposit is not a send of the input with a memo", "policy_refused");
224
471
  vault = tx.to;
225
472
  memo = tx.memo;
226
473
  }
227
- const checked = verifiedMemo(memo, dst.asset, params.destAddress, params.refundAddress ?? "", opts.affiliate ?? "", opts.affiliateBps ?? "");
228
- if (!checked) return `the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`;
229
- if (memo !== checked.canonical) return `the memo is not the canonical ${checked.canonical}`;
230
- if (checked.limit * 10n ** BigInt(dst.decimals) / 100000000n < BigInt(quote.minDestAmount)) return `the memo's limit ${checked.limit} is below the quoted minimum`;
474
+ let memoRefusal = "recipient_mismatch";
475
+ const checked = verifiedMemo(memo, dst.asset, params.destAddress, params.refundAddress ?? "", opts.affiliate ?? "", opts.affiliateBps ?? "", (why) => {
476
+ memoRefusal = why;
477
+ });
478
+ if (!checked) return note(`the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`, memoRefusal);
479
+ if (memo !== checked.canonical) return note(`the memo is not the canonical ${checked.canonical}`, "policy_refused");
480
+ if (checked.limit * 10n ** BigInt(dst.decimals) / 100000000n < BigInt(quote.minDestAmount)) return note(`the memo's limit ${checked.limit} is below the quoted minimum`, "price_check_failed");
481
+ const sourceChain = THOR_CHAIN[params.sourceChain] ?? params.sourceChain;
482
+ const claim = { chain: sourceChain, destChain: THOR_CHAIN[params.destChain] ?? params.destChain, vault, ...router !== void 0 ? { router } : {} };
483
+ const failures = [];
484
+ const views = await Promise.all(nodes.map((host) => askNode(host, claim, failures)));
485
+ const top = views.reduce((max, v) => v && v.height > max ? v.height : max, -1);
231
486
  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;
487
+ let lagging = false;
488
+ for (const view of views) {
489
+ if (!view?.verdict) continue;
490
+ if (top - view.height > maxLagBlocks) {
491
+ lagging = true;
492
+ continue;
493
+ }
494
+ const v = view.verdict;
495
+ if (!v.ok) {
496
+ switch (v.why) {
497
+ case "halted":
498
+ return note(`${v.chain} is halted / paused`, "chain_halted");
499
+ case "another-vault":
500
+ return note(`a THORNode reports another inbound vault than ${vault}`, "vault_mismatch");
501
+ case "not-active":
502
+ return note(`a THORNode lists the inbound vault as ${v.status}, not ActiveVault`, "vault_mismatch");
503
+ case "flagged":
504
+ return note(`a THORNode's mimir flags the inbound vault as ${v.flag}`, "vault_mismatch");
505
+ case "another-router":
506
+ return note("a THORNode reports another router", "vault_mismatch");
507
+ }
239
508
  }
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
509
  if (++agreeing >= 2) return null;
245
510
  }
246
- return "two THORNodes could not confirm the inbound vault";
511
+ return lagging ? note(`two THORNodes within ${maxLagBlocks} blocks of the highest THORChain height they report could not confirm the inbound vault (behind or unreachable)`, "stale_node") : note("two THORNodes could not confirm the inbound vault", nodesFailed(failures));
247
512
  }
248
513
  });
249
514
  }
@@ -283,23 +548,109 @@ function thorchainProvider(config = {}) {
283
548
  routers.set(chain, set);
284
549
  }
285
550
  const pinnedRouter = (chain, router) => !!router && routers.get(chain)?.has(router.toLowerCase()) === true;
286
- const confirmedElsewhere = async (quoteHost, chain, vault, router) => {
551
+ const { maxLagBlocks: confirmMaxLagBlocks, timeoutMs: confirmTimeoutMs } = confirmOptions(config);
552
+ const nodeSnapshot = (host, outer) => new Promise((resolve, reject) => {
553
+ let inbounds;
554
+ let height;
555
+ let heightError;
556
+ let done = false;
557
+ const tip = nodeDeadline(NODE_READ_TIMEOUT_MS, outer);
558
+ const settle = () => {
559
+ if (done || inbounds === void 0 || height === void 0 && heightError === void 0) return;
560
+ done = true;
561
+ resolve(heightError ? { inbounds, heightError } : { inbounds, height });
562
+ };
563
+ const fail = (error) => {
564
+ if (done) return;
565
+ done = true;
566
+ tip.clear();
567
+ reject(error);
568
+ };
569
+ inboundAddressesOn(host, clientId).then((v) => {
570
+ inbounds = v;
571
+ settle();
572
+ }, fail);
573
+ nodeRead(host, THORNODE_CONFIRM_PATHS.lastblock, clientId, tip.signal, outer).then(
574
+ (body) => {
575
+ tip.clear();
576
+ const h = lastblockHeight(body);
577
+ if (h === null) heightError = new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
578
+ else height = h;
579
+ settle();
580
+ },
581
+ (error) => {
582
+ tip.clear();
583
+ if (!(error instanceof HostUnavailable)) return fail(error);
584
+ heightError = error;
585
+ settle();
586
+ }
587
+ );
588
+ });
589
+ const confirmText = (v) => {
590
+ switch (v.why) {
591
+ case "halted":
592
+ return { text: `${v.chain} is halted / paused on the confirming node`, info: { reason: "chain_halted" } };
593
+ case "another-vault":
594
+ return { text: "a second node reports another inbound vault \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
595
+ case "not-active":
596
+ return { text: `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`, info: { reason: "vault_mismatch" } };
597
+ case "flagged":
598
+ return { text: `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`, info: { reason: "vault_mismatch" } };
599
+ case "another-router":
600
+ return { text: "a second node reports another router \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
601
+ }
602
+ };
603
+ const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
604
+ const read = (path) => nodeRead(host, path, clientId, signal, outer);
605
+ const head = await nodeHead(host, read);
606
+ if (quoteHeight - head.height > confirmMaxLagBlocks) return LAGGING;
607
+ const verdict = await vaultVerdict(host, read, head.inbound, claim);
608
+ return verdict.ok ? null : confirmText(verdict);
609
+ };
610
+ const confirmedElsewhere = async (quoteHost, quoteHeight, chain, destChain, vault, router, deadlineAt, outer) => {
611
+ const claim = {
612
+ chain: THOR_CHAIN[chain] ?? chain,
613
+ ...destChain !== void 0 ? { destChain: THOR_CHAIN[destChain] ?? destChain } : {},
614
+ vault,
615
+ ...router !== void 0 ? { router } : {}
616
+ };
617
+ let lagging = false;
618
+ let asked = 0;
619
+ const failures = [];
287
620
  for (const host of confirmHosts) {
288
621
  if (host === quoteHost) continue;
289
- let entries;
622
+ const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
623
+ if (budget < MIN_CONFIRM_TIMEOUT_MS) {
624
+ failures.push({ reason: "upstream_unavailable" });
625
+ break;
626
+ }
627
+ asked++;
628
+ const node = nodeDeadline(budget, outer);
290
629
  try {
291
- entries = await inboundAddressesOn(host, clientId);
630
+ const verdict = await confirmOn(host, node.signal, outer, quoteHeight, claim);
631
+ if (verdict === LAGGING) {
632
+ lagging = true;
633
+ continue;
634
+ }
635
+ return verdict;
292
636
  } catch (error) {
293
- if (error instanceof HostUnavailable) continue;
637
+ if (outer?.aborted) throw error;
638
+ if (error instanceof HostUnavailable) {
639
+ failures.push(refusalOf(error));
640
+ continue;
641
+ }
294
642
  throw error;
643
+ } finally {
644
+ node.clear();
295
645
  }
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;
301
646
  }
302
- return "no second node answered to confirm the inbound vault (configure confirmNodeUrl)";
647
+ if (lagging) {
648
+ return { text: `no confirming node is within ${confirmMaxLagBlocks} blocks of the quoting node's THORChain height (behind or unreachable) \u2014 refused until one catches up`, info: { reason: "stale_node" } };
649
+ }
650
+ if (asked === 0) {
651
+ return { text: `the ${CONFIRM_BUDGET_MS / 1e3} s confirmation budget ran out before a second node could be asked to confirm the inbound vault \u2014 retry`, info: { reason: "upstream_unavailable" } };
652
+ }
653
+ return { text: "no second node answered to confirm the inbound vault (configure confirmNodeUrl)", info: nodesFailed(failures) };
303
654
  };
304
655
  const configuredAffiliate = config.affiliate ?? "";
305
656
  const configuredAffiliateBps = config.affiliateBps ?? "";
@@ -345,24 +696,27 @@ function thorchainProvider(config = {}) {
345
696
  const fromAsset = thorAsset(params.sourceChain, params.sourceToken);
346
697
  const dst = destAsset(params.destChain, params.destToken);
347
698
  if (!src || !fromAsset || !dst || fromAsset === dst.asset) {
348
- params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`);
699
+ params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`, { reason: "unsupported_pair" });
349
700
  return null;
350
701
  }
351
702
  const amountIn = BigInt(params.amountIn);
352
703
  const amount1e8 = amountIn * 100000000n / 10n ** BigInt(src.decimals);
353
704
  if (amount1e8 <= 0n) return null;
354
705
  if (amount1e8 * 10n ** BigInt(src.decimals) !== amountIn * 100000000n) {
355
- params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`);
706
+ params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`, { reason: "policy_refused" });
356
707
  return null;
357
708
  }
358
709
  const requestedTolerance = params.slippageBps ?? Number(TOLERANCE_BPS);
359
710
  if (!Number.isInteger(requestedTolerance) || requestedTolerance < 0 || requestedTolerance > bigintToNumber(MAX_TOLERANCE_BPS)) {
360
- params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`);
711
+ params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`, { reason: "policy_refused" });
361
712
  return null;
362
713
  }
363
714
  const explicitSlippage = params.slippageBps !== void 0;
364
715
  const liquidityBps = explicitSlippage ? dst.decimals > 8 ? requestedTolerance - 1 : requestedTolerance : 0;
365
716
  const limitMode = liquidityBps > 0 ? "liquidity_tolerance_bps" : "tolerance_bps";
717
+ let quoteStatus = 0;
718
+ let quoteBodyless = false;
719
+ let quoteBodyAborted = false;
366
720
  const quoteOn = async (host, bps, limitMode2 = "tolerance_bps") => {
367
721
  const qs = new URLSearchParams({
368
722
  from_asset: fromAsset,
@@ -383,6 +737,9 @@ function thorchainProvider(config = {}) {
383
737
  }
384
738
  if (params.refundAddress) qs.set("refund_address", params.refundAddress);
385
739
  const res = await hostGet(host, `/thorchain/quote/swap?${qs.toString()}`, clientId, signal);
740
+ quoteStatus = res.status;
741
+ quoteBodyless = res.body === null && !res.ok;
742
+ quoteBodyAborted = res.body === null && signal?.aborted === true;
386
743
  if (Array.isArray(res.body)) return null;
387
744
  return res.body ?? (res.ok ? null : { error: `HTTP ${res.status}` });
388
745
  };
@@ -390,13 +747,17 @@ function thorchainProvider(config = {}) {
390
747
  let checkedMemo = null;
391
748
  let inbounds = null;
392
749
  let quoteHost = "";
750
+ let quoteHeight = 0;
751
+ let confirmDeadlineAt = 0;
752
+ let heightUnavailable = false;
753
+ const hostFailures = [];
393
754
  for (const host of hosts) {
394
755
  try {
395
756
  if (limitMode === "liquidity_tolerance_bps") {
396
757
  q = await quoteOn(host, String(liquidityBps), "liquidity_tolerance_bps");
397
758
  const feeBps = Number(q?.fees?.total_bps);
398
759
  if (Number.isFinite(feeBps) && feeBps > bigintToNumber(MAX_TOLERANCE_BPS)) {
399
- params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${bigintToNumber(MAX_TOLERANCE_BPS) / 100}%)`);
760
+ params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${bigintToNumber(MAX_TOLERANCE_BPS) / 100}%)`, { reason: "policy_refused" });
400
761
  return null;
401
762
  }
402
763
  } else {
@@ -413,32 +774,44 @@ function thorchainProvider(config = {}) {
413
774
  }
414
775
  }
415
776
  if (q?.error || q?.message) {
416
- params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}`);
777
+ params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}`, quoteBodyAborted ? httpRefusal2(quoteStatus) : quoteRejection(String(q.error ?? q.message), quoteStatus, quoteBodyless));
417
778
  return null;
418
779
  }
419
780
  if (!q || !q.expected_amount_out || !q.inbound_address || !q.memo || !q.expiry) {
420
- params.onRefusal?.(`thorchain: incomplete quote from ${host} (expected_amount_out / inbound_address / memo / expiry missing)`);
781
+ params.onRefusal?.(`thorchain: incomplete quote from ${host} (expected_amount_out / inbound_address / memo / expiry missing)`, quoteBodyAborted ? void 0 : { reason: "upstream_unavailable", ...quoteStatus >= 300 ? { httpStatus: quoteStatus } : {} });
421
782
  return null;
422
783
  }
423
784
  if (chainId ? !q.router : !!q.router) {
424
- params.onRefusal?.(chainId ? "thorchain: quote has no router for an EVM source" : "thorchain: quote names a router for a deposit source \u2014 execution model changed");
785
+ params.onRefusal?.(chainId ? "thorchain: quote has no router for an EVM source" : "thorchain: quote names a router for a deposit source \u2014 execution model changed", { reason: chainId ? "upstream_unavailable" : "policy_refused" });
425
786
  return null;
426
787
  }
427
- checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps);
788
+ let memoRefusal = "recipient_mismatch";
789
+ checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps, (why) => {
790
+ memoRefusal = why;
791
+ });
428
792
  if (!checkedMemo) {
429
- params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`);
793
+ params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`, { reason: memoRefusal });
430
794
  return null;
431
795
  }
432
796
  const floor1e8 = params.minAmountOut !== void 0 ? toThorUnitsCeil(BigInt(params.minAmountOut), dst.decimals) : void 0;
433
797
  if (floor1e8 !== void 0 && checkedMemo.limit < floor1e8) {
434
- params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`);
798
+ params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`, { reason: "price_check_failed" });
435
799
  return null;
436
800
  }
437
- inbounds = await inboundAddressesOn(host, clientId);
801
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
802
+ const snapshot = await nodeSnapshot(host, signal);
803
+ if ("heightError" in snapshot) {
804
+ heightUnavailable = true;
805
+ throw snapshot.heightError;
806
+ }
807
+ inbounds = snapshot.inbounds;
808
+ quoteHeight = snapshot.height;
438
809
  quoteHost = host;
810
+ confirmDeadlineAt = deadlineAt;
439
811
  break;
440
812
  } catch (error) {
441
813
  if (error instanceof HostUnavailable) {
814
+ hostFailures.push(refusalOf(error));
442
815
  q = null;
443
816
  checkedMemo = null;
444
817
  inbounds = null;
@@ -448,60 +821,72 @@ function thorchainProvider(config = {}) {
448
821
  }
449
822
  }
450
823
  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");
824
+ params.onRefusal?.(
825
+ 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",
826
+ inbounds ? { reason: "no_route" } : nodesFailed(hostFailures)
827
+ );
452
828
  return null;
453
829
  }
454
830
  const limit1e8 = checkedMemo.limit;
455
831
  const srcInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.sourceChain]);
456
832
  const dstInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.destChain]);
457
833
  if (paused(srcInbound) || paused(dstInbound)) {
458
- params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`);
834
+ params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`, { reason: "chain_halted" });
459
835
  return null;
460
836
  }
461
837
  if (!sameTarget(srcInbound?.address, q.inbound_address)) {
462
- params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry");
838
+ params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry", { reason: "vault_mismatch" });
463
839
  return null;
464
840
  }
465
841
  if (chainId && !sameTarget(srcInbound?.router, q.router)) {
466
- params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry");
842
+ params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry", { reason: "vault_mismatch" });
467
843
  return null;
468
844
  }
469
845
  if (chainId && !pinnedRouter(params.sourceChain, q.router)) {
470
- params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`);
846
+ params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`, { reason: "vault_mismatch" });
471
847
  return null;
472
848
  }
473
- const unconfirmed = await confirmedElsewhere(quoteHost, params.sourceChain, q.inbound_address, chainId ? q.router : void 0);
849
+ const unconfirmed = await confirmedElsewhere(
850
+ quoteHost,
851
+ quoteHeight,
852
+ params.sourceChain,
853
+ params.destChain,
854
+ q.inbound_address,
855
+ chainId ? q.router : void 0,
856
+ confirmDeadlineAt,
857
+ signal
858
+ );
474
859
  if (unconfirmed) {
475
- params.onRefusal?.(`thorchain: ${unconfirmed}`);
860
+ params.onRefusal?.(`thorchain: ${unconfirmed.text}`, unconfirmed.info);
476
861
  return null;
477
862
  }
478
863
  const tooSmall = belowThorMinimums(amount1e8, srcInbound, q);
479
864
  if (tooSmall) {
480
- params.onRefusal?.(`thorchain: ${tooSmall}`);
865
+ params.onRefusal?.(`thorchain: ${tooSmall}`, { reason: "amount_too_small" });
481
866
  return null;
482
867
  }
483
868
  const outUnits = BigInt(q.expected_amount_out) * 10n ** BigInt(dst.decimals) / 100000000n;
484
869
  if (outUnits <= 0n) {
485
- params.onRefusal?.("thorchain: expected output rounds to zero");
870
+ params.onRefusal?.("thorchain: expected output rounds to zero", { reason: "amount_too_small" });
486
871
  return null;
487
872
  }
488
873
  const memoMinUnits = limit1e8 * 10n ** BigInt(dst.decimals) / 100000000n;
489
874
  const minUnits = memoMinUnits;
490
875
  if (minUnits <= 0n || minUnits > outUnits) {
491
- params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`);
876
+ params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`, { reason: "price_check_failed" });
492
877
  return null;
493
878
  }
494
879
  if (params.slippageBps !== void 0) {
495
880
  const slippageFloor = outUnits * BigInt(1e4 - params.slippageBps) / 10000n;
496
881
  if (minUnits < slippageFloor) {
497
- params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`);
882
+ params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`, { reason: "price_check_failed" });
498
883
  return null;
499
884
  }
500
885
  }
501
886
  const nowSeconds = Math.floor(Date.now() / 1e3);
502
887
  const executionExpiry = Math.min(q.expiry, nowSeconds + 15 * 60);
503
888
  if (!Number.isInteger(executionExpiry) || executionExpiry <= nowSeconds + 30) {
504
- params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`);
889
+ params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`, { reason: "quote_expired" });
505
890
  return null;
506
891
  }
507
892
  const assetAddr = src.contract ? src.contract : NATIVE;
@@ -557,7 +942,7 @@ function thorchainProvider(config = {}) {
557
942
  if (!quote.txRequest) {
558
943
  const sourceChain = quote.providerData?.sourceChain ?? "";
559
944
  if (!isDepositSource(sourceChain) || !quote.depositAddress || !quote.depositMemo) {
560
- throw new Error("thorchain: deposit-address execution is only supported for an opted-in Tron source");
945
+ throw refusal("unsupported_pair", "thorchain: deposit-address execution is only supported for an opted-in Tron source");
561
946
  }
562
947
  return { chain: sourceChain, to: quote.depositAddress, value: quote.sourceAmount, memo: quote.depositMemo };
563
948
  }
@@ -574,67 +959,96 @@ function thorchainProvider(config = {}) {
574
959
  async preflightDeposit(quote, tx) {
575
960
  const data = quote.providerData;
576
961
  if (!data?.sourceChain || !data.destChain || !data.inboundAddress || !data.assetAddress || !data.expiry || !data.memo) {
577
- throw new Error("thorchain: quote is missing execution-bound metadata");
962
+ throw refusal("policy_refused", "thorchain: quote is missing execution-bound metadata");
578
963
  }
579
964
  const evmSource = CHAIN_ID[data.sourceChain] !== void 0;
580
- if (evmSource && !data.router) throw new Error("thorchain: quote is missing execution-bound metadata");
581
- if (!evmSource && !isDepositSource(data.sourceChain)) throw new Error("thorchain: source chain has no executor");
965
+ if (evmSource && !data.router) throw refusal("policy_refused", "thorchain: quote is missing execution-bound metadata");
966
+ if (!evmSource && !isDepositSource(data.sourceChain)) throw refusal("unsupported_pair", "thorchain: source chain has no executor");
582
967
  if (!/^\d+$/.test(data.expiry) || BigInt(data.expiry) <= BigInt(Math.floor(Date.now() / 1e3))) {
583
- throw new Error("thorchain: quote expired before broadcast");
968
+ throw refusal("quote_expired", "thorchain: quote expired before broadcast");
584
969
  }
585
- if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw new Error("thorchain: the quote's router is not one this module trusts");
970
+ if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw refusal("vault_mismatch", "thorchain: the quote's router is not one this module trusts");
586
971
  let inbounds = null;
587
972
  let snapshotHost = "";
973
+ let snapshotHeight = 0;
974
+ let confirmDeadlineAt = 0;
975
+ let heightUnavailable = false;
976
+ const hostFailures = [];
588
977
  for (const host of hosts) {
978
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
589
979
  try {
590
- inbounds = await inboundAddressesOn(host, clientId);
980
+ const snapshot = await nodeSnapshot(host);
981
+ if ("heightError" in snapshot) {
982
+ heightUnavailable = true;
983
+ hostFailures.push(refusalOf(snapshot.heightError));
984
+ continue;
985
+ }
986
+ inbounds = snapshot.inbounds;
987
+ snapshotHeight = snapshot.height;
591
988
  snapshotHost = host;
989
+ confirmDeadlineAt = deadlineAt;
592
990
  break;
593
991
  } catch (error) {
992
+ inbounds = null;
594
993
  if (!(error instanceof HostUnavailable)) throw error;
994
+ hostFailures.push(refusalOf(error));
595
995
  }
596
996
  }
597
- if (!inbounds) throw new Error("thorchain: cannot refresh inbound addresses before broadcast");
997
+ if (!inbounds) {
998
+ throw withReason2(new Error(
999
+ 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"
1000
+ ), nodesFailed(hostFailures));
1001
+ }
598
1002
  const confirm = async () => {
599
- const unconfirmed = await confirmedElsewhere(snapshotHost, data.sourceChain, data.inboundAddress, evmSource ? data.router : void 0);
600
- if (unconfirmed) throw new Error(`thorchain: ${unconfirmed}`);
1003
+ const unconfirmed = await confirmedElsewhere(
1004
+ snapshotHost,
1005
+ snapshotHeight,
1006
+ data.sourceChain,
1007
+ data.destChain,
1008
+ data.inboundAddress,
1009
+ evmSource ? data.router : void 0,
1010
+ confirmDeadlineAt
1011
+ );
1012
+ if (unconfirmed) throw withReason2(new Error(`thorchain: ${unconfirmed.text}`), unconfirmed.info);
601
1013
  };
602
1014
  const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);
603
1015
  const dst = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.destChain]);
604
- if (paused(src) || paused(dst)) throw new Error("thorchain: source or destination chain trading is paused");
605
- if (!sameTarget(src?.address, data.inboundAddress)) throw new Error("thorchain: inbound vault rotated after quote");
1016
+ if (paused(src) || paused(dst)) throw refusal("chain_halted", "thorchain: source or destination chain trading is paused");
1017
+ if (!sameTarget(src?.address, data.inboundAddress)) throw refusal("vault_mismatch", "thorchain: inbound vault rotated after quote");
606
1018
  if (!evmSource) {
607
1019
  if (tx.data !== void 0 || tx.approvalAddress !== void 0 || tx.bodyBoc !== void 0 || tx.typedData !== void 0 || tx.serializedTx !== void 0 || tx.stateInitBoc !== void 0) {
608
- throw new Error("thorchain: deposit-address execution must be a plain send with memo");
1020
+ throw refusal("policy_refused", "thorchain: deposit-address execution must be a plain send with memo");
609
1021
  }
610
1022
  if (!sameTarget(tx.to, data.inboundAddress) || tx.memo !== data.memo || tx.value !== quote.sourceAmount || tx.chain && tx.chain !== data.sourceChain) {
611
- throw new Error("thorchain: deposit does not match the validated quote");
1023
+ throw refusal(!sameTarget(tx.to, data.inboundAddress) ? "vault_mismatch" : "policy_refused", "thorchain: deposit does not match the validated quote");
612
1024
  }
613
1025
  const srcAsset = ccAssetByToken(data.sourceChain, sameTarget(data.assetAddress, NATIVE) ? "" : data.assetAddress);
614
- if (!srcAsset) throw new Error("thorchain: unknown source asset");
1026
+ if (!srcAsset) throw refusal("unsupported_pair", "thorchain: unknown source asset");
615
1027
  const amount1e8 = BigInt(quote.sourceAmount) * 100000000n / 10n ** BigInt(srcAsset.decimals);
616
1028
  const tooSmall = belowThorMinimums(amount1e8, src, null);
617
- if (tooSmall) throw new Error(`thorchain: ${tooSmall}`);
1029
+ if (tooSmall) throw refusal("amount_too_small", `thorchain: ${tooSmall}`);
618
1030
  await confirm();
619
1031
  return;
620
1032
  }
621
1033
  if (!sameTarget(src?.router, data.router) || !sameTarget(tx.to, data.router)) {
622
- throw new Error("thorchain: router rotated after quote");
1034
+ throw refusal("vault_mismatch", "thorchain: router rotated after quote");
623
1035
  }
624
- if (!tx.data) throw new Error("thorchain: missing depositWithExpiry calldata");
1036
+ if (!tx.data) throw refusal("policy_refused", "thorchain: missing depositWithExpiry calldata");
625
1037
  let decoded;
626
1038
  try {
627
1039
  decoded = decodeFunctionData({ abi: ROUTER_ABI, data: tx.data });
628
1040
  } catch {
629
- throw new Error("thorchain: malformed depositWithExpiry calldata");
1041
+ throw refusal("policy_refused", "thorchain: malformed depositWithExpiry calldata");
630
1042
  }
631
1043
  const args = decoded.args;
632
1044
  if (decoded.functionName !== "depositWithExpiry" || !args || args.length !== 5 || !sameTarget(String(args[0]), data.inboundAddress) || !sameTarget(String(args[1]), data.assetAddress) || BigInt(args[2]) !== BigInt(quote.sourceAmount) || String(args[3]) !== data.memo || BigInt(args[4]) !== BigInt(data.expiry) || tx.chainId !== CHAIN_ID[data.sourceChain]) {
633
- throw new Error("thorchain: calldata does not match the validated quote");
1045
+ const vaultChanged = decoded.functionName === "depositWithExpiry" && !!args && args.length === 5 && !sameTarget(String(args[0]), data.inboundAddress);
1046
+ throw refusal(vaultChanged ? "vault_mismatch" : "policy_refused", "thorchain: calldata does not match the validated quote");
634
1047
  }
635
1048
  const tokenSource = !sameTarget(data.assetAddress, NATIVE);
636
1049
  if (tx.value !== (tokenSource ? "0" : quote.sourceAmount) || (tokenSource ? !sameTarget(tx.approvalAddress, data.router) : tx.approvalAddress !== void 0)) {
637
- throw new Error("thorchain: value/approval does not match the validated source asset");
1050
+ const spenderChanged = tokenSource && tx.approvalAddress !== void 0 && !sameTarget(tx.approvalAddress, data.router);
1051
+ throw refusal(spenderChanged ? "vault_mismatch" : "policy_refused", "thorchain: value/approval does not match the validated source asset");
638
1052
  }
639
1053
  await confirm();
640
1054
  },
@@ -688,8 +1102,9 @@ function thorchainProvider(config = {}) {
688
1102
  }
689
1103
 
690
1104
  export {
1105
+ THORNODE_CONFIRM_PATHS,
691
1106
  THORCHAIN_ROUTERS,
692
1107
  thorchainPins,
693
1108
  thorchainProvider
694
1109
  };
695
- //# sourceMappingURL=chunk-LYJOENYE.js.map
1110
+ //# sourceMappingURL=chunk-EIAXGWNG.js.map