@anywalletkit/protocol-swap2-thorchain 0.4.0 → 0.4.2

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
@@ -77,10 +77,12 @@ function describeThorchainActivity(tx, ctx) {
77
77
  // src/provider.ts
78
78
  var import_viem = require("viem");
79
79
  var import_util2 = require("@anywalletkit/protocol-swap2-core/util");
80
+ var import_errors2 = require("@anywalletkit/protocol-swap2-core/errors");
80
81
  var import_swap2_assets = require("@anywalletkit/swap2-assets");
81
82
  var import_core = require("@anywalletkit/core");
82
83
 
83
84
  // src/confirm.ts
85
+ var import_errors = require("@anywalletkit/protocol-swap2-core/errors");
84
86
  var THORNODE_CONFIRM_PATHS = Object.freeze({
85
87
  lastblock: "/thorchain/lastblock",
86
88
  inboundAddresses: "/thorchain/inbound_addresses",
@@ -103,10 +105,18 @@ function confirmOptions(o) {
103
105
  return { maxLagBlocks, timeoutMs };
104
106
  }
105
107
  var HostUnavailable = class extends Error {
106
- constructor(host, reason) {
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) {
107
113
  super(`thorchain: ${host} unavailable (${reason})`);
114
+ (0, import_errors.withReason)(this, info ?? "upstream_unavailable");
108
115
  }
109
116
  };
117
+ function statusRefusal(status, retryAfter) {
118
+ return (0, import_errors.httpRefusal)(status, retryAfter) ?? { reason: "upstream_unavailable", httpStatus: status };
119
+ }
110
120
  function paused(entry) {
111
121
  return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
112
122
  }
@@ -152,7 +162,7 @@ async function nodeRead(host, path, clientId, signal, outer) {
152
162
  throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
153
163
  }
154
164
  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}`);
165
+ if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
156
166
  try {
157
167
  return await res.json();
158
168
  } catch (error) {
@@ -384,7 +394,7 @@ async function hostGet(host, path, clientId, signal) {
384
394
  throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
385
395
  }
386
396
  if (res.status >= 500 || res.status === 429 || res.status === 403) {
387
- throw new HostUnavailable(host, `HTTP ${res.status}`);
397
+ throw new HostUnavailable(host, `HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
388
398
  }
389
399
  const body = await res.json().catch(() => null);
390
400
  return { ok: res.ok, status: res.status, body };
@@ -409,30 +419,53 @@ function belowThorMinimums(amount1e8, inbound, quote) {
409
419
  if (min !== void 0 && (!/^\d+$/.test(String(min)) || amount1e8 < BigInt(min))) return "amount below THORChain recommended minimum";
410
420
  return null;
411
421
  }
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;
427
+ }
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" };
440
+ }
412
441
  async function inboundAddressesOn(host, clientId) {
413
442
  const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
414
- if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`);
443
+ if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`, res.ok ? void 0 : statusRefusal(res.status));
415
444
  const entries = inboundAddresses(res.body);
416
445
  if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
417
446
  return entries;
418
447
  }
419
- function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
420
- 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");
421
454
  const parts = memo.split(":");
422
- if (parts.length !== 4 && parts.length !== 6) return null;
455
+ if (parts.length !== 4 && parts.length !== 6) return refused("policy_refused");
423
456
  const [op, asset, destination, limit, affiliate, bps] = parts;
424
- 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");
425
458
  const expectedTarget = expectedRefund ? `${expectedDestination}/${expectedRefund}` : expectedDestination;
426
- if (!sameTarget(destination, expectedTarget)) return null;
459
+ if (!sameTarget(destination, expectedTarget)) return refused("recipient_mismatch");
427
460
  if (parts.length === 6) {
428
- if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return null;
461
+ if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return refused("policy_refused");
429
462
  } else if (expectedAffiliate || expectedAffiliateBps) {
430
- return null;
463
+ return refused("policy_refused");
431
464
  }
