@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.
package/dist/provider.cjs CHANGED
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var provider_exports = {};
22
22
  __export(provider_exports, {
23
23
  THORCHAIN_ROUTERS: () => THORCHAIN_ROUTERS,
24
+ THORNODE_CONFIRM_PATHS: () => THORNODE_CONFIRM_PATHS,
24
25
  thorchainPins: () => thorchainPins,
25
26
  thorchainProvider: () => thorchainProvider
26
27
  });
@@ -68,8 +69,212 @@ function describeThorchainActivity(tx, ctx) {
68
69
  // src/provider.ts
69
70
  var import_viem = require("viem");
70
71
  var import_util2 = require("@anywalletkit/protocol-swap2-core/util");
72
+ var import_errors2 = require("@anywalletkit/protocol-swap2-core/errors");
71
73
  var import_swap2_assets = require("@anywalletkit/swap2-assets");
72
74
  var import_core = require("@anywalletkit/core");
75
+
76
+ // src/confirm.ts
77
+ var import_errors = require("@anywalletkit/protocol-swap2-core/errors");
78
+ var THORNODE_CONFIRM_PATHS = Object.freeze({
79
+ lastblock: "/thorchain/lastblock",
80
+ inboundAddresses: "/thorchain/inbound_addresses",
81
+ asgardVaults: "/thorchain/vaults/asgard",
82
+ mimir: "/thorchain/mimir"
83
+ });
84
+ var DEFAULT_CONFIRM_MAX_LAG_BLOCKS = 50;
85
+ var DEFAULT_CONFIRM_TIMEOUT_MS = 2500;
86
+ var MIN_CONFIRM_TIMEOUT_MS = 100;
87
+ var MAX_CONFIRM_TIMEOUT_MS = 8e3;
88
+ function confirmOptions(o) {
89
+ const maxLagBlocks = o.confirmMaxLagBlocks ?? DEFAULT_CONFIRM_MAX_LAG_BLOCKS;
90
+ if (!Number.isInteger(maxLagBlocks) || maxLagBlocks < 0 || maxLagBlocks > 1e5) {
91
+ throw new Error("thorchain: confirmMaxLagBlocks must be an integer between 0 and 100000");
92
+ }
93
+ const timeoutMs = o.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
94
+ if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_CONFIRM_TIMEOUT_MS || timeoutMs > MAX_CONFIRM_TIMEOUT_MS) {
95
+ throw new Error(`thorchain: confirmTimeoutMs must be an integer between ${MIN_CONFIRM_TIMEOUT_MS} and ${MAX_CONFIRM_TIMEOUT_MS}`);
96
+ }
97
+ return { maxLagBlocks, timeoutMs };
98
+ }
99
+ var HostUnavailable = class extends Error {
100
+ /**
101
+ * `info` classifies the failure (`refusalOf` reads it back): what the HTTP status says (429 `rate_limited`, 401 / 403
102
+ * `key_rejected`), else `upstream_unavailable` — unreachable, timed out, a 5xx, redirected or another shape.
103
+ */
104
+ constructor(host, reason, info) {
105
+ super(`thorchain: ${host} unavailable (${reason})`);
106
+ (0, import_errors.withReason)(this, info ?? "upstream_unavailable");
107
+ }
108
+ };
109
+ function statusRefusal(status, retryAfter) {
110
+ return (0, import_errors.httpRefusal)(status, retryAfter) ?? { reason: "upstream_unavailable", httpStatus: status };
111
+ }
112
+ function paused(entry) {
113
+ return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
114
+ }
115
+ function sameTarget(a, b) {
116
+ if (typeof a !== "string" || typeof b !== "string" || !a || !b) return false;
117
+ return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
118
+ }
119
+ function nodeDeadline(ms, outer) {
120
+ const ctrl = new AbortController();
121
+ const timer = setTimeout(() => ctrl.abort(), Math.max(0, ms));
122
+ const onAbort = () => ctrl.abort();
123
+ if (outer) {
124
+ if (outer.aborted) ctrl.abort();
125
+ else outer.addEventListener("abort", onAbort);
126
+ }
127
+ return {
128
+ signal: ctrl.signal,
129
+ clear() {
130
+ clearTimeout(timer);
131
+ outer?.removeEventListener("abort", onAbort);
132
+ if (!ctrl.signal.aborted) ctrl.abort();
133
+ }
134
+ };
135
+ }
136
+ function sameRequestUrl(actual, expected) {
137
+ if (actual === void 0 || actual === null || actual === "") return true;
138
+ if (typeof actual !== "string") return false;
139
+ try {
140
+ const a = new URL(actual);
141
+ const e = new URL(expected);
142
+ return a.origin === e.origin && a.pathname === e.pathname && a.search === "" && a.hash === "";
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+ async function nodeRead(host, path, clientId, signal, outer) {
148
+ const url = `${host}${path}`;
149
+ let res;
150
+ try {
151
+ res = await fetch(url, { method: "GET", headers: { accept: "application/json", "x-client-id": clientId }, redirect: "error", signal });
152
+ } catch (error) {
153
+ if (outer?.aborted) throw error;
154
+ throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
155
+ }
156
+ if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
157
+ if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
158
+ try {
159
+ return await res.json();
160
+ } catch (error) {
161
+ if (outer?.aborted) throw error;
162
+ throw new HostUnavailable(host, `${path} is not JSON`);
163
+ }
164
+ }
165
+ function int64(v) {
166
+ if (typeof v === "number") return Number.isSafeInteger(v) ? v : null;
167
+ if (typeof v === "string" && /^-?\d{1,15}$/.test(v)) return Number(v);
168
+ return null;
169
+ }
170
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
171
+ var INBOUND_STRINGS = ["address", "router", "dust_threshold"];
172
+ var INBOUND_FLAGS = ["halted", "global_trading_paused", "chain_trading_paused"];
173
+ function inboundAddresses(body) {
174
+ if (!Array.isArray(body)) return null;
175
+ const out = [];
176
+ for (const e of body) {
177
+ if (!isObject(e) || typeof e.chain !== "string") return null;
178
+ const entry = { chain: e.chain };
179
+ for (const k of INBOUND_STRINGS) {
180
+ const v = e[k];
181
+ if (v === void 0) continue;
182
+ if (typeof v !== "string") return null;
183
+ entry[k] = v;
184
+ }
185
+ for (const k of INBOUND_FLAGS) {
186
+ const v = e[k];
187
+ if (v === void 0) continue;
188
+ if (typeof v !== "boolean") return null;
189
+ entry[k] = v;
190
+ }
191
+ out.push(entry);
192
+ }
193
+ return out;
194
+ }
195
+ function lastblockHeight(body) {
196
+ if (!Array.isArray(body) || body.length === 0) return null;
197
+ let height = -1;
198
+ for (const entry of body) {
199
+ if (!isObject(entry)) return null;
200
+ const h = int64(entry.thorchain);
201
+ if (h === null || h < 0) return null;
202
+ if (h > height) height = h;
203
+ }
204
+ return height;
205
+ }
206
+ function asgardVaults(body) {
207
+ if (!Array.isArray(body)) return null;
208
+ const out = [];
209
+ for (const v of body) {
210
+ if (!isObject(v)) return null;
211
+ if (typeof v.status !== "string" || typeof v.pub_key !== "string" || !/^thorpub1[0-9a-z]{20,}$/.test(v.pub_key)) return null;
212
+ if (v.type !== void 0 && v.type !== "AsgardVault") return null;
213
+ if (!Array.isArray(v.addresses)) return null;
214
+ const addresses = [];
215
+ for (const a of v.addresses) {
216
+ if (!isObject(a) || typeof a.chain !== "string" || typeof a.address !== "string") return null;
217
+ addresses.push({ chain: a.chain, address: a.address });
218
+ }
219
+ out.push({ pubKey: v.pub_key, status: v.status, addresses });
220
+ }
221
+ return out;
222
+ }
223
+ function mimirFlags(body) {
224
+ if (!isObject(body) || Object.keys(body).length === 0) return null;
225
+ const keys = Object.keys(body).map((k) => [k.toUpperCase(), body[k]]);
226
+ if (keys.some(([, value]) => typeof value !== "number" && typeof value !== "string")) return null;
227
+ return (pubKey) => {
228
+ const pub = pubKey.toUpperCase();
229
+ const compromisedKey = `COMPROMISEDVAULT-${pub}`;
230
+ const insolventPrefix = `SOLVENCYHALTVAULT-${pub}-`;
231
+ let flag = null;
232
+ for (const [key, value] of keys) {
233
+ const kind = key === compromisedKey ? "compromised" : key.startsWith(insolventPrefix) ? "insolvent" : null;
234
+ if (!kind) continue;
235
+ const n = int64(value);
236
+ if (n === null) return "malformed";
237
+ if (n > 0 && flag !== "compromised") flag = kind;
238
+ }
239
+ return flag;
240
+ };
241
+ }
242
+ async function nodeHead(host, read) {
243
+ const [lastblock, inbound] = await Promise.all([read(THORNODE_CONFIRM_PATHS.lastblock), read(THORNODE_CONFIRM_PATHS.inboundAddresses)]);
244
+ const height = lastblockHeight(lastblock);
245
+ if (height === null) throw new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
246
+ return { height, inbound };
247
+ }
248
+ async function vaultVerdict(host, read, inbound, claim) {
249
+ if (!Array.isArray(inbound)) throw new HostUnavailable(host, "inbound_addresses is not a list");
250
+ const entries = inboundAddresses(inbound);
251
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
252
+ const entry = entries.find((e) => e.chain === claim.chain);
253
+ if (paused(entry)) return { ok: false, why: "halted", chain: claim.chain };
254
+ if (claim.destChain !== void 0 && paused(entries.find((e) => e.chain === claim.destChain))) {
255
+ return { ok: false, why: "halted", chain: claim.destChain };
256
+ }
257
+ if (!sameTarget(entry?.address, claim.vault)) {
258
+ const [asgardBody, mimirBody] = await Promise.all([read(THORNODE_CONFIRM_PATHS.asgardVaults), read(THORNODE_CONFIRM_PATHS.mimir)]);
259
+ const vaults = asgardVaults(asgardBody);
260
+ if (!vaults) throw new HostUnavailable(host, "vaults/asgard is not a THORNode vault list");
261
+ const flags = mimirFlags(mimirBody);
262
+ if (!flags) throw new HostUnavailable(host, "mimir is not a THORNode mimir map");
263
+ const holders = vaults.filter((v) => v.addresses.some((a) => a.chain === claim.chain && sameTarget(a.address, claim.vault)));
264
+ const active = holders.find((v) => v.status === "ActiveVault");
265
+ if (!active) {
266
+ if (!holders.length) return { ok: false, why: "another-vault" };
267
+ return { ok: false, why: "not-active", status: /^[A-Za-z]{1,32}$/.test(holders[0].status) ? holders[0].status : "an unknown status" };
268
+ }
269
+ const flag = flags(active.pubKey);
270
+ if (flag === "malformed") throw new HostUnavailable(host, "mimir holds a non-integer vault flag");
271
+ if (flag) return { ok: false, why: "flagged", flag };
272
+ }
273
+ if (claim.router !== void 0 && !sameTarget(entry?.router, claim.router)) return { ok: false, why: "another-router" };
274
+ return { ok: true };
275
+ }
276
+
277
+ // src/provider.ts
73
278
  var HOSTS = [
74
279
  "https://gateway.liquify.com/chain/thorchain_api",
75
280
  "https://thorchain.ibs.team/api"
@@ -85,6 +290,9 @@ var NATIVE = "0x0000000000000000000000000000000000000000";
85
290
  var TOLERANCE_BPS = "300";
86
291
  var MAX_TOLERANCE_BPS = 500n;
87
292
  var QUOTE_VALIDITY_MS = 5 * 6e4;
293
+ var NODE_READ_TIMEOUT_MS = 8e3;
294
+ var CONFIRM_BUDGET_MS = 8e3;
295
+ var LAGGING = /* @__PURE__ */ Symbol("lagging");
88
296
  var THOR_CHAIN = {
89
297
  "eip155:1": "ETH",
90
298
  "eip155:8453": "BASE",
@@ -165,25 +373,20 @@ function destAsset(destChain, destToken) {
165
373
  const asset = thorAsset(destChain, a.symbol);
166
374
  return asset ? { asset, decimals: a.decimals } : void 0;
167
375
  }
168
- var HostUnavailable = class extends Error {
169
- constructor(host, reason) {
170
- super(`thorchain: ${host} unavailable (${reason})`);
171
- }
172
- };
173
376
  async function hostGet(host, path, clientId, signal) {
174
377
  let res;
175
378
  try {
176
379
  res = await fetch(`${host}${path}`, {
177
380
  headers: { accept: "application/json", "x-client-id": clientId },
178
381
  cache: "no-store",
179
- signal: signal ?? (0, import_util2.timeoutSignal)(8e3)
382
+ signal: signal ?? (0, import_util2.timeoutSignal)(NODE_READ_TIMEOUT_MS)
180
383
  });
181
384
  } catch (error) {
182
385
  if (signal?.aborted) throw error;
183
386
  throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
184
387
  }
185
388
  if (res.status >= 500 || res.status === 429 || res.status === 403) {
186
- throw new HostUnavailable(host, `HTTP ${res.status}`);
389
+ throw new HostUnavailable(host, `HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
187
390
  }
188
391
  const body = await res.json().catch(() => null);
189
392
  return { ok: res.ok, status: res.status, body };
@@ -208,35 +411,53 @@ function belowThorMinimums(amount1e8, inbound, quote) {
208
411
  if (min !== void 0 && (!/^\d+$/.test(String(min)) || amount1e8 < BigInt(min))) return "amount below THORChain recommended minimum";
209
412
  return null;
210
413
  }
211
- async function inboundAddressesOn(host, clientId) {
212
- const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
213
- if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`);
214
- return res.body;
414
+ function nodesFailed(failures) {
415
+ const known = failures.filter((f) => f !== void 0);
416
+ if (!known.length || known.length !== failures.length) return { reason: "upstream_unavailable" };
417
+ const combined = (0, import_errors2.combinedRefusal)(known.map((f) => ({ message: "", ...f })));
418
+ return combined.reason === "no_route" ? { reason: "upstream_unavailable" } : combined;
215
419
  }
216
- function paused(entry) {
217
- return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
420
+ function quoteRejection(text, status, bodyless) {
421
+ if (/emit asset 0 less than price limit/.test(text)) return { reason: "amount_too_small" };
422
+ if (/emit asset \d+ less than price limit/.test(text)) return { reason: "price_check_failed" };
423
+ if (/halt|paused/i.test(text)) return { reason: "chain_halted" };
424
+ if (/dust|too small|not enough [a-z ]*(?:to pay|for) (?:the )?fee/i.test(text)) return { reason: "amount_too_small" };
425
+ if (/insufficient liquidity|not enough liquidity/i.test(text)) return { reason: "insufficient_liquidity" };
426
+ 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" };
427
+ if (/rate limit|too many requests/i.test(text)) return { reason: "rate_limited" };
428
+ if (/api key|unauthori[sz]ed/i.test(text)) return { reason: "key_rejected" };
429
+ const byStatus = status >= 400 ? (0, import_errors2.httpRefusal)(status) : void 0;
430
+ if (byStatus) return byStatus;
431
+ return bodyless ? { reason: "upstream_unavailable", httpStatus: status } : { reason: "no_route" };
218
432
  }
219
- function sameTarget(a, b) {
220
- if (!a || !b) return false;
221
- return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
433
+ async function inboundAddressesOn(host, clientId) {
434
+ const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
435
+ if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`, res.ok ? void 0 : statusRefusal(res.status));
436
+ const entries = inboundAddresses(res.body);
437
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
438
+ return entries;
222
439
  }
223
- function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
224
- if (memo.includes("|")) return null;
440
+ function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps, why) {
441
+ const refused = (reason) => {
442
+ why?.(reason);
443
+ return null;
444
+ };
445
+ if (memo.includes("|")) return refused("recipient_unverifiable");
225
446
  const parts = memo.split(":");
226
- if (parts.length !== 4 && parts.length !== 6) return null;
447
+ if (parts.length !== 4 && parts.length !== 6) return refused("policy_refused");
227
448
  const [op, asset, destination, limit, affiliate, bps] = parts;
228
- if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return null;
449
+ if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return refused("policy_refused");
229
450
  const expectedTarget = expectedRefund ? `${expectedDestination}/${expectedRefund}` : expectedDestination;
230
- if (!sameTarget(destination, expectedTarget)) return null;
451
+ if (!sameTarget(destination, expectedTarget)) return refused("recipient_mismatch");
231
452
  if (parts.length === 6) {
232
- if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return null;
453
+ if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return refused("policy_refused");
233
454
  } else if (expectedAffiliate || expectedAffiliateBps) {
234
- return null;
455
+ return refused("policy_refused");
235
456
  }
236
457
  const stream = (limit ?? "").split("/");
237
- if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return null;
458
+ if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return refused("price_check_failed");
238
459
  const floor = BigInt(stream[0] ?? "0");
239
- if (floor <= 0n) return null;
460
+ if (floor <= 0n) return refused("price_check_failed");
240
461
  const canonical = `=:${expectedAsset}:${expectedTarget}:${stream.join("/")}${expectedAffiliate ? `:${expectedAffiliate}:${expectedAffiliateBps}` : ""}`;
241
462
  return { limit: floor, canonical };
242
463
  }
@@ -248,62 +469,107 @@ function thorchainPins(opts = {}) {
248
469
  const nodes = [...new Set((Array.isArray(opts.nodeUrl) ? opts.nodeUrl : opts.nodeUrl ? opts.nodeUrl.split(",") : HOSTS).map((h) => h.trim().replace(/\/$/, "")).filter(Boolean))];
249
470
  for (const n of nodes) if (!/^https:\/\//i.test(n)) throw new Error("thorchain: thorchainPins nodeUrl must use https");
250
471
  const clientId = opts.clientId?.trim() || DEFAULT_CLIENT_ID;
472
+ const { maxLagBlocks, timeoutMs } = confirmOptions(opts);
473
+ const askNode = async (host, claim, failures) => {
474
+ const node = nodeDeadline(timeoutMs);
475
+ const read = (path) => nodeRead(host, path, clientId, node.signal);
476
+ try {
477
+ const head = await nodeHead(host, read);
478
+ try {
479
+ return { height: head.height, verdict: await vaultVerdict(host, read, head.inbound, claim) };
480
+ } catch (error) {
481
+ if (error instanceof HostUnavailable) {
482
+ failures.push((0, import_errors2.refusalOf)(error));
483
+ return { height: head.height, verdict: null };
484
+ }
485
+ throw error;
486
+ }
487
+ } catch (error) {
488
+ if (error instanceof HostUnavailable) {
489
+ failures.push((0, import_errors2.refusalOf)(error));
490
+ return null;
491
+ }
492
+ throw error;
493
+ } finally {
494
+ node.clear();
495
+ }
496
+ };
251
497
  const routersFor = (chain) => [...THORCHAIN_ROUTERS[chain] ?? [], ...opts.trustedRouters?.[chain] ?? []];
498
+ const { note, reasonOf } = (0, import_errors2.refusalNotes)();
252
499
  return Object.freeze({
253
500
  callTargets: routersFor,
254
501
  spenders: routersFor,
502
+ reasonOf,
255
503
  // A Tron source is a send to the inbound vault; verifyBuild confirms that vault with two THORNodes (scan 2026-10-28 F9).
256
504
  depositAddressRoutes: true,
257
505
  async verifyBuild(quote, tx, params) {
258
- if (!params) return "the request this quote answered is unknown";
506
+ if (!params) return note("the request this quote answered is unknown", "policy_refused");
259
507
  const src = (0, import_swap2_assets.ccAsset)(params.sourceChain, params.sourceToken);
260
508
  const dst = destAsset(params.destChain, params.destToken);
261
- if (!src || !dst) return "unknown source or destination asset";
509
+ if (!src || !dst) return note("unknown source or destination asset", "unsupported_pair");
262
510
  const chainId = CHAIN_ID[params.sourceChain];
263
511
  let vault, memo, router;
264
512
  if (chainId) {
265
- if (!tx.data) return "no router calldata";
513
+ if (!tx.data) return note("no router calldata", "policy_refused");
266
514
  let args;
267
515
  try {
268
516
  const d = (0, import_viem.decodeFunctionData)({ abi: ROUTER_ABI, data: tx.data });
269
- if (d.functionName !== "depositWithExpiry") return `${d.functionName} is not depositWithExpiry`;
517
+ if (d.functionName !== "depositWithExpiry") return note(`${d.functionName} is not depositWithExpiry`, "policy_refused");
270
518
  args = d.args;
271
519
  } catch {
272
- return "the router calldata does not decode";
520
+ return note("the router calldata does not decode", "policy_refused");
273
521
  }
274
- if (!args || args.length !== 5) return "the router calldata does not decode";
522
+ if (!args || args.length !== 5) return note("the router calldata does not decode", "policy_refused");
275
523
  router = tx.to;
276
- if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return `router ${tx.to} is not pinned`;
524
+ if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return note(`router ${tx.to} is not pinned`, "vault_mismatch");
277
525
  vault = String(args[0]);
278
526
  const asset = String(args[1]);
279
- if (!sameTarget(asset, src.contract ?? NATIVE)) return `the router call moves ${asset}, not the source asset`;
280
- if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return `the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input`;
527
+ if (!sameTarget(asset, src.contract ?? NATIVE)) return note(`the router call moves ${asset}, not the source asset`, "policy_refused");
528
+ if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return note(`the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input`, "policy_refused");
281
529
  memo = String(args[3]);
282
530
  } else {
283
- if (!tx.memo || tx.value !== quote.sourceAmount) return "the deposit is not a send of the input with a memo";
531
+ if (!tx.memo || tx.value !== quote.sourceAmount) return note("the deposit is not a send of the input with a memo", "policy_refused");
284
532
  vault = tx.to;
285
533
  memo = tx.memo;
286
534
  }
287
- const checked = verifiedMemo(memo, dst.asset, params.destAddress, params.refundAddress ?? "", opts.affiliate ?? "", opts.affiliateBps ?? "");
288
- if (!checked) return `the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`;
289
- if (memo !== checked.canonical) return `the memo is not the canonical ${checked.canonical}`;
290
- if (checked.limit * 10n ** BigInt(dst.decimals) / 100000000n < BigInt(quote.minDestAmount)) return `the memo's limit ${checked.limit} is below the quoted minimum`;
535
+ let memoRefusal = "recipient_mismatch";
536
+ const checked = verifiedMemo(memo, dst.asset, params.destAddress, params.refundAddress ?? "", opts.affiliate ?? "", opts.affiliateBps ?? "", (why) => {
537
+ memoRefusal = why;
538
+ });
539
+ if (!checked) return note(`the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`, memoRefusal);
540
+ if (memo !== checked.canonical) return note(`the memo is not the canonical ${checked.canonical}`, "policy_refused");
541
+ 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");
542
+ const sourceChain = THOR_CHAIN[params.sourceChain] ?? params.sourceChain;
543
+ const claim = { chain: sourceChain, destChain: THOR_CHAIN[params.destChain] ?? params.destChain, vault, ...router !== void 0 ? { router } : {} };
544
+ const failures = [];
545
+ const views = await Promise.all(nodes.map((host) => askNode(host, claim, failures)));
546
+ const top = views.reduce((max, v) => v && v.height > max ? v.height : max, -1);
291
547
  let agreeing = 0;
292
- for (const host of nodes) {
293
- let entries;
294
- try {
295
- entries = await inboundAddressesOn(host, clientId);
296
- } catch (error) {
297
- if (error instanceof HostUnavailable) continue;
298
- throw error;
548
+ let lagging = false;
549
+ for (const view of views) {
550
+ if (!view?.verdict) continue;
551
+ if (top - view.height > maxLagBlocks) {
552
+ lagging = true;
553
+ continue;
554
+ }
555
+ const v = view.verdict;
556
+ if (!v.ok) {
557
+ switch (v.why) {
558
+ case "halted":
559
+ return note(`${v.chain} is halted / paused`, "chain_halted");
560
+ case "another-vault":
561
+ return note(`a THORNode reports another inbound vault than ${vault}`, "vault_mismatch");
562
+ case "not-active":
563
+ return note(`a THORNode lists the inbound vault as ${v.status}, not ActiveVault`, "vault_mismatch");
564
+ case "flagged":
565
+ return note(`a THORNode's mimir flags the inbound vault as ${v.flag}`, "vault_mismatch");
566
+ case "another-router":
567
+ return note("a THORNode reports another router", "vault_mismatch");
568
+ }
299
569
  }
300
- const entry = entries.find((e) => e.chain === THOR_CHAIN[params.sourceChain]);
301
- if (paused(entry)) return `${THOR_CHAIN[params.sourceChain]} is halted / paused`;
302
- if (!sameTarget(entry?.address, vault)) return `a THORNode reports another inbound vault than ${vault}`;
303
- if (router !== void 0 && !sameTarget(entry?.router, router)) return "a THORNode reports another router";
304
570
  if (++agreeing >= 2) return null;
305
571
  }
306
- return "two THORNodes could not confirm the inbound vault";
572
+ 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));
307
573
  }
308
574
  });
309
575
  }
@@ -343,23 +609,109 @@ function thorchainProvider(config = {}) {
343
609
  routers.set(chain, set);
344
610
  }
345
611
  const pinnedRouter = (chain, router) => !!router && routers.get(chain)?.has(router.toLowerCase()) === true;
346
- const confirmedElsewhere = async (quoteHost, chain, vault, router) => {
612
+ const { maxLagBlocks: confirmMaxLagBlocks, timeoutMs: confirmTimeoutMs } = confirmOptions(config);
613
+ const nodeSnapshot = (host, outer) => new Promise((resolve, reject) => {
614
+ let inbounds;
615
+ let height;
616
+ let heightError;
617
+ let done = false;
618
+ const tip = nodeDeadline(NODE_READ_TIMEOUT_MS, outer);
619
+ const settle = () => {
620
+ if (done || inbounds === void 0 || height === void 0 && heightError === void 0) return;
621
+ done = true;
622
+ resolve(heightError ? { inbounds, heightError } : { inbounds, height });
623
+ };
624
+ const fail = (error) => {
625
+ if (done) return;
626
+ done = true;
627
+ tip.clear();
628
+ reject(error);
629
+ };
630
+ inboundAddressesOn(host, clientId).then((v) => {
631
+ inbounds = v;
632
+ settle();
633
+ }, fail);
634
+ nodeRead(host, THORNODE_CONFIRM_PATHS.lastblock, clientId, tip.signal, outer).then(
635
+ (body) => {
636
+ tip.clear();
637
+ const h = lastblockHeight(body);
638
+ if (h === null) heightError = new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
639
+ else height = h;
640
+ settle();
641
+ },
642
+ (error) => {
643
+ tip.clear();
644
+ if (!(error instanceof HostUnavailable)) return fail(error);
645
+ heightError = error;
646
+ settle();
647
+ }
648
+ );
649
+ });
650
+ const confirmText = (v) => {
651
+ switch (v.why) {
652
+ case "halted":
653
+ return { text: `${v.chain} is halted / paused on the confirming node`, info: { reason: "chain_halted" } };
654
+ case "another-vault":
655
+ return { text: "a second node reports another inbound vault \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
656
+ case "not-active":
657
+ return { text: `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`, info: { reason: "vault_mismatch" } };
658
+ case "flagged":
659
+ return { text: `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`, info: { reason: "vault_mismatch" } };
660
+ case "another-router":
661
+ return { text: "a second node reports another router \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
662
+ }
663
+ };
664
+ const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
665
+ const read = (path) => nodeRead(host, path, clientId, signal, outer);
666
+ const head = await nodeHead(host, read);
667
+ if (quoteHeight - head.height > confirmMaxLagBlocks) return LAGGING;
668
+ const verdict = await vaultVerdict(host, read, head.inbound, claim);
669
+ return verdict.ok ? null : confirmText(verdict);
670
+ };
671
+ const confirmedElsewhere = async (quoteHost, quoteHeight, chain, destChain, vault, router, deadlineAt, outer) => {
672
+ const claim = {
673
+ chain: THOR_CHAIN[chain] ?? chain,
674
+ ...destChain !== void 0 ? { destChain: THOR_CHAIN[destChain] ?? destChain } : {},
675
+ vault,
676
+ ...router !== void 0 ? { router } : {}
677
+ };
678
+ let lagging = false;
679
+ let asked = 0;
680
+ const failures = [];
347
681
  for (const host of confirmHosts) {
348
682
  if (host === quoteHost) continue;
349
- let entries;
683
+ const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
684
+ if (budget < MIN_CONFIRM_TIMEOUT_MS) {
685
+ failures.push({ reason: "upstream_unavailable" });
686
+ break;
687
+ }
688
+ asked++;
689
+ const node = nodeDeadline(budget, outer);
350
690
  try {
351
- entries = await inboundAddressesOn(host, clientId);
691
+ const verdict = await confirmOn(host, node.signal, outer, quoteHeight, claim);
692
+ if (verdict === LAGGING) {
693
+ lagging = true;
694
+ continue;
695
+ }
696
+ return verdict;
352
697
  } catch (error) {
353
- if (error instanceof HostUnavailable) continue;
698
+ if (outer?.aborted) throw error;
699
+ if (error instanceof HostUnavailable) {
700
+ failures.push((0, import_errors2.refusalOf)(error));
701
+ continue;
702
+ }
354
703
  throw error;
704
+ } finally {
705
+ node.clear();
355
706
  }
356
- const entry = entries.find((e) => e.chain === THOR_CHAIN[chain]);
357
- if (paused(entry)) return `${THOR_CHAIN[chain]} is halted / paused on the confirming node`;
358
- if (!sameTarget(entry?.address, vault)) return "a second node reports another inbound vault \u2014 refused until they agree";
359
- if (router !== void 0 && !sameTarget(entry?.router, router)) return "a second node reports another router \u2014 refused until they agree";
360
- return null;
361
707
  }
362
- return "no second node answered to confirm the inbound vault (configure confirmNodeUrl)";
708
+ if (lagging) {
709
+ 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" } };
710
+ }
711
+ if (asked === 0) {
712
+ 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" } };
713
+ }
714
+ return { text: "no second node answered to confirm the inbound vault (configure confirmNodeUrl)", info: nodesFailed(failures) };
363
715
  };
364
716
  const configuredAffiliate = config.affiliate ?? "";
365
717
  const configuredAffiliateBps = config.affiliateBps ?? "";
@@ -405,24 +757,27 @@ function thorchainProvider(config = {}) {
405
757
  const fromAsset = thorAsset(params.sourceChain, params.sourceToken);
406
758
  const dst = destAsset(params.destChain, params.destToken);
407
759
  if (!src || !fromAsset || !dst || fromAsset === dst.asset) {
408
- params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`);
760
+ params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`, { reason: "unsupported_pair" });
409
761
  return null;
410
762
  }
411
763
  const amountIn = BigInt(params.amountIn);
412
764
  const amount1e8 = amountIn * 100000000n / 10n ** BigInt(src.decimals);
413
765
  if (amount1e8 <= 0n) return null;
414
766
  if (amount1e8 * 10n ** BigInt(src.decimals) !== amountIn * 100000000n) {
415
- params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`);
767
+ params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`, { reason: "policy_refused" });
416
768
  return null;
417
769
  }
418
770
  const requestedTolerance = params.slippageBps ?? Number(TOLERANCE_BPS);
419
771
  if (!Number.isInteger(requestedTolerance) || requestedTolerance < 0 || requestedTolerance > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
420
- params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`);
772
+ params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`, { reason: "policy_refused" });
421
773
  return null;
422
774
  }
423
775
  const explicitSlippage = params.slippageBps !== void 0;
424
776
  const liquidityBps = explicitSlippage ? dst.decimals > 8 ? requestedTolerance - 1 : requestedTolerance : 0;
425
777
  const limitMode = liquidityBps > 0 ? "liquidity_tolerance_bps" : "tolerance_bps";
778
+ let quoteStatus = 0;
779
+ let quoteBodyless = false;
780
+ let quoteBodyAborted = false;
426
781
  const quoteOn = async (host, bps, limitMode2 = "tolerance_bps") => {
427
782
  const qs = new URLSearchParams({
428
783
  from_asset: fromAsset,
@@ -443,6 +798,9 @@ function thorchainProvider(config = {}) {
443
798
  }
444
799
  if (params.refundAddress) qs.set("refund_address", params.refundAddress);
445
800
  const res = await hostGet(host, `/thorchain/quote/swap?${qs.toString()}`, clientId, signal);
801
+ quoteStatus = res.status;
802
+ quoteBodyless = res.body === null && !res.ok;
803
+ quoteBodyAborted = res.body === null && signal?.aborted === true;
446
804
  if (Array.isArray(res.body)) return null;
447
805
  return res.body ?? (res.ok ? null : { error: `HTTP ${res.status}` });
448
806
  };
@@ -450,13 +808,17 @@ function thorchainProvider(config = {}) {
450
808
  let checkedMemo = null;
451
809
  let inbounds = null;
452
810
  let quoteHost = "";
811
+ let quoteHeight = 0;
812
+ let confirmDeadlineAt = 0;
813
+ let heightUnavailable = false;
814
+ const hostFailures = [];
453
815
  for (const host of hosts) {
454
816
  try {
455
817
  if (limitMode === "liquidity_tolerance_bps") {
456
818
  q = await quoteOn(host, String(liquidityBps), "liquidity_tolerance_bps");
457
819
  const feeBps = Number(q?.fees?.total_bps);
458
820
  if (Number.isFinite(feeBps) && feeBps > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
459
- params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${(0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS) / 100}%)`);
821
+ params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${(0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS) / 100}%)`, { reason: "policy_refused" });
460
822
  return null;
461
823
  }
462
824
  } else {
@@ -473,32 +835,44 @@ function thorchainProvider(config = {}) {
473
835
  }
474
836
  }
475
837
  if (q?.error || q?.message) {
476
- params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}`);
838
+ params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}`, quoteBodyAborted ? (0, import_errors2.httpRefusal)(quoteStatus) : quoteRejection(String(q.error ?? q.message), quoteStatus, quoteBodyless));
477
839
  return null;
478
840
  }
479
841
  if (!q || !q.expected_amount_out || !q.inbound_address || !q.memo || !q.expiry) {
480
- params.onRefusal?.(`thorchain: incomplete quote from ${host} (expected_amount_out / inbound_address / memo / expiry missing)`);
842
+ 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 } : {} });
481
843
  return null;
482
844
  }
483
845
  if (chainId ? !q.router : !!q.router) {
484
- 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");
846
+ 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" });
485
847
  return null;
486
848
  }
487
- checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps);
849
+ let memoRefusal = "recipient_mismatch";
850
+ checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps, (why) => {
851
+ memoRefusal = why;
852
+ });
488
853
  if (!checkedMemo) {
489
- params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`);
854
+ params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`, { reason: memoRefusal });
490
855
  return null;
491
856
  }
