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