@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/protocol.cjs CHANGED
@@ -67,8 +67,212 @@ function describeThorchainActivity(tx, ctx) {
67
67
  // src/provider.ts
68
68
  var import_viem = require("viem");
69
69
  var import_util2 = require("@anywalletkit/protocol-swap2-core/util");
70
+ var import_errors2 = require("@anywalletkit/protocol-swap2-core/errors");
70
71
  var import_swap2_assets = require("@anywalletkit/swap2-assets");
71
72
  var import_core = require("@anywalletkit/core");
73
+
74
+ // src/confirm.ts
75
+ var import_errors = require("@anywalletkit/protocol-swap2-core/errors");
76
+ var THORNODE_CONFIRM_PATHS = Object.freeze({
77
+ lastblock: "/thorchain/lastblock",
78
+ inboundAddresses: "/thorchain/inbound_addresses",
79
+ asgardVaults: "/thorchain/vaults/asgard",
80
+ mimir: "/thorchain/mimir"
81
+ });
82
+ var DEFAULT_CONFIRM_MAX_LAG_BLOCKS = 50;
83
+ var DEFAULT_CONFIRM_TIMEOUT_MS = 2500;
84
+ var MIN_CONFIRM_TIMEOUT_MS = 100;
85
+ var MAX_CONFIRM_TIMEOUT_MS = 8e3;
86
+ function confirmOptions(o) {
87
+ const maxLagBlocks = o.confirmMaxLagBlocks ?? DEFAULT_CONFIRM_MAX_LAG_BLOCKS;
88
+ if (!Number.isInteger(maxLagBlocks) || maxLagBlocks < 0 || maxLagBlocks > 1e5) {
89
+ throw new Error("thorchain: confirmMaxLagBlocks must be an integer between 0 and 100000");
90
+ }
91
+ const timeoutMs = o.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
92
+ if (!Number.isInteger(timeoutMs) || timeoutMs < MIN_CONFIRM_TIMEOUT_MS || timeoutMs > MAX_CONFIRM_TIMEOUT_MS) {
93
+ throw new Error(`thorchain: confirmTimeoutMs must be an integer between ${MIN_CONFIRM_TIMEOUT_MS} and ${MAX_CONFIRM_TIMEOUT_MS}`);
94
+ }
95
+ return { maxLagBlocks, timeoutMs };
96
+ }
97
+ var HostUnavailable = class extends Error {
98
+ /**
99
+ * `info` classifies the failure (`refusalOf` reads it back): what the HTTP status says (429 `rate_limited`, 401 / 403
100
+ * `key_rejected`), else `upstream_unavailable` — unreachable, timed out, a 5xx, redirected or another shape.
101
+ */
102
+ constructor(host, reason, info) {
103
+ super(`thorchain: ${host} unavailable (${reason})`);
104
+ (0, import_errors.withReason)(this, info ?? "upstream_unavailable");
105
+ }
106
+ };
107
+ function statusRefusal(status, retryAfter) {
108
+ return (0, import_errors.httpRefusal)(status, retryAfter) ?? { reason: "upstream_unavailable", httpStatus: status };
109
+ }
110
+ function paused(entry) {
111
+ return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
112
+ }
113
+ function sameTarget(a, b) {
114
+ if (typeof a !== "string" || typeof b !== "string" || !a || !b) return false;
115
+ return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
116
+ }
117
+ function nodeDeadline(ms, outer) {
118
+ const ctrl = new AbortController();
119
+ const timer = setTimeout(() => ctrl.abort(), Math.max(0, ms));
120
+ const onAbort = () => ctrl.abort();
121
+ if (outer) {
122
+ if (outer.aborted) ctrl.abort();
123
+ else outer.addEventListener("abort", onAbort);
124
+ }
125
+ return {
126
+ signal: ctrl.signal,
127
+ clear() {
128
+ clearTimeout(timer);
129
+ outer?.removeEventListener("abort", onAbort);
130
+ if (!ctrl.signal.aborted) ctrl.abort();
131
+ }
132
+ };
133
+ }
134
+ function sameRequestUrl(actual, expected) {
135
+ if (actual === void 0 || actual === null || actual === "") return true;
136
+ if (typeof actual !== "string") return false;
137
+ try {
138
+ const a = new URL(actual);
139
+ const e = new URL(expected);
140
+ return a.origin === e.origin && a.pathname === e.pathname && a.search === "" && a.hash === "";
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+ async function nodeRead(host, path, clientId, signal, outer) {
146
+ const url = `${host}${path}`;
147
+ let res;
148
+ try {
149
+ res = await fetch(url, { method: "GET", headers: { accept: "application/json", "x-client-id": clientId }, redirect: "error", signal });
150
+ } catch (error) {
151
+ if (outer?.aborted) throw error;
152
+ throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
153
+ }
154
+ if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
155
+ if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
156
+ try {
157
+ return await res.json();
158
+ } catch (error) {
159
+ if (outer?.aborted) throw error;
160
+ throw new HostUnavailable(host, `${path} is not JSON`);
161
+ }
162
+ }
163
+ function int64(v) {
164
+ if (typeof v === "number") return Number.isSafeInteger(v) ? v : null;
165
+ if (typeof v === "string" && /^-?\d{1,15}$/.test(v)) return Number(v);
166
+ return null;
167
+ }
168
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
169
+ var INBOUND_STRINGS = ["address", "router", "dust_threshold"];
170
+ var INBOUND_FLAGS = ["halted", "global_trading_paused", "chain_trading_paused"];
171
+ function inboundAddresses(body) {
172
+ if (!Array.isArray(body)) return null;
173
+ const out = [];
174
+ for (const e of body) {
175
+ if (!isObject(e) || typeof e.chain !== "string") return null;
176
+ const entry = { chain: e.chain };
177
+ for (const k of INBOUND_STRINGS) {
178
+ const v = e[k];
179
+ if (v === void 0) continue;
180
+ if (typeof v !== "string") return null;
181
+ entry[k] = v;
182
+ }
183
+ for (const k of INBOUND_FLAGS) {
184
+ const v = e[k];
185
+ if (v === void 0) continue;
186
+ if (typeof v !== "boolean") return null;
187
+ entry[k] = v;
188
+ }
189
+ out.push(entry);
190
+ }
191
+ return out;
192
+ }
193
+ function lastblockHeight(body) {
194
+ if (!Array.isArray(body) || body.length === 0) return null;
195
+ let height = -1;
196
+ for (const entry of body) {
197
+ if (!isObject(entry)) return null;
198
+ const h = int64(entry.thorchain);
199
+ if (h === null || h < 0) return null;
200
+ if (h > height) height = h;
201
+ }
202
+ return height;
203
+ }
204
+ function asgardVaults(body) {
205
+ if (!Array.isArray(body)) return null;
206
+ const out = [];
207
+ for (const v of body) {
208
+ if (!isObject(v)) return null;
209
+ if (typeof v.status !== "string" || typeof v.pub_key !== "string" || !/^thorpub1[0-9a-z]{20,}$/.test(v.pub_key)) return null;
210
+ if (v.type !== void 0 && v.type !== "AsgardVault") return null;
211
+ if (!Array.isArray(v.addresses)) return null;
212
+ const addresses = [];
213
+ for (const a of v.addresses) {
214
+ if (!isObject(a) || typeof a.chain !== "string" || typeof a.address !== "string") return null;
215
+ addresses.push({ chain: a.chain, address: a.address });
216
+ }
217
+ out.push({ pubKey: v.pub_key, status: v.status, addresses });
218
+ }
219
+ return out;
220
+ }
221
+ function mimirFlags(body) {
222
+ if (!isObject(body) || Object.keys(body).length === 0) return null;
223
+ const keys = Object.keys(body).map((k) => [k.toUpperCase(), body[k]]);
224
+ if (keys.some(([, value]) => typeof value !== "number" && typeof value !== "string")) return null;
225
+ return (pubKey) => {
226
+ const pub = pubKey.toUpperCase();
227
+ const compromisedKey = `COMPROMISEDVAULT-${pub}`;
228
+ const insolventPrefix = `SOLVENCYHALTVAULT-${pub}-`;
229
+ let flag = null;
230
+ for (const [key, value] of keys) {
231
+ const kind = key === compromisedKey ? "compromised" : key.startsWith(insolventPrefix) ? "insolvent" : null;
232
+ if (!kind) continue;
233
+ const n = int64(value);
234
+ if (n === null) return "malformed";
235
+ if (n > 0 && flag !== "compromised") flag = kind;
236
+ }
237
+ return flag;
238
+ };
239
+ }
240
+ async function nodeHead(host, read) {
241
+ const [lastblock, inbound] = await Promise.all([read(THORNODE_CONFIRM_PATHS.lastblock), read(THORNODE_CONFIRM_PATHS.inboundAddresses)]);
242
+ const height = lastblockHeight(lastblock);
243
+ if (height === null) throw new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
244
+ return { height, inbound };
245
+ }
246
+ async function vaultVerdict(host, read, inbound, claim) {
247
+ if (!Array.isArray(inbound)) throw new HostUnavailable(host, "inbound_addresses is not a list");
248
+ const entries = inboundAddresses(inbound);
249
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
250
+ const entry = entries.find((e) => e.chain === claim.chain);
251
+ if (paused(entry)) return { ok: false, why: "halted", chain: claim.chain };
252
+ if (claim.destChain !== void 0 && paused(entries.find((e) => e.chain === claim.destChain))) {
253
+ return { ok: false, why: "halted", chain: claim.destChain };
254
+ }
255
+ if (!sameTarget(entry?.address, claim.vault)) {
256
+ const [asgardBody, mimirBody] = await Promise.all([read(THORNODE_CONFIRM_PATHS.asgardVaults), read(THORNODE_CONFIRM_PATHS.mimir)]);
257
+ const vaults = asgardVaults(asgardBody);
258
+ if (!vaults) throw new HostUnavailable(host, "vaults/asgard is not a THORNode vault list");
259
+ const flags = mimirFlags(mimirBody);
260
+ if (!flags) throw new HostUnavailable(host, "mimir is not a THORNode mimir map");
261
+ const holders = vaults.filter((v) => v.addresses.some((a) => a.chain === claim.chain && sameTarget(a.address, claim.vault)));
262
+ const active = holders.find((v) => v.status === "ActiveVault");
263
+ if (!active) {
264
+ if (!holders.length) return { ok: false, why: "another-vault" };
265
+ return { ok: false, why: "not-active", status: /^[A-Za-z]{1,32}$/.test(holders[0].status) ? holders[0].status : "an unknown status" };
266
+ }
267
+ const flag = flags(active.pubKey);
268
+ if (flag === "malformed") throw new HostUnavailable(host, "mimir holds a non-integer vault flag");
269
+ if (flag) return { ok: false, why: "flagged", flag };
270
+ }
271
+ if (claim.router !== void 0 && !sameTarget(entry?.router, claim.router)) return { ok: false, why: "another-router" };
272
+ return { ok: true };
273
+ }
274
+
275
+ // src/provider.ts
72
276
  var HOSTS = [
73
277
  "https://gateway.liquify.com/chain/thorchain_api",
74
278
  "https://thorchain.ibs.team/api"
@@ -84,6 +288,9 @@ var NATIVE = "0x0000000000000000000000000000000000000000";
84
288
  var TOLERANCE_BPS = "300";
85
289
  var MAX_TOLERANCE_BPS = 500n;
86
290
  var QUOTE_VALIDITY_MS = 5 * 6e4;
291
+ var NODE_READ_TIMEOUT_MS = 8e3;
292
+ var CONFIRM_BUDGET_MS = 8e3;
293
+ var LAGGING = /* @__PURE__ */ Symbol("lagging");
87
294
  var THOR_CHAIN = {
88
295
  "eip155:1": "ETH",
89
296
  "eip155:8453": "BASE",
@@ -164,25 +371,20 @@ function destAsset(destChain, destToken) {
164
371
  const asset = thorAsset(destChain, a.symbol);
165
372
  return asset ? { asset, decimals: a.decimals } : void 0;
166
373
  }
167
- var HostUnavailable = class extends Error {
168
- constructor(host, reason) {
169
- super(`thorchain: ${host} unavailable (${reason})`);
170
- }
171
- };
172
374
  async function hostGet(host, path, clientId, signal) {
173
375
  let res;
174
376
  try {
175
377
  res = await fetch(`${host}${path}`, {
176
378
  headers: { accept: "application/json", "x-client-id": clientId },
177
379
  cache: "no-store",
178
- signal: signal ?? (0, import_util2.timeoutSignal)(8e3)
380
+ signal: signal ?? (0, import_util2.timeoutSignal)(NODE_READ_TIMEOUT_MS)
179
381
  });
180
382
  } catch (error) {
181
383
  if (signal?.aborted) throw error;
182
384
  throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
183
385
  }
184
386
  if (res.status >= 500 || res.status === 429 || res.status === 403) {
185
- throw new HostUnavailable(host, `HTTP ${res.status}`);
387
+ throw new HostUnavailable(host, `HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
186
388
  }
187
389
  const body = await res.json().catch(() => null);
188
390
  return { ok: res.ok, status: res.status, body };
@@ -207,35 +409,53 @@ function belowThorMinimums(amount1e8, inbound, quote) {
207
409
  if (min !== void 0 && (!/^\d+$/.test(String(min)) || amount1e8 < BigInt(min))) return "amount below THORChain recommended minimum";
208
410
  return null;
209
411
  }
210
- async function inboundAddressesOn(host, clientId) {
211
- const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
212
- if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`);
213
- return res.body;
412
+ function nodesFailed(failures) {
413
+ const known = failures.filter((f) => f !== void 0);
414
+ if (!known.length || known.length !== failures.length) return { reason: "upstream_unavailable" };
415
+ const combined = (0, import_errors2.combinedRefusal)(known.map((f) => ({ message: "", ...f })));
416
+ return combined.reason === "no_route" ? { reason: "upstream_unavailable" } : combined;
214
417
  }
215
- function paused(entry) {
216
- return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
418
+ function quoteRejection(text, status, bodyless) {
419
+ if (/emit asset 0 less than price limit/.test(text)) return { reason: "amount_too_small" };
420
+ if (/emit asset \d+ less than price limit/.test(text)) return { reason: "price_check_failed" };
421
+ if (/halt|paused/i.test(text)) return { reason: "chain_halted" };
422
+ if (/dust|too small|not enough [a-z ]*(?:to pay|for) (?:the )?fee/i.test(text)) return { reason: "amount_too_small" };
423
+ if (/insufficient liquidity|not enough liquidity/i.test(text)) return { reason: "insufficient_liquidity" };
424
+ 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" };
425
+ if (/rate limit|too many requests/i.test(text)) return { reason: "rate_limited" };
426
+ if (/api key|unauthori[sz]ed/i.test(text)) return { reason: "key_rejected" };
427
+ const byStatus = status >= 400 ? (0, import_errors2.httpRefusal)(status) : void 0;
428
+ if (byStatus) return byStatus;
429
+ return bodyless ? { reason: "upstream_unavailable", httpStatus: status } : { reason: "no_route" };
217
430
  }
218
- function sameTarget(a, b) {
219
- if (!a || !b) return false;
220
- return a.startsWith("0x") || b.startsWith("0x") ? a.toLowerCase() === b.toLowerCase() : a === b;
431
+ async function inboundAddressesOn(host, clientId) {
432
+ const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
433
+ if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`, res.ok ? void 0 : statusRefusal(res.status));
434
+ const entries = inboundAddresses(res.body);
435
+ if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
436
+ return entries;
221
437
  }
222
- function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
223
- if (memo.includes("|")) return null;
438
+ function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps, why) {
439
+ const refused = (reason) => {
440
+ why?.(reason);
441
+ return null;
442
+ };
443
+ if (memo.includes("|")) return refused("recipient_unverifiable");
224
444
  const parts = memo.split(":");
225
- if (parts.length !== 4 && parts.length !== 6) return null;
445
+ if (parts.length !== 4 && parts.length !== 6) return refused("policy_refused");
226
446
  const [op, asset, destination, limit, affiliate, bps] = parts;
227
- if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return null;
447
+ if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return refused("policy_refused");
228
448
  const expectedTarget = expectedRefund ? `${expectedDestination}/${expectedRefund}` : expectedDestination;
229
- if (!sameTarget(destination, expectedTarget)) return null;
449
+ if (!sameTarget(destination, expectedTarget)) return refused("recipient_mismatch");
230
450
  if (parts.length === 6) {
231
- if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return null;
451
+ if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return refused("policy_refused");
232
452
  } else if (expectedAffiliate || expectedAffiliateBps) {
233
- return null;
453
+ return refused("policy_refused");
234
454
  }
235
455
  const stream = (limit ?? "").split("/");
236
- if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return null;
456
+ if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return refused("price_check_failed");
237
457
  const floor = BigInt(stream[0] ?? "0");
238
- if (floor <= 0n) return null;
458
+ if (floor <= 0n) return refused("price_check_failed");
239
459
  const canonical = `=:${expectedAsset}:${expectedTarget}:${stream.join("/")}${expectedAffiliate ? `:${expectedAffiliate}:${expectedAffiliateBps}` : ""}`;
240
460
  return { limit: floor, canonical };
241
461
  }
@@ -279,23 +499,109 @@ function thorchainProvider(config = {}) {
279
499
  routers.set(chain, set);
280
500
  }
281
501
  const pinnedRouter = (chain, router) => !!router && routers.get(chain)?.has(router.toLowerCase()) === true;
282
- const confirmedElsewhere = async (quoteHost, chain, vault, router) => {
502
+ const { maxLagBlocks: confirmMaxLagBlocks, timeoutMs: confirmTimeoutMs } = confirmOptions(config);
503
+ const nodeSnapshot = (host, outer) => new Promise((resolve, reject) => {
504
+ let inbounds;
505
+ let height;
506
+ let heightError;
507
+ let done = false;
508
+ const tip = nodeDeadline(NODE_READ_TIMEOUT_MS, outer);
509
+ const settle = () => {
510
+ if (done || inbounds === void 0 || height === void 0 && heightError === void 0) return;
511
+ done = true;
512
+ resolve(heightError ? { inbounds, heightError } : { inbounds, height });
513
+ };
514
+ const fail = (error) => {
515
+ if (done) return;
516
+ done = true;
517
+ tip.clear();
518
+ reject(error);
519
+ };
520
+ inboundAddressesOn(host, clientId).then((v) => {
521
+ inbounds = v;
522
+ settle();
523
+ }, fail);
524
+ nodeRead(host, THORNODE_CONFIRM_PATHS.lastblock, clientId, tip.signal, outer).then(
525
+ (body) => {
526
+ tip.clear();
527
+ const h = lastblockHeight(body);
528
+ if (h === null) heightError = new HostUnavailable(host, "lastblock is not a THORNode lastblock list");
529
+ else height = h;
530
+ settle();
531
+ },
532
+ (error) => {
533
+ tip.clear();
534
+ if (!(error instanceof HostUnavailable)) return fail(error);
535
+ heightError = error;
536
+ settle();
537
+ }
538
+ );
539
+ });
540
+ const confirmText = (v) => {
541
+ switch (v.why) {
542
+ case "halted":
543
+ return { text: `${v.chain} is halted / paused on the confirming node`, info: { reason: "chain_halted" } };
544
+ case "another-vault":
545
+ return { text: "a second node reports another inbound vault \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
546
+ case "not-active":
547
+ return { text: `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`, info: { reason: "vault_mismatch" } };
548
+ case "flagged":
549
+ return { text: `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`, info: { reason: "vault_mismatch" } };
550
+ case "another-router":
551
+ return { text: "a second node reports another router \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
552
+ }
553
+ };
554
+ const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
555
+ const read = (path) => nodeRead(host, path, clientId, signal, outer);
556
+ const head = await nodeHead(host, read);
557
+ if (quoteHeight - head.height > confirmMaxLagBlocks) return LAGGING;
558
+ const verdict = await vaultVerdict(host, read, head.inbound, claim);
559
+ return verdict.ok ? null : confirmText(verdict);
560
+ };
561
+ const confirmedElsewhere = async (quoteHost, quoteHeight, chain, destChain, vault, router, deadlineAt, outer) => {
562
+ const claim = {
563
+ chain: THOR_CHAIN[chain] ?? chain,
564
+ ...destChain !== void 0 ? { destChain: THOR_CHAIN[destChain] ?? destChain } : {},
565
+ vault,
566
+ ...router !== void 0 ? { router } : {}
567
+ };
568
+ let lagging = false;
569
+ let asked = 0;
570
+ const failures = [];
283
571
  for (const host of confirmHosts) {
284
572
  if (host === quoteHost) continue;
285
- let entries;
573
+ const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
574
+ if (budget < MIN_CONFIRM_TIMEOUT_MS) {
575
+ failures.push({ reason: "upstream_unavailable" });
576
+ break;
577
+ }
578
+ asked++;
579
+ const node = nodeDeadline(budget, outer);
286
580
  try {
287
- entries = await inboundAddressesOn(host, clientId);
581
+ const verdict = await confirmOn(host, node.signal, outer, quoteHeight, claim);
582
+ if (verdict === LAGGING) {
583
+ lagging = true;
584
+ continue;
585
+ }
586
+ return verdict;
288
587
  } catch (error) {
289
- if (error instanceof HostUnavailable) continue;
588
+ if (outer?.aborted) throw error;
589
+ if (error instanceof HostUnavailable) {
590
+ failures.push((0, import_errors2.refusalOf)(error));
591
+ continue;
592
+ }
290
593
  throw error;
594
+ } finally {
595
+ node.clear();
291
596
  }
292
- const entry = entries.find((e) => e.chain === THOR_CHAIN[chain]);
293
- if (paused(entry)) return `${THOR_CHAIN[chain]} is halted / paused on the confirming node`;
294
- if (!sameTarget(entry?.address, vault)) return "a second node reports another inbound vault \u2014 refused until they agree";
295
- if (router !== void 0 && !sameTarget(entry?.router, router)) return "a second node reports another router \u2014 refused until they agree";
296
- return null;
297
597
  }
298
- return "no second node answered to confirm the inbound vault (configure confirmNodeUrl)";
598
+ if (lagging) {
599
+ 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" } };
600
+ }
601
+ if (asked === 0) {
602
+ 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" } };
603
+ }
604
+ return { text: "no second node answered to confirm the inbound vault (configure confirmNodeUrl)", info: nodesFailed(failures) };
299
605
  };
300
606
  const configuredAffiliate = config.affiliate ?? "";
301
607
  const configuredAffiliateBps = config.affiliateBps ?? "";
@@ -341,24 +647,27 @@ function thorchainProvider(config = {}) {
341
647
  const fromAsset = thorAsset(params.sourceChain, params.sourceToken);
342
648
  const dst = destAsset(params.destChain, params.destToken);
343
649
  if (!src || !fromAsset || !dst || fromAsset === dst.asset) {
344
- params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`);
650
+ params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`, { reason: "unsupported_pair" });
345
651
  return null;
346
652
  }
347
653
  const amountIn = BigInt(params.amountIn);
348
654
  const amount1e8 = amountIn * 100000000n / 10n ** BigInt(src.decimals);
349
655
  if (amount1e8 <= 0n) return null;
350
656
  if (amount1e8 * 10n ** BigInt(src.decimals) !== amountIn * 100000000n) {
351
- params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`);
657
+ params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`, { reason: "policy_refused" });
352
658
  return null;
353
659
  }
354
660
  const requestedTolerance = params.slippageBps ?? Number(TOLERANCE_BPS);
355
661
  if (!Number.isInteger(requestedTolerance) || requestedTolerance < 0 || requestedTolerance > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
356
- params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`);
662
+ params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`, { reason: "policy_refused" });
357
663
  return null;
358
664
  }
359
665
  const explicitSlippage = params.slippageBps !== void 0;
360
666
  const liquidityBps = explicitSlippage ? dst.decimals > 8 ? requestedTolerance - 1 : requestedTolerance : 0;
361
667
  const limitMode = liquidityBps > 0 ? "liquidity_tolerance_bps" : "tolerance_bps";
668
+ let quoteStatus = 0;
669
+ let quoteBodyless = false;
670
+ let quoteBodyAborted = false;
362
671
  const quoteOn = async (host, bps, limitMode2 = "tolerance_bps") => {
363
672
  const qs = new URLSearchParams({
364
673
  from_asset: fromAsset,
@@ -379,6 +688,9 @@ function thorchainProvider(config = {}) {
379
688
  }
380
689
  if (params.refundAddress) qs.set("refund_address", params.refundAddress);
381
690
  const res = await hostGet(host, `/thorchain/quote/swap?${qs.toString()}`, clientId, signal);
691
+ quoteStatus = res.status;
692
+ quoteBodyless = res.body === null && !res.ok;
693
+ quoteBodyAborted = res.body === null && signal?.aborted === true;
382
694
  if (Array.isArray(res.body)) return null;
383
695
  return res.body ?? (res.ok ? null : { error: `HTTP ${res.status}` });
384
696
  };
@@ -386,13 +698,17 @@ function thorchainProvider(config = {}) {
386
698
  let checkedMemo = null;
387
699
  let inbounds = null;
388
700
  let quoteHost = "";
701
+ let quoteHeight = 0;
702
+ let confirmDeadlineAt = 0;
703
+ let heightUnavailable = false;
704
+ const hostFailures = [];
389
705
  for (const host of hosts) {
390
706
  try {
391
707
  if (limitMode === "liquidity_tolerance_bps") {
392
708
  q = await quoteOn(host, String(liquidityBps), "liquidity_tolerance_bps");
393
709
  const feeBps = Number(q?.fees?.total_bps);
394
710
  if (Number.isFinite(feeBps) && feeBps > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
395
- params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${(0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS) / 100}%)`);
711
+ 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" });
396
712
  return null;
397
713
  }
398
714
  } else {
@@ -409,32 +725,44 @@ function thorchainProvider(config = {}) {
409
725
  }
410
726
  }
411
727
  if (q?.error || q?.message) {
412
- params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}`);
728
+ 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));
413
729
  return null;
414
730
  }
415
731
  if (!q || !q.expected_amount_out || !q.inbound_address || !q.memo || !q.expiry) {
416
- params.onRefusal?.(`thorchain: incomplete quote from ${host} (expected_amount_out / inbound_address / memo / expiry missing)`);
732
+ 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 } : {} });
417
733
  return null;
418
734
  }
419
735
  if (chainId ? !q.router : !!q.router) {
420
- 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");
736
+ 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" });
421
737
  return null;
422
738
  }
423
- checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps);
739
+ let memoRefusal = "recipient_mismatch";
740
+ checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps, (why) => {
741
+ memoRefusal = why;
742
+ });
424
743
  if (!checkedMemo) {
425
- params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`);
744
+ params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`, { reason: memoRefusal });
426
745
  return null;
427
746
  }
428
747
  const floor1e8 = params.minAmountOut !== void 0 ? toThorUnitsCeil(BigInt(params.minAmountOut), dst.decimals) : void 0;
429
748
  if (floor1e8 !== void 0 && checkedMemo.limit < floor1e8) {
430
- params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`);
749
+ params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`, { reason: "price_check_failed" });
431
750
  return null;
432
751
  }
433
- inbounds = await inboundAddressesOn(host, clientId);
752
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
753
+ const snapshot = await nodeSnapshot(host, signal);
754
+ if ("heightError" in snapshot) {
755
+ heightUnavailable = true;
756
+ throw snapshot.heightError;
757
+ }
758
+ inbounds = snapshot.inbounds;
759
+ quoteHeight = snapshot.height;
434
760
  quoteHost = host;
761
+ confirmDeadlineAt = deadlineAt;
435
762
  break;
436
763
  } catch (error) {
437
764
  if (error instanceof HostUnavailable) {
765
+ hostFailures.push((0, import_errors2.refusalOf)(error));
438
766
  q = null;
439
767
  checkedMemo = null;
440
768
  inbounds = null;
@@ -444,60 +772,72 @@ function thorchainProvider(config = {}) {
444
772
  }
445
773
  }
446
774
  if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
447
- params.onRefusal?.(!inbounds ? "thorchain: inbound addresses unavailable from every node" : "thorchain: no quote from any node");
775
+ params.onRefusal?.(
776
+ 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",
777
+ inbounds ? { reason: "no_route" } : nodesFailed(hostFailures)
778
+ );
448
779
  return null;
449
780
  }
450
781
  const limit1e8 = checkedMemo.limit;
451
782
  const srcInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.sourceChain]);
452
783
  const dstInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.destChain]);
453
784
  if (paused(srcInbound) || paused(dstInbound)) {
454
- params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`);
785
+ params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`, { reason: "chain_halted" });
455
786
  return null;
456
787
  }
457
788
  if (!sameTarget(srcInbound?.address, q.inbound_address)) {
458
- params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry");
789
+ params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry", { reason: "vault_mismatch" });
459
790
  return null;
460
791
  }
461
792
  if (chainId && !sameTarget(srcInbound?.router, q.router)) {
462
- params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry");
793
+ params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry", { reason: "vault_mismatch" });
463
794
  return null;
464
795
  }
465
796
  if (chainId && !pinnedRouter(params.sourceChain, q.router)) {
466
- params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`);
797
+ params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`, { reason: "vault_mismatch" });
467
798
  return null;
468
799
  }
469
- const unconfirmed = await confirmedElsewhere(quoteHost, params.sourceChain, q.inbound_address, chainId ? q.router : void 0);
800
+ const unconfirmed = await confirmedElsewhere(
801
+ quoteHost,
802
+ quoteHeight,
803
+ params.sourceChain,
804
+ params.destChain,
805
+ q.inbound_address,
806
+ chainId ? q.router : void 0,
807
+ confirmDeadlineAt,
808
+ signal
809
+ );
470
810
  if (unconfirmed) {
471
- params.onRefusal?.(`thorchain: ${unconfirmed}`);
811
+ params.onRefusal?.(`thorchain: ${unconfirmed.text}`, unconfirmed.info);
472
812
  return null;
473
813
  }
474
814
  const tooSmall = belowThorMinimums(amount1e8, srcInbound, q);
475
815
  if (tooSmall) {
476
- params.onRefusal?.(`thorchain: ${tooSmall}`);
816
+ params.onRefusal?.(`thorchain: ${tooSmall}`, { reason: "amount_too_small" });
477
817
  return null;
478
818
  }
479
819
  const outUnits = BigInt(q.expected_amount_out) * 10n ** BigInt(dst.decimals) / 100000000n;
480
820
  if (outUnits <= 0n) {
481
- params.onRefusal?.("thorchain: expected output rounds to zero");
821
+ params.onRefusal?.("thorchain: expected output rounds to zero", { reason: "amount_too_small" });
482
822
  return null;
483
823
  }
484
824
  const memoMinUnits = limit1e8 * 10n ** BigInt(dst.decimals) / 100000000n;
485
825
  const minUnits = memoMinUnits;
486
826
  if (minUnits <= 0n || minUnits > outUnits) {
487
- params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`);
827
+ params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`, { reason: "price_check_failed" });
488
828
  return null;
489
829
  }
490
830
  if (params.slippageBps !== void 0) {
491
831
  const slippageFloor = outUnits * BigInt(1e4 - params.slippageBps) / 10000n;
492
832
  if (minUnits < slippageFloor) {
493
- params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`);
833
+ params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`, { reason: "price_check_failed" });
494
834
  return null;
495
835
  }
496
836
  }
497
837
  const nowSeconds = Math.floor(Date.now() / 1e3);
498
838
  const executionExpiry = Math.min(q.expiry, nowSeconds + 15 * 60);
499
839
  if (!Number.isInteger(executionExpiry) || executionExpiry <= nowSeconds + 30) {
500
- params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`);
840
+ params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`, { reason: "quote_expired" });
501
841
  return null;
502
842
  }
503
843
  const assetAddr = src.contract ? src.contract : NATIVE;
@@ -553,7 +893,7 @@ function thorchainProvider(config = {}) {
553
893
  if (!quote.txRequest) {
554
894
  const sourceChain = quote.providerData?.sourceChain ?? "";
555
895
  if (!isDepositSource(sourceChain) || !quote.depositAddress || !quote.depositMemo) {
556
- throw new Error("thorchain: deposit-address execution is only supported for an opted-in Tron source");
896
+ throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: deposit-address execution is only supported for an opted-in Tron source");
557
897
  }
558
898
  return { chain: sourceChain, to: quote.depositAddress, value: quote.sourceAmount, memo: quote.depositMemo };
559
899
  }
@@ -570,67 +910,96 @@ function thorchainProvider(config = {}) {
570
910
  async preflightDeposit(quote, tx) {
571
911
  const data = quote.providerData;
572
912
  if (!data?.sourceChain || !data.destChain || !data.inboundAddress || !data.assetAddress || !data.expiry || !data.memo) {
573
- throw new Error("thorchain: quote is missing execution-bound metadata");
913
+ throw (0, import_errors2.refusal)("policy_refused", "thorchain: quote is missing execution-bound metadata");
574
914
  }
575
915
  const evmSource = CHAIN_ID[data.sourceChain] !== void 0;
576
- if (evmSource && !data.router) throw new Error("thorchain: quote is missing execution-bound metadata");
577
- if (!evmSource && !isDepositSource(data.sourceChain)) throw new Error("thorchain: source chain has no executor");
916
+ if (evmSource && !data.router) throw (0, import_errors2.refusal)("policy_refused", "thorchain: quote is missing execution-bound metadata");
917
+ if (!evmSource && !isDepositSource(data.sourceChain)) throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: source chain has no executor");
578
918
  if (!/^\d+$/.test(data.expiry) || BigInt(data.expiry) <= BigInt(Math.floor(Date.now() / 1e3))) {
579
- throw new Error("thorchain: quote expired before broadcast");
919
+ throw (0, import_errors2.refusal)("quote_expired", "thorchain: quote expired before broadcast");
580
920
  }
581
- if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw new Error("thorchain: the quote's router is not one this module trusts");
921
+ 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");
582
922
  let inbounds = null;
583
923
  let snapshotHost = "";
924
+ let snapshotHeight = 0;
925
+ let confirmDeadlineAt = 0;
926
+ let heightUnavailable = false;
927
+ const hostFailures = [];
584
928
  for (const host of hosts) {
929
+ const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
585
930
  try {
586
- inbounds = await inboundAddressesOn(host, clientId);
931
+ const snapshot = await nodeSnapshot(host);
932
+ if ("heightError" in snapshot) {
933
+ heightUnavailable = true;
934
+ hostFailures.push((0, import_errors2.refusalOf)(snapshot.heightError));
935
+ continue;
936
+ }
937
+ inbounds = snapshot.inbounds;
938
+ snapshotHeight = snapshot.height;
587
939
  snapshotHost = host;
940
+ confirmDeadlineAt = deadlineAt;
588
941
  break;
589
942
  } catch (error) {
943
+ inbounds = null;
590
944
  if (!(error instanceof HostUnavailable)) throw error;
945
+ hostFailures.push((0, import_errors2.refusalOf)(error));
591
946
  }
592
947
  }
593
- if (!inbounds) throw new Error("thorchain: cannot refresh inbound addresses before broadcast");
948
+ if (!inbounds) {
949
+ throw (0, import_errors2.withReason)(new Error(
950
+ 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"
951
+ ), nodesFailed(hostFailures));
952
+ }
594
953
  const confirm = async () => {
595
- const unconfirmed = await confirmedElsewhere(snapshotHost, data.sourceChain, data.inboundAddress, evmSource ? data.router : void 0);
596
- if (unconfirmed) throw new Error(`thorchain: ${unconfirmed}`);
954
+ const unconfirmed = await confirmedElsewhere(
955
+ snapshotHost,
956
+ snapshotHeight,
957
+ data.sourceChain,
958
+ data.destChain,
959
+ data.inboundAddress,
960
+ evmSource ? data.router : void 0,
961
+ confirmDeadlineAt
962
+ );
963
+ if (unconfirmed) throw (0, import_errors2.withReason)(new Error(`thorchain: ${unconfirmed.text}`), unconfirmed.info);
597
964
  };
598
965
  const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);
599
966
  const dst = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.destChain]);
600
- if (paused(src) || paused(dst)) throw new Error("thorchain: source or destination chain trading is paused");
601
- if (!sameTarget(src?.address, data.inboundAddress)) throw new Error("thorchain: inbound vault rotated after quote");
967
+ if (paused(src) || paused(dst)) throw (0, import_errors2.refusal)("chain_halted", "thorchain: source or destination chain trading is paused");
968
+ if (!sameTarget(src?.address, data.inboundAddress)) throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: inbound vault rotated after quote");
602
969
  if (!evmSource) {
603
970
  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) {
604
- throw new Error("thorchain: deposit-address execution must be a plain send with memo");
971
+ throw (0, import_errors2.refusal)("policy_refused", "thorchain: deposit-address execution must be a plain send with memo");
605
972
  }
606
973
  if (!sameTarget(tx.to, data.inboundAddress) || tx.memo !== data.memo || tx.value !== quote.sourceAmount || tx.chain && tx.chain !== data.sourceChain) {
607
- throw new Error("thorchain: deposit does not match the validated quote");
974
+ throw (0, import_errors2.refusal)(!sameTarget(tx.to, data.inboundAddress) ? "vault_mismatch" : "policy_refused", "thorchain: deposit does not match the validated quote");
608
975
  }
609
976
  const srcAsset = (0, import_swap2_assets.ccAssetByToken)(data.sourceChain, sameTarget(data.assetAddress, NATIVE) ? "" : data.assetAddress);
610
- if (!srcAsset) throw new Error("thorchain: unknown source asset");
977
+ if (!srcAsset) throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: unknown source asset");
611
978
  const amount1e8 = BigInt(quote.sourceAmount) * 100000000n / 10n ** BigInt(srcAsset.decimals);
612
979
  const tooSmall = belowThorMinimums(amount1e8, src, null);
613
- if (tooSmall) throw new Error(`thorchain: ${tooSmall}`);
980
+ if (tooSmall) throw (0, import_errors2.refusal)("amount_too_small", `thorchain: ${tooSmall}`);
614
981
  await confirm();
615
982
  return;
616
983
  }
617
984
  if (!sameTarget(src?.router, data.router) || !sameTarget(tx.to, data.router)) {
618
- throw new Error("thorchain: router rotated after quote");
985
+ throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: router rotated after quote");
619
986
  }
620
- if (!tx.data) throw new Error("thorchain: missing depositWithExpiry calldata");
987
+ if (!tx.data) throw (0, import_errors2.refusal)("policy_refused", "thorchain: missing depositWithExpiry calldata");
621
988
  let decoded;
622
989
  try {
623
990
  decoded = (0, import_viem.decodeFunctionData)({ abi: ROUTER_ABI, data: tx.data });
624
991
  } catch {
625
- throw new Error("thorchain: malformed depositWithExpiry calldata");
992
+ throw (0, import_errors2.refusal)("policy_refused", "thorchain: malformed depositWithExpiry calldata");
626
993
  }
627
994
  const args = decoded.args;
628
995
  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]) {
629
- throw new Error("thorchain: calldata does not match the validated quote");
996
+ const vaultChanged = decoded.functionName === "depositWithExpiry" && !!args && args.length === 5 && !sameTarget(String(args[0]), data.inboundAddress);
997
+ throw (0, import_errors2.refusal)(vaultChanged ? "vault_mismatch" : "policy_refused", "thorchain: calldata does not match the validated quote");
630
998
  }
631
999
  const tokenSource = !sameTarget(data.assetAddress, NATIVE);
632
1000
  if (tx.value !== (tokenSource ? "0" : quote.sourceAmount) || (tokenSource ? !sameTarget(tx.approvalAddress, data.router) : tx.approvalAddress !== void 0)) {
633
- throw new Error("thorchain: value/approval does not match the validated source asset");
1001
+ const spenderChanged = tokenSource && tx.approvalAddress !== void 0 && !sameTarget(tx.approvalAddress, data.router);
1002
+ throw (0, import_errors2.refusal)(spenderChanged ? "vault_mismatch" : "policy_refused", "thorchain: value/approval does not match the validated source asset");
634
1003
  }
635
1004
  await confirm();
636
1005
  },