492
857
  const floor1e8 = params.minAmountOut !== void 0 ? toThorUnitsCeil(BigInt(params.minAmountOut), dst.decimals) : void 0;
493
858
  if (floor1e8 !== void 0 && checkedMemo.limit < floor1e8) {
494
- params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`);
859
+ params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`, { reason: "price_check_failed" });
495
860
  return null;
496
861
  }
497
- inbounds = await inboundAddressesOn(host, clientId);
862
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
863
+ const snapshot = await nodeSnapshot(host, signal);
864
+ if ("heightError" in snapshot) {
865
+ heightUnavailable = true;
866
+ throw snapshot.heightError;
867
+ }
868
+ inbounds = snapshot.inbounds;
869
+ quoteHeight = snapshot.height;
498
870
  quoteHost = host;
871
+ confirmDeadlineAt = deadlineAt;
499
872
  break;
500
873
  } catch (error) {
501
874
  if (error instanceof HostUnavailable) {
875
+ hostFailures.push((0, import_errors2.refusalOf)(error));
502
876
  q = null;
503
877
  checkedMemo = null;
504
878
  inbounds = null;
@@ -508,60 +882,72 @@ function thorchainProvider(config = {}) {
508
882
  }
509
883
  }
510
884
  if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
511
- params.onRefusal?.(!inbounds ? "thorchain: inbound addresses unavailable from every node" : "thorchain: no quote from any node");
885
+ params.onRefusal?.(
886
+ 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",
887
+ inbounds ? { reason: "no_route" } : nodesFailed(hostFailures)
888
+ );
512
889
  return null;
513
890
  }