432
465
  const stream = (limit ?? "").split("/");
433
- 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");
434
467
  const floor = BigInt(stream[0] ?? "0");
435
- if (floor <= 0n) return null;
468
+ if (floor <= 0n) return refused("price_check_failed");
436
469
  const canonical = `=:${expectedAsset}:${expectedTarget}:${stream.join("/")}${expectedAffiliate ? `:${expectedAffiliate}:${expectedAffiliateBps}` : ""}`;
437
470
  return { limit: floor, canonical };
438
471
  }
@@ -445,7 +478,7 @@ function thorchainPins(opts = {}) {
445
478
  for (const n of nodes) if (!/^https:\/\//i.test(n)) throw new Error("thorchain: thorchainPins nodeUrl must use https");
446
479
  const clientId = opts.clientId?.trim() || DEFAULT_CLIENT_ID;
447
480
  const { maxLagBlocks, timeoutMs } = confirmOptions(opts);
448
- const askNode = async (host, claim) => {
481
+ const askNode = async (host, claim, failures) => {
449
482
  const node = nodeDeadline(timeoutMs);
450
483
  const read = (path) => nodeRead(host, path, clientId, node.signal);
451
484
  try {
@@ -453,59 +486,71 @@ function thorchainPins(opts = {}) {
453
486
  try {
454
487
  return { height: head.height, verdict: await vaultVerdict(host, read, head.inbound, claim) };
455
488
  } catch (error) {
456
- if (error instanceof HostUnavailable) return { height: head.height, verdict: null };
489
+ if (error instanceof HostUnavailable) {
490
+ failures.push((0, import_errors2.refusalOf)(error));
491
+ return { height: head.height, verdict: null };
492
+ }
457
493
  throw error;
458
494
  }
459
495
  } catch (error) {
460
- if (error instanceof HostUnavailable) return null;
496
+ if (error instanceof HostUnavailable) {
497
+ failures.push((0, import_errors2.refusalOf)(error));
498
+ return null;
499
+ }
461
500
  throw error;
462
501
  } finally {
463
502
  node.clear();
464
503
  }
465
504
  };
466
505
  const routersFor = (chain) => [...THORCHAIN_ROUTERS[chain] ?? [], ...opts.trustedRouters?.[chain] ?? []];
506
+ const { note, reasonOf } = (0, import_errors2.refusalNotes)();
467
507
  return Object.freeze({
468
508
  callTargets: routersFor,
469
509
  spenders: routersFor,
510
+ reasonOf,
470
511
  // A Tron source is a send to the inbound vault; verifyBuild confirms that vault with two THORNodes (scan 2026-10-28 F9).
471
512
  depositAddressRoutes: true,
472
513
  async verifyBuild(quote, tx, params) {
473
- if (!params) return "the request this quote answered is unknown";
514
+ if (!params) return note("the request this quote answered is unknown", "policy_refused");
474
515
  const src = (0, import_swap2_assets.ccAsset)(params.sourceChain, params.sourceToken);
475
516
  const dst = destAsset(params.destChain, params.destToken);
476
- if (!src || !dst) return "unknown source or destination asset";
517
+ if (!src || !dst) return note("unknown source or destination asset", "unsupported_pair");
477
518
  const chainId = CHAIN_ID[params.sourceChain];
478
519
  let vault, memo, router;
479
520
  if (chainId) {
480
- if (!tx.data) return "no router calldata";
521
+ if (!tx.data) return note("no router calldata", "policy_refused");
481
522
  let args;
482
523
  try {
483
524
  const d = (0, import_viem.decodeFunctionData)({ abi: ROUTER_ABI, data: tx.data });
484
- if (d.functionName !== "depositWithExpiry") return `${d.functionName} is not depositWithExpiry`;
525
+ if (d.functionName !== "depositWithExpiry") return note(`${d.functionName} is not depositWithExpiry`, "policy_refused");
485
526
  args = d.args;
486
527
  } catch {
487
- return "the router calldata does not decode";
528
+ return note("the router calldata does not decode", "policy_refused");
488
529
  }
489
- 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");
490
531
  router = tx.to;
491
- 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");
492
533
  vault = String(args[0]);
493
534
  const asset = String(args[1]);
494
- if (!sameTarget(asset, src.contract ?? NATIVE)) return `the router call moves ${asset}, not the source asset`;
495
- 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");
496
537
  memo = String(args[3]);
497
538
  } else {
498
- 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");
499
540
  vault = tx.to;
500
541
  memo = tx.memo;
501
542
  }
502
- const checked = verifiedMemo(memo, dst.asset, params.destAddress, params.refundAddress ?? "", opts.affiliate ?? "", opts.affiliateBps ?? "");
503
- if (!checked) return `the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`;
504
- if (memo !== checked.canonical) return `the memo is not the canonical ${checked.canonical}`;
505
- 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");
506
550
  const sourceChain = THOR_CHAIN[params.sourceChain] ?? params.sourceChain;
507
551
  const claim = { chain: sourceChain, destChain: THOR_CHAIN[params.destChain] ?? params.destChain, vault, ...router !== void 0 ? { router } : {} };
508
- const views = await Promise.all(nodes.map((host) => askNode(host, claim)));
552
+ const failures = [];
553
+ const views = await Promise.all(nodes.map((host) => askNode(host, claim, failures)));
509
554
  const top = views.reduce((max, v) => v && v.height > max ? v.height : max, -1);
510
555
  let agreeing = 0;
511
556
  let lagging = false;
@@ -519,20 +564,20 @@ function thorchainPins(opts = {}) {
519
564
  if (!v.ok) {
520
565
  switch (v.why) {
521
566
  case "halted":
522
- return `${v.chain} is halted / paused`;
567
+ return note(`${v.chain} is halted / paused`, "chain_halted");
523
568
  case "another-vault":
524
- return `a THORNode reports another inbound vault than ${vault}`;
569
+ return note(`a THORNode reports another inbound vault than ${vault}`, "vault_mismatch");
525
570
  case "not-active":
526
- return `a THORNode lists the inbound vault as ${v.status}, not ActiveVault`;
571
+ return note(`a THORNode lists the inbound vault as ${v.status}, not ActiveVault`, "vault_mismatch");
527
572
  case "flagged":
528
- return `a THORNode's mimir flags the inbound vault as ${v.flag}`;
573
+ return note(`a THORNode's mimir flags the inbound vault as ${v.flag}`, "vault_mismatch");
529
574
  case "another-router":
530
- return "a THORNode reports another router";
575
+ return note("a THORNode reports another router", "vault_mismatch");
531
576
  }
532
577
  }
533
578
  if (++agreeing >= 2) return null;
534
579
  }
535
- return lagging ? `two THORNodes within ${maxLagBlocks} blocks of the highest THORChain height they report could not confirm the inbound vault (behind or unreachable)` : "two THORNodes could not confirm the inbound vault";
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));
536
581
  }
537
582
  });
538
583
  }
@@ -613,15 +658,15 @@ function thorchainProvider(config = {}) {
613
658
  const confirmText = (v) => {
614
659
  switch (v.why) {
615
660
  case "halted":
616
- return `${v.chain} is halted / paused on the confirming node`;
661
+ return { text: `${v.chain} is halted / paused on the confirming node`, info: { reason: "chain_halted" } };
617
662
  case "another-vault":
618
- return "a second node reports another inbound vault \u2014 refused until they agree";
663
+ return { text: "a second node reports another inbound vault \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
619
664
  case "not-active":
620
- return `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`;
665
+ return { text: `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`, info: { reason: "vault_mismatch" } };
621
666
  case "flagged":
622
- return `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`;
667
+ return { text: `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`, info: { reason: "vault_mismatch" } };
623
668
  case "another-router":
624
- return "a second node reports another router \u2014 refused until they agree";
669
+ return { text: "a second node reports another router \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
625
670
  }
626
671
  };
627
672
  const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
@@ -640,10 +685,14 @@ function thorchainProvider(config = {}) {
640
685
  };
641
686
  let lagging = false;
642
687
  let asked = 0;
688
+ const failures = [];
643
689
  for (const host of confirmHosts) {
644
690
  if (host === quoteHost) continue;
645
691
  const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
646
- if (budget < MIN_CONFIRM_TIMEOUT_MS) break;
692
+ if (budget < MIN_CONFIRM_TIMEOUT_MS) {
693
+ failures.push({ reason: "upstream_unavailable" });
694
+ break;
695
+ }
647
696
  asked++;
648
697
  const node = nodeDeadline(budget, outer);
649
698
  try {
@@ -655,19 +704,22 @@ function thorchainProvider(config = {}) {
655
704
  return verdict;
656
705
  } catch (error) {
657
706
  if (outer?.aborted) throw error;
658
- if (error instanceof HostUnavailable) continue;
707
+ if (error instanceof HostUnavailable) {
708
+ failures.push((0, import_errors2.refusalOf)(error));
709
+ continue;
710
+ }
659
711
  throw error;
660
712
  } finally {
661
713
  node.clear();
662
714
  }
663
715
  }
664
716
  if (lagging) {
665
- return `no confirming node is within ${confirmMaxLagBlocks} blocks of the quoting node's THORChain height (behind or unreachable) \u2014 refused until one catches up`;
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" } };
666
718
  }
667
719
  if (asked === 0) {
668
- return `the ${CONFIRM_BUDGET_MS / 1e3} s confirmation budget ran out before a second node could be asked to confirm the inbound vault \u2014 retry`;
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" } };
669
721
  }
670
- return "no second node answered to confirm the inbound vault (configure confirmNodeUrl)";
722
+ return { text: "no second node answered to confirm the inbound vault (configure confirmNodeUrl)", info: nodesFailed(failures) };
671
723
  };
672
724
  const configuredAffiliate = config.affiliate ?? "";
673
725
  const configuredAffiliateBps = config.affiliateBps ?? "";
@@ -713,24 +765,27 @@ function thorchainProvider(config = {}) {
713
765
  const fromAsset = thorAsset(params.sourceChain, params.sourceToken);
714
766
  const dst = destAsset(params.destChain, params.destToken);
715
767
  if (!src || !fromAsset || !dst || fromAsset === dst.asset) {
716
- 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" });
717
769
  return null;
718
770
  }
719
771
  const amountIn = BigInt(params.amountIn);
720
772
  const amount1e8 = amountIn * 100000000n / 10n ** BigInt(src.decimals);
721
773
  if (amount1e8 <= 0n) return null;
722
774
  if (amount1e8 * 10n ** BigInt(src.decimals) !== amountIn * 100000000n) {
723
- 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" });
724
776
  return null;
725
777
  }
726
778
  const requestedTolerance = params.slippageBps ?? Number(TOLERANCE_BPS);
727
779
  if (!Number.isInteger(requestedTolerance) || requestedTolerance < 0 || requestedTolerance > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
728
- 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" });
729
781
  return null;
730
782
  }
731
783
  const explicitSlippage = params.slippageBps !== void 0;
732
784
  const liquidityBps = explicitSlippage ? dst.decimals > 8 ? requestedTolerance - 1 : requestedTolerance : 0;
733
785
  const limitMode = liquidityBps > 0 ? "liquidity_tolerance_bps" : "tolerance_bps";
786
+ let quoteStatus = 0;
787
+ let quoteBodyless = false;
788
+ let quoteBodyAborted = false;
734
789
  const quoteOn = async (host, bps, limitMode2 = "tolerance_bps") => {
735
790
  const qs = new URLSearchParams({
736
791
  from_asset: fromAsset,
@@ -751,6 +806,9 @@ function thorchainProvider(config = {}) {
751
806
  }
752
807
  if (params.refundAddress) qs.set("refund_address", params.refundAddress);
753
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;
754
812
  if (Array.isArray(res.body)) return null;
755
813
  return res.body ?? (res.ok ? null : { error: `HTTP ${res.status}` });
756
814
  };
@@ -761,13 +819,14 @@ function thorchainProvider(config = {}) {
761
819
  let quoteHeight = 0;
762
820
  let confirmDeadlineAt = 0;
763
821
  let heightUnavailable = false;
822
+ const hostFailures = [];
764
823
  for (const host of hosts) {
765
824
  try {
766
825
  if (limitMode === "liquidity_tolerance_bps") {
767
826
  q = await quoteOn(host, String(liquidityBps), "liquidity_tolerance_bps");
768
827
  const feeBps = Number(q?.fees?.total_bps);
769
828
  if (Number.isFinite(feeBps) && feeBps > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
770
- 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" });
771
830
  return null;
772
831
  }
773
832
  } else {
@@ -784,25 +843,28 @@ function thorchainProvider(config = {}) {
784
843
  }
785
844
  }
786
845
  if (q?.error || q?.message) {
787
- 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));
788
847
  return null;
789
848
  }
790
849
  if (!q || !q.expected_amount_out || !q.inbound_address || !q.memo || !q.expiry) {
791
- 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 } : {} });
792
851
  return null;
793
852
  }
794
853
  if (chainId ? !q.router : !!q.router) {
795
- 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" });
796
855
  return null;
797
856
  }
798
- 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
+ });
799
861
  if (!checkedMemo) {
800
- 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 });
801
863
  return null;
802
864
  }
803
865
  const floor1e8 = params.minAmountOut !== void 0 ? toThorUnitsCeil(BigInt(params.minAmountOut), dst.decimals) : void 0;
804
866
  if (floor1e8 !== void 0 && checkedMemo.limit < floor1e8) {
805
- 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" });
806
868
  return null;
807
869
  }
808
870
  const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
@@ -818,6 +880,7 @@ function thorchainProvider(config = {}) {
818
880
  break;
819
881
  } catch (error) {
820
882
  if (error instanceof HostUnavailable) {
883
+ hostFailures.push((0, import_errors2.refusalOf)(error));
821
884
  q = null;
822
885
  checkedMemo = null;
823
886
  inbounds = null;
@@ -828,7 +891,8 @@ function thorchainProvider(config = {}) {
828
891
  }
829
892
  if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
830
893
  params.onRefusal?.(
831
- 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"
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)
832
896
  );
833
897
  return null;
834
898
  }
@@ -836,19 +900,19 @@ function thorchainProvider(config = {}) {
836
900
  const srcInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.sourceChain]);