514
891
  const limit1e8 = checkedMemo.limit;
515
892
  const srcInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.sourceChain]);
516
893
  const dstInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.destChain]);
517
894
  if (paused(srcInbound) || paused(dstInbound)) {
518
- params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`);
895
+ params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`, { reason: "chain_halted" });
519
896
  return null;
520
897
  }
521
898
  if (!sameTarget(srcInbound?.address, q.inbound_address)) {
522
- params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry");
899
+ params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry", { reason: "vault_mismatch" });
523
900
  return null;
524
901
  }
525
902
  if (chainId && !sameTarget(srcInbound?.router, q.router)) {
526
- params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry");
903
+ params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry", { reason: "vault_mismatch" });
527
904
  return null;
528
905
  }
529
906
  if (chainId && !pinnedRouter(params.sourceChain, q.router)) {
530
- params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`);
907
+ params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`, { reason: "vault_mismatch" });
531
908
  return null;
532
909
  }
533
- const unconfirmed = await confirmedElsewhere(quoteHost, params.sourceChain, q.inbound_address, chainId ? q.router : void 0);
910
+ const unconfirmed = await confirmedElsewhere(
911
+ quoteHost,
912
+ quoteHeight,
913
+ params.sourceChain,
914
+ params.destChain,
915
+ q.inbound_address,
916
+ chainId ? q.router : void 0,
917
+ confirmDeadlineAt,
918
+ signal
919
+ );
534
920
  if (unconfirmed) {
535
- params.onRefusal?.(`thorchain: ${unconfirmed}`);
921
+ params.onRefusal?.(`thorchain: ${unconfirmed.text}`, unconfirmed.info);
536
922
  return null;
537
923
  }
538
924
  const tooSmall = belowThorMinimums(amount1e8, srcInbound, q);
539
925
  if (tooSmall) {
540
- params.onRefusal?.(`thorchain: ${tooSmall}`);
926
+ params.onRefusal?.(`thorchain: ${tooSmall}`, { reason: "amount_too_small" });
541
927
  return null;
542
928
  }
543
929
  const outUnits = BigInt(q.expected_amount_out) * 10n ** BigInt(dst.decimals) / 100000000n;
544
930
  if (outUnits <= 0n) {
545
- params.onRefusal?.("thorchain: expected output rounds to zero");
931
+ params.onRefusal?.("thorchain: expected output rounds to zero", { reason: "amount_too_small" });
546
932
  return null;
547
933
  }
548
934
  const memoMinUnits = limit1e8 * 10n ** BigInt(dst.decimals) / 100000000n;
549
935
  const minUnits = memoMinUnits;
550
936
  if (minUnits <= 0n || minUnits > outUnits) {
551
- params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`);
937
+ params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`, { reason: "price_check_failed" });
552
938
  return null;
553
939
  }
554
940
  if (params.slippageBps !== void 0) {
555
941
  const slippageFloor = outUnits * BigInt(1e4 - params.slippageBps) / 10000n;
556
942
  if (minUnits < slippageFloor) {
557
- params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`);
943
+ params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`, { reason: "price_check_failed" });
558
944
  return null;
559
945
  }