837
901
  const dstInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.destChain]);
838
902
  if (paused(srcInbound) || paused(dstInbound)) {
839
- 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" });
840
904
  return null;
841
905
  }
842
906
  if (!sameTarget(srcInbound?.address, q.inbound_address)) {
843
- 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" });
844
908
  return null;
845
909
  }
846
910
  if (chainId && !sameTarget(srcInbound?.router, q.router)) {
847
- 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" });
848
912
  return null;
849
913
  }
850
914
  if (chainId && !pinnedRouter(params.sourceChain, q.router)) {
851
- 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" });
852
916
  return null;
853
917
  }
854
918
  const unconfirmed = await confirmedElsewhere(
@@ -862,36 +926,36 @@ function thorchainProvider(config = {}) {
862
926
  signal
863
927
  );
864
928
  if (unconfirmed) {
865
- params.onRefusal?.(`thorchain: ${unconfirmed}`);
929
+ params.onRefusal?.(`thorchain: ${unconfirmed.text}`, unconfirmed.info);
866
930
  return null;
867
931
  }
868
932
  const tooSmall = belowThorMinimums(amount1e8, srcInbound, q);
869
933
  if (tooSmall) {
870
- params.onRefusal?.(`thorchain: ${tooSmall}`);
934
+ params.onRefusal?.(`thorchain: ${tooSmall}`, { reason: "amount_too_small" });
871
935
  return null;
872
936
  }
873
937
  const outUnits = BigInt(q.expected_amount_out) * 10n ** BigInt(dst.decimals) / 100000000n;
874
938
  if (outUnits <= 0n) {
875
- params.onRefusal?.("thorchain: expected output rounds to zero");
939
+ params.onRefusal?.("thorchain: expected output rounds to zero", { reason: "amount_too_small" });
876
940
  return null;
877
941
  }
878
942
  const memoMinUnits = limit1e8 * 10n ** BigInt(dst.decimals) / 100000000n;
879
943
  const minUnits = memoMinUnits;
880
944
  if (minUnits <= 0n || minUnits > outUnits) {
881
- 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" });
882
946
  return null;
883
947
  }
884
948
  if (params.slippageBps !== void 0) {
885
949
  const slippageFloor = outUnits * BigInt(1e4 - params.slippageBps) / 10000n;
886
950
  if (minUnits < slippageFloor) {
887
- 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" });
888
952
  return null;
889
953
  }
890
954
  }
891
955
  const nowSeconds = Math.floor(Date.now() / 1e3);
892
956
  const executionExpiry = Math.min(q.expiry, nowSeconds + 15 * 60);
893
957
  if (!Number.isInteger(executionExpiry) || executionExpiry <= nowSeconds + 30) {
894
- 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" });
895
959
  return null;
896
960
  }
897
961
  const assetAddr = src.contract ? src.contract : NATIVE;
@@ -947,7 +1011,7 @@ function thorchainProvider(config = {}) {
947
1011
  if (!quote.txRequest) {
948
1012
  const sourceChain = quote.providerData?.sourceChain ?? "";
949
1013
  if (!isDepositSource(sourceChain) || !quote.depositAddress || !quote.depositMemo) {
950
- 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");
951
1015
  }
952
1016
  return { chain: sourceChain, to: quote.depositAddress, value: quote.sourceAmount, memo: quote.depositMemo };
953
1017
  }
@@ -964,26 +1028,28 @@ function thorchainProvider(config = {}) {
964
1028
  async preflightDeposit(quote, tx) {
965
1029
  const data = quote.providerData;
966
1030
  if (!data?.sourceChain || !data.destChain || !data.inboundAddress || !data.assetAddress || !data.expiry || !data.memo) {
967
- 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");
968
1032
  }
969
1033
  const evmSource = CHAIN_ID[data.sourceChain] !== void 0;
970
- if (evmSource && !data.router) throw new Error("thorchain: quote is missing execution-bound metadata");
971
- 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");
972
1036
  if (!/^\d+$/.test(data.expiry) || BigInt(data.expiry) <= BigInt(Math.floor(Date.now() / 1e3))) {
973
- throw new Error("thorchain: quote expired before broadcast");
1037
+ throw (0, import_errors2.refusal)("quote_expired", "thorchain: quote expired before broadcast");
974
1038
  }
975
- 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");
976
1040
  let inbounds = null;
977
1041
  let snapshotHost = "";
978
1042
  let snapshotHeight = 0;
979
1043
  let confirmDeadlineAt = 0;
980
1044
  let heightUnavailable = false;
1045
+ const hostFailures = [];
981
1046
  for (const host of hosts) {
982
1047
  const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
983
1048
  try {
984
1049
  const snapshot = await nodeSnapshot(host);
985
1050
  if ("heightError" in snapshot) {
986
1051
  heightUnavailable = true;
1052
+ hostFailures.push((0, import_errors2.refusalOf)(snapshot.heightError));
987
1053
  continue;
988
1054
  }
989
1055
  inbounds = snapshot.inbounds;
@@ -994,12 +1060,13 @@ function thorchainProvider(config = {}) {
994
1060
  } catch (error) {
995
1061
  inbounds = null;
996
1062
  if (!(error instanceof HostUnavailable)) throw error;
1063
+ hostFailures.push((0, import_errors2.refusalOf)(error));
997
1064
  }
998
1065
  }
999
1066
  if (!inbounds) {
1000
- throw new Error(
1067
+ throw (0, import_errors2.withReason)(new Error(
1001
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"
1002
- );
1069
+ ), nodesFailed(hostFailures));
1003
1070
  }
1004
1071
  const confirm = async () => {
1005
1072
  const unconfirmed = await confirmedElsewhere(
@@ -1011,44 +1078,46 @@ function thorchainProvider(config = {}) {
1011
1078
  evmSource ? data.router : void 0,
1012
1079
  confirmDeadlineAt
1013
1080
  );
1014
- if (unconfirmed) throw new Error(`thorchain: ${unconfirmed}`);
1081
+ if (unconfirmed) throw (0, import_errors2.withReason)(new Error(`thorchain: ${unconfirmed.text}`), unconfirmed.info);
1015
1082
  };
1016
1083
  const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);
1017
1084
  const dst = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.destChain]);
1018
- if (paused(src) || paused(dst)) throw new Error("thorchain: source or destination chain trading is paused");
1019
- 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");
1020
1087
  if (!evmSource) {
1021
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) {
1022
- 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");
1023
1090
  }
1024
1091
  if (!sameTarget(tx.to, data.inboundAddress) || tx.memo !== data.memo || tx.value !== quote.sourceAmount || tx.chain && tx.chain !== data.sourceChain) {
1025
- 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");
1026
1093
  }
1027
1094
  const srcAsset = (0, import_swap2_assets.ccAssetByToken)(data.sourceChain, sameTarget(data.assetAddress, NATIVE) ? "" : data.assetAddress);
1028
- if (!srcAsset) throw new Error("thorchain: unknown source asset");
1095
+ if (!srcAsset) throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: unknown source asset");
1029
1096
  const amount1e8 = BigInt(quote.sourceAmount) * 100000000n / 10n ** BigInt(srcAsset.decimals);
1030
1097
  const tooSmall = belowThorMinimums(amount1e8, src, null);
1031
- if (tooSmall) throw new Error(`thorchain: ${tooSmall}`);
1098
+ if (tooSmall) throw (0, import_errors2.refusal)("amount_too_small", `thorchain: ${tooSmall}`);
1032
1099
  await confirm();
1033
1100
  return;
1034
1101
  }
1035
1102
  if (!sameTarget(src?.router, data.router) || !sameTarget(tx.to, data.router)) {
1036
- throw new Error("thorchain: router rotated after quote");
1103
+ throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: router rotated after quote");
1037
1104
  }
1038
- 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");
1039
1106
  let decoded;
1040
1107
  try {
1041
1108
  decoded = (0, import_viem.decodeFunctionData)({ abi: ROUTER_ABI, data: tx.data });
1042
1109
  } catch {
1043
- throw new Error("thorchain: malformed depositWithExpiry calldata");
1110
+ throw (0, import_errors2.refusal)("policy_refused", "thorchain: malformed depositWithExpiry calldata");
1044
1111
  }
1045
1112
  const args = decoded.args;
1046
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]) {
1047
- 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");
1048
1116
  }
1049
1117
  const tokenSource = !sameTarget(data.assetAddress, NATIVE);
1050
1118
  if (tx.value !== (tokenSource ? "0" : quote.sourceAmount) || (tokenSource ? !sameTarget(tx.approvalAddress, data.router) : tx.approvalAddress !== void 0)) {
1051
- 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");
1052
1121
  }
1053
1122
  await confirm();
1054
1123
  },