560
946
  }
561
947
  const nowSeconds = Math.floor(Date.now() / 1e3);
562
948
  const executionExpiry = Math.min(q.expiry, nowSeconds + 15 * 60);
563
949
  if (!Number.isInteger(executionExpiry) || executionExpiry <= nowSeconds + 30) {
564
- params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`);
950
+ params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`, { reason: "quote_expired" });
565
951
  return null;
566
952
  }
567
953
  const assetAddr = src.contract ? src.contract : NATIVE;
@@ -617,7 +1003,7 @@ function thorchainProvider(config = {}) {
617
1003
  if (!quote.txRequest) {
618
1004
  const sourceChain = quote.providerData?.sourceChain ?? "";
619
1005
  if (!isDepositSource(sourceChain) || !quote.depositAddress || !quote.depositMemo) {
620
- throw new Error("thorchain: deposit-address execution is only supported for an opted-in Tron source");
1006
+ throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: deposit-address execution is only supported for an opted-in Tron source");
621
1007
  }
622
1008
  return { chain: sourceChain, to: quote.depositAddress, value: quote.sourceAmount, memo: quote.depositMemo };
623
1009
  }
@@ -634,67 +1020,96 @@ function thorchainProvider(config = {}) {
634
1020
  async preflightDeposit(quote, tx) {
635
1021
  const data = quote.providerData;
636
1022
  if (!data?.sourceChain || !data.destChain || !data.inboundAddress || !data.assetAddress || !data.expiry || !data.memo) {
637
- throw new Error("thorchain: quote is missing execution-bound metadata");
1023
+ throw (0, import_errors2.refusal)("policy_refused", "thorchain: quote is missing execution-bound metadata");
638
1024
  }
639
1025
  const evmSource = CHAIN_ID[data.sourceChain] !== void 0;
640
- if (evmSource && !data.router) throw new Error("thorchain: quote is missing execution-bound metadata");
641
- if (!evmSource && !isDepositSource(data.sourceChain)) throw new Error("thorchain: source chain has no executor");
1026
+ if (evmSource && !data.router) throw (0, import_errors2.refusal)("policy_refused", "thorchain: quote is missing execution-bound metadata");
1027
+ if (!evmSource && !isDepositSource(data.sourceChain)) throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: source chain has no executor");
642
1028
  if (!/^\d+$/.test(data.expiry) || BigInt(data.expiry) <= BigInt(Math.floor(Date.now() / 1e3))) {
643
- throw new Error("thorchain: quote expired before broadcast");
1029
+ throw (0, import_errors2.refusal)("quote_expired", "thorchain: quote expired before broadcast");
644
1030
  }
645
- if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw new Error("thorchain: the quote's router is not one this module trusts");
1031
+ if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: the quote's router is not one this module trusts");
646
1032
  let inbounds = null;
647
1033
  let snapshotHost = "";
1034
+ let snapshotHeight = 0;
1035
+ let confirmDeadlineAt = 0;
1036
+ let heightUnavailable = false;
1037
+ const hostFailures = [];
648
1038
  for (const host of hosts) {
1039
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
649
1040
  try {
650
- inbounds = await inboundAddressesOn(host, clientId);
1041
+ const snapshot = await nodeSnapshot(host);
1042
+ if ("heightError" in snapshot) {
1043
+ heightUnavailable = true;
1044
+ hostFailures.push((0, import_errors2.refusalOf)(snapshot.heightError));
1045
+ continue;
1046
+ }
1047
+ inbounds = snapshot.inbounds;
1048
+ snapshotHeight = snapshot.height;
651
1049
  snapshotHost = host;
1050
+ confirmDeadlineAt = deadlineAt;
652
1051
  break;
653
1052
  } catch (error) {
1053
+ inbounds = null;
654
1054
  if (!(error instanceof HostUnavailable)) throw error;
1055
+ hostFailures.push((0, import_errors2.refusalOf)(error));
655
1056
  }
656
1057
  }
657
- if (!inbounds) throw new Error("thorchain: cannot refresh inbound addresses before broadcast");
1058
+ if (!inbounds) {
1059
+ throw (0, import_errors2.withReason)(new Error(
1060
+ 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"
1061
+ ), nodesFailed(hostFailures));
1062
+ }
658
1063
  const confirm = async () => {
659
- const unconfirmed = await confirmedElsewhere(snapshotHost, data.sourceChain, data.inboundAddress, evmSource ? data.router : void 0);
660
- if (unconfirmed) throw new Error(`thorchain: ${unconfirmed}`);
1064
+ const unconfirmed = await confirmedElsewhere(
1065
+ snapshotHost,
1066
+ snapshotHeight,
1067
+ data.sourceChain,
1068
+ data.destChain,
1069
+ data.inboundAddress,
1070
+ evmSource ? data.router : void 0,
1071
+ confirmDeadlineAt
1072
+ );
1073
+ if (unconfirmed) throw (0, import_errors2.withReason)(new Error(`thorchain: ${unconfirmed.text}`), unconfirmed.info);
661
1074
  };
662
1075
  const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);
663
1076
  const dst = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.destChain]);
664
- if (paused(src) || paused(dst)) throw new Error("thorchain: source or destination chain trading is paused");
665
- if (!sameTarget(src?.address, data.inboundAddress)) throw new Error("thorchain: inbound vault rotated after quote");
1077
+ if (paused(src) || paused(dst)) throw (0, import_errors2.refusal)("chain_halted", "thorchain: source or destination chain trading is paused");
1078
+ if (!sameTarget(src?.address, data.inboundAddress)) throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: inbound vault rotated after quote");
666
1079
  if (!evmSource) {
667
1080
  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) {
668
- throw new Error("thorchain: deposit-address execution must be a plain send with memo");
1081
+ throw (0, import_errors2.refusal)("policy_refused", "thorchain: deposit-address execution must be a plain send with memo");
669
1082
  }
670
1083
  if (!sameTarget(tx.to, data.inboundAddress) || tx.memo !== data.memo || tx.value !== quote.sourceAmount || tx.chain && tx.chain !== data.sourceChain) {
671
- throw new Error("thorchain: deposit does not match the validated quote");
1084
+ throw (0, import_errors2.refusal)(!sameTarget(tx.to, data.inboundAddress) ? "vault_mismatch" : "policy_refused", "thorchain: deposit does not match the validated quote");
672
1085
  }
673
1086
  const srcAsset = (0, import_swap2_assets.ccAssetByToken)(data.sourceChain, sameTarget(data.assetAddress, NATIVE) ? "" : data.assetAddress);
674
- if (!srcAsset) throw new Error("thorchain: unknown source asset");
1087
+ if (!srcAsset) throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: unknown source asset");
675
1088
  const amount1e8 = BigInt(quote.sourceAmount) * 100000000n / 10n ** BigInt(srcAsset.decimals);
676
1089
  const tooSmall = belowThorMinimums(amount1e8, src, null);
677
- if (tooSmall) throw new Error(`thorchain: ${tooSmall}`);
1090
+ if (tooSmall) throw (0, import_errors2.refusal)("amount_too_small", `thorchain: ${tooSmall}`);
678
1091
  await confirm();
679
1092
  return;
680
1093
  }
681
1094
  if (!sameTarget(src?.router, data.router) || !sameTarget(tx.to, data.router)) {
682
- throw new Error("thorchain: router rotated after quote");
1095
+ throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: router rotated after quote");
683
1096
  }
684
- if (!tx.data) throw new Error("thorchain: missing depositWithExpiry calldata");
1097
+ if (!tx.data) throw (0, import_errors2.refusal)("policy_refused", "thorchain: missing depositWithExpiry calldata");
685
1098
  let decoded;
686
1099
  try {
687
1100
  decoded = (0, import_viem.decodeFunctionData)({ abi: ROUTER_ABI, data: tx.data });
688
1101
  } catch {
689
- throw new Error("thorchain: malformed depositWithExpiry calldata");
1102
+ throw (0, import_errors2.refusal)("policy_refused", "thorchain: malformed depositWithExpiry calldata");
690
1103
  }
691
1104
  const args = decoded.args;
692
1105
  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]) {
693
- throw new Error("thorchain: calldata does not match the validated quote");
1106
+ const vaultChanged = decoded.functionName === "depositWithExpiry" && !!args && args.length === 5 && !sameTarget(String(args[0]), data.inboundAddress);
1107
+ throw (0, import_errors2.refusal)(vaultChanged ? "vault_mismatch" : "policy_refused", "thorchain: calldata does not match the validated quote");
694
1108
  }
695
1109
  const tokenSource = !sameTarget(data.assetAddress, NATIVE);
696
1110
  if (tx.value !== (tokenSource ? "0" : quote.sourceAmount) || (tokenSource ? !sameTarget(tx.approvalAddress, data.router) : tx.approvalAddress !== void 0)) {
697
- throw new Error("thorchain: value/approval does not match the validated source asset");
1111
+ const spenderChanged = tokenSource && tx.approvalAddress !== void 0 && !sameTarget(tx.approvalAddress, data.router);
1112
+ throw (0, import_errors2.refusal)(spenderChanged ? "vault_mismatch" : "policy_refused", "thorchain: value/approval does not match the validated source asset");
698
1113
  }
699
1114
  await confirm();
700
1115
  },