@anywalletkit/protocol-swap2-thorchain 0.4.0 → 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.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ThorchainSwap2Protocol
3
- } from "./chunk-LP5BU2JS.js";
4
- import "./chunk-AYP7O2NT.js";
3
+ } from "./chunk-YKA6IYKX.js";
4
+ import "./chunk-EIAXGWNG.js";
5
5
  import "./chunk-DIQDMNP7.js";
6
6
  import "./chunk-X6XXFQ2M.js";
7
7
  export {
package/dist/provider.cjs CHANGED
@@ -69,10 +69,12 @@ function describeThorchainActivity(tx, ctx) {
69
69
  // src/provider.ts
70
70
  var import_viem = require("viem");
71
71
  var import_util2 = require("@anywalletkit/protocol-swap2-core/util");
72
+ var import_errors2 = require("@anywalletkit/protocol-swap2-core/errors");
72
73
  var import_swap2_assets = require("@anywalletkit/swap2-assets");
73
74
  var import_core = require("@anywalletkit/core");
74
75
 
75
76
  // src/confirm.ts
77
+ var import_errors = require("@anywalletkit/protocol-swap2-core/errors");
76
78
  var THORNODE_CONFIRM_PATHS = Object.freeze({
77
79
  lastblock: "/thorchain/lastblock",
78
80
  inboundAddresses: "/thorchain/inbound_addresses",
@@ -95,10 +97,18 @@ function confirmOptions(o) {
95
97
  return { maxLagBlocks, timeoutMs };
96
98
  }
97
99
  var HostUnavailable = class extends Error {
98
- constructor(host, reason) {
100
+ /**
101
+ * `info` classifies the failure (`refusalOf` reads it back): what the HTTP status says (429 `rate_limited`, 401 / 403
102
+ * `key_rejected`), else `upstream_unavailable` — unreachable, timed out, a 5xx, redirected or another shape.
103
+ */
104
+ constructor(host, reason, info) {
99
105
  super(`thorchain: ${host} unavailable (${reason})`);
106
+ (0, import_errors.withReason)(this, info ?? "upstream_unavailable");
100
107
  }
101
108
  };
109
+ function statusRefusal(status, retryAfter) {
110
+ return (0, import_errors.httpRefusal)(status, retryAfter) ?? { reason: "upstream_unavailable", httpStatus: status };
111
+ }
102
112
  function paused(entry) {
103
113
  return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
104
114
  }
@@ -144,7 +154,7 @@ async function nodeRead(host, path, clientId, signal, outer) {
144
154
  throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
145
155
  }
146
156
  if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
147
- if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`);
157
+ if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
148
158
  try {
149
159
  return await res.json();
150
160
  } catch (error) {
@@ -376,7 +386,7 @@ async function hostGet(host, path, clientId, signal) {
376
386
  throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
377
387
  }
378
388
  if (res.status >= 500 || res.status === 429 || res.status === 403) {
379
- throw new HostUnavailable(host, `HTTP ${res.status}`);
389
+ throw new HostUnavailable(host, `HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
380
390
  }
381
391
  const body = await res.json().catch(() => null);
382
392
  return { ok: res.ok, status: res.status, body };
@@ -401,30 +411,53 @@ function belowThorMinimums(amount1e8, inbound, quote) {
401
411
  if (min !== void 0 && (!/^\d+$/.test(String(min)) || amount1e8 < BigInt(min))) return "amount below THORChain recommended minimum";
402
412
  return null;
403
413
  }
414
+ function nodesFailed(failures) {
415
+ const known = failures.filter((f) => f !== void 0);
416
+ if (!known.length || known.length !== failures.length) return { reason: "upstream_unavailable" };
417
+ const combined = (0, import_errors2.combinedRefusal)(known.map((f) => ({ message: "", ...f })));
418
+ return combined.reason === "no_route" ? { reason: "upstream_unavailable" } : combined;
419
+ }
420
+ function quoteRejection(text, status, bodyless) {
421
+ if (/emit asset 0 less than price limit/.test(text)) return { reason: "amount_too_small" };
422
+ if (/emit asset \d+ less than price limit/.test(text)) return { reason: "price_check_failed" };
423
+ if (/halt|paused/i.test(text)) return { reason: "chain_halted" };
424
+ if (/dust|too small|not enough [a-z ]*(?:to pay|for) (?:the )?fee/i.test(text)) return { reason: "amount_too_small" };
425
+ if (/insufficient liquidity|not enough liquidity/i.test(text)) return { reason: "insufficient_liquidity" };
426
+ if (/pool [^ ]* ?(?:does not exist|doesn't exist|not exist|not found|not available|is not available)|no pool/i.test(text)) return { reason: "unsupported_pair" };
427
+ if (/rate limit|too many requests/i.test(text)) return { reason: "rate_limited" };
428
+ if (/api key|unauthori[sz]ed/i.test(text)) return { reason: "key_rejected" };
429
+ const byStatus = status >= 400 ? (0, import_errors2.httpRefusal)(status) : void 0;
430
+ if (byStatus) return byStatus;
431
+ return bodyless ? { reason: "upstream_unavailable", httpStatus: status } : { reason: "no_route" };
432
+ }
404
433
  async function inboundAddressesOn(host, clientId) {
405
434
  const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
406
- if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`);
435
+ if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`, res.ok ? void 0 : statusRefusal(res.status));
407
436
  const entries = inboundAddresses(res.body);
408
437
  if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
409
438
  return entries;
410
439
  }
411
- function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
412
- if (memo.includes("|")) return null;
440
+ function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps, why) {
441
+ const refused = (reason) => {
442
+ why?.(reason);
443
+ return null;
444
+ };
445
+ if (memo.includes("|")) return refused("recipient_unverifiable");
413
446
  const parts = memo.split(":");
414
- if (parts.length !== 4 && parts.length !== 6) return null;
447
+ if (parts.length !== 4 && parts.length !== 6) return refused("policy_refused");
415
448
  const [op, asset, destination, limit, affiliate, bps] = parts;
416
- if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return null;
449
+ if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return refused("policy_refused");
417
450
  const expectedTarget = expectedRefund ? `${expectedDestination}/${expectedRefund}` : expectedDestination;
418
- if (!sameTarget(destination, expectedTarget)) return null;
451
+ if (!sameTarget(destination, expectedTarget)) return refused("recipient_mismatch");
419
452
  if (parts.length === 6) {
420
- if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return null;
453
+ if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return refused("policy_refused");
421
454
  } else if (expectedAffiliate || expectedAffiliateBps) {
422
- return null;
455
+ return refused("policy_refused");
423
456
  }
424
457
  const stream = (limit ?? "").split("/");
425
- if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return null;
458
+ if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return refused("price_check_failed");
426
459
  const floor = BigInt(stream[0] ?? "0");
427
- if (floor <= 0n) return null;
460
+ if (floor <= 0n) return refused("price_check_failed");
428
461
  const canonical = `=:${expectedAsset}:${expectedTarget}:${stream.join("/")}${expectedAffiliate ? `:${expectedAffiliate}:${expectedAffiliateBps}` : ""}`;
429
462
  return { limit: floor, canonical };
430
463
  }
@@ -437,7 +470,7 @@ function thorchainPins(opts = {}) {
437
470
  for (const n of nodes) if (!/^https:\/\//i.test(n)) throw new Error("thorchain: thorchainPins nodeUrl must use https");
438
471
  const clientId = opts.clientId?.trim() || DEFAULT_CLIENT_ID;
439
472
  const { maxLagBlocks, timeoutMs } = confirmOptions(opts);
440
- const askNode = async (host, claim) => {
473
+ const askNode = async (host, claim, failures) => {
441
474
  const node = nodeDeadline(timeoutMs);
442
475
  const read = (path) => nodeRead(host, path, clientId, node.signal);
443
476
  try {
@@ -445,59 +478,71 @@ function thorchainPins(opts = {}) {
445
478
  try {
446
479
  return { height: head.height, verdict: await vaultVerdict(host, read, head.inbound, claim) };
447
480
  } catch (error) {
448
- if (error instanceof HostUnavailable) return { height: head.height, verdict: null };
481
+ if (error instanceof HostUnavailable) {
482
+ failures.push((0, import_errors2.refusalOf)(error));
483
+ return { height: head.height, verdict: null };
484
+ }
449
485
  throw error;
450
486
  }
451
487
  } catch (error) {
452
- if (error instanceof HostUnavailable) return null;
488
+ if (error instanceof HostUnavailable) {
489
+ failures.push((0, import_errors2.refusalOf)(error));
490
+ return null;
491
+ }
453
492
  throw error;
454
493
  } finally {
455
494
  node.clear();
456
495
  }
457
496
  };
458
497
  const routersFor = (chain) => [...THORCHAIN_ROUTERS[chain] ?? [], ...opts.trustedRouters?.[chain] ?? []];
498
+ const { note, reasonOf } = (0, import_errors2.refusalNotes)();
459
499
  return Object.freeze({
460
500
  callTargets: routersFor,
461
501
  spenders: routersFor,
502
+ reasonOf,
462
503
  // A Tron source is a send to the inbound vault; verifyBuild confirms that vault with two THORNodes (scan 2026-10-28 F9).
463
504
  depositAddressRoutes: true,
464
505
  async verifyBuild(quote, tx, params) {
465
- if (!params) return "the request this quote answered is unknown";
506
+ if (!params) return note("the request this quote answered is unknown", "policy_refused");
466
507
  const src = (0, import_swap2_assets.ccAsset)(params.sourceChain, params.sourceToken);
467
508
  const dst = destAsset(params.destChain, params.destToken);
468
- if (!src || !dst) return "unknown source or destination asset";
509
+ if (!src || !dst) return note("unknown source or destination asset", "unsupported_pair");
469
510
  const chainId = CHAIN_ID[params.sourceChain];
470
511
  let vault, memo, router;
471
512
  if (chainId) {
472
- if (!tx.data) return "no router calldata";
513
+ if (!tx.data) return note("no router calldata", "policy_refused");
473
514
  let args;
474
515
  try {
475
516
  const d = (0, import_viem.decodeFunctionData)({ abi: ROUTER_ABI, data: tx.data });
476
- if (d.functionName !== "depositWithExpiry") return `${d.functionName} is not depositWithExpiry`;
517
+ if (d.functionName !== "depositWithExpiry") return note(`${d.functionName} is not depositWithExpiry`, "policy_refused");
477
518
  args = d.args;
478
519
  } catch {
479
- return "the router calldata does not decode";
520
+ return note("the router calldata does not decode", "policy_refused");
480
521
  }
481
- if (!args || args.length !== 5) return "the router calldata does not decode";
522
+ if (!args || args.length !== 5) return note("the router calldata does not decode", "policy_refused");
482
523
  router = tx.to;
483
- if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return `router ${tx.to} is not pinned`;
524
+ if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return note(`router ${tx.to} is not pinned`, "vault_mismatch");
484
525
  vault = String(args[0]);
485
526
  const asset = String(args[1]);
486
- if (!sameTarget(asset, src.contract ?? NATIVE)) return `the router call moves ${asset}, not the source asset`;
487
- if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return `the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input`;
527
+ if (!sameTarget(asset, src.contract ?? NATIVE)) return note(`the router call moves ${asset}, not the source asset`, "policy_refused");
528
+ if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return note(`the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input`, "policy_refused");
488
529
  memo = String(args[3]);
489
530
  } else {
490
- if (!tx.memo || tx.value !== quote.sourceAmount) return "the deposit is not a send of the input with a memo";
531
+ if (!tx.memo || tx.value !== quote.sourceAmount) return note("the deposit is not a send of the input with a memo", "policy_refused");
491
532
  vault = tx.to;
492
533
  memo = tx.memo;
493
534
  }
494
- const checked = verifiedMemo(memo, dst.asset, params.destAddress, params.refundAddress ?? "", opts.affiliate ?? "", opts.affiliateBps ?? "");
495
- if (!checked) return `the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`;
496
- if (memo !== checked.canonical) return `the memo is not the canonical ${checked.canonical}`;
497
- if (checked.limit * 10n ** BigInt(dst.decimals) / 100000000n < BigInt(quote.minDestAmount)) return `the memo's limit ${checked.limit} is below the quoted minimum`;
535
+ let memoRefusal = "recipient_mismatch";
536
+ const checked = verifiedMemo(memo, dst.asset, params.destAddress, params.refundAddress ?? "", opts.affiliate ?? "", opts.affiliateBps ?? "", (why) => {
537
+ memoRefusal = why;
538
+ });
539
+ if (!checked) return note(`the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`, memoRefusal);
540
+ if (memo !== checked.canonical) return note(`the memo is not the canonical ${checked.canonical}`, "policy_refused");
541
+ if (checked.limit * 10n ** BigInt(dst.decimals) / 100000000n < BigInt(quote.minDestAmount)) return note(`the memo's limit ${checked.limit} is below the quoted minimum`, "price_check_failed");
498
542
  const sourceChain = THOR_CHAIN[params.sourceChain] ?? params.sourceChain;
499
543
  const claim = { chain: sourceChain, destChain: THOR_CHAIN[params.destChain] ?? params.destChain, vault, ...router !== void 0 ? { router } : {} };
500
- const views = await Promise.all(nodes.map((host) => askNode(host, claim)));
544
+ const failures = [];
545
+ const views = await Promise.all(nodes.map((host) => askNode(host, claim, failures)));
501
546
  const top = views.reduce((max, v) => v && v.height > max ? v.height : max, -1);
502
547
  let agreeing = 0;
503
548
  let lagging = false;
@@ -511,20 +556,20 @@ function thorchainPins(opts = {}) {
511
556
  if (!v.ok) {
512
557
  switch (v.why) {
513
558
  case "halted":
514
- return `${v.chain} is halted / paused`;
559
+ return note(`${v.chain} is halted / paused`, "chain_halted");
515
560
  case "another-vault":
516
- return `a THORNode reports another inbound vault than ${vault}`;
561
+ return note(`a THORNode reports another inbound vault than ${vault}`, "vault_mismatch");
517
562
  case "not-active":
518
- return `a THORNode lists the inbound vault as ${v.status}, not ActiveVault`;
563
+ return note(`a THORNode lists the inbound vault as ${v.status}, not ActiveVault`, "vault_mismatch");
519
564
  case "flagged":
520
- return `a THORNode's mimir flags the inbound vault as ${v.flag}`;
565
+ return note(`a THORNode's mimir flags the inbound vault as ${v.flag}`, "vault_mismatch");
521
566
  case "another-router":
522
- return "a THORNode reports another router";
567
+ return note("a THORNode reports another router", "vault_mismatch");
523
568
  }
524
569
  }
525
570
  if (++agreeing >= 2) return null;
526
571
  }
527
- 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";
572
+ return lagging ? note(`two THORNodes within ${maxLagBlocks} blocks of the highest THORChain height they report could not confirm the inbound vault (behind or unreachable)`, "stale_node") : note("two THORNodes could not confirm the inbound vault", nodesFailed(failures));
528
573
  }
529
574
  });
530
575
  }
@@ -605,15 +650,15 @@ function thorchainProvider(config = {}) {
605
650
  const confirmText = (v) => {
606
651
  switch (v.why) {
607
652
  case "halted":
608
- return `${v.chain} is halted / paused on the confirming node`;
653
+ return { text: `${v.chain} is halted / paused on the confirming node`, info: { reason: "chain_halted" } };
609
654
  case "another-vault":
610
- return "a second node reports another inbound vault \u2014 refused until they agree";
655
+ return { text: "a second node reports another inbound vault \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
611
656
  case "not-active":
612
- return `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`;
657
+ return { text: `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`, info: { reason: "vault_mismatch" } };
613
658
  case "flagged":
614
- return `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`;
659
+ return { text: `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`, info: { reason: "vault_mismatch" } };
615
660
  case "another-router":
616
- return "a second node reports another router \u2014 refused until they agree";
661
+ return { text: "a second node reports another router \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
617
662
  }
618
663
  };
619
664
  const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
@@ -632,10 +677,14 @@ function thorchainProvider(config = {}) {
632
677
  };
633
678
  let lagging = false;
634
679
  let asked = 0;
680
+ const failures = [];
635
681
  for (const host of confirmHosts) {
636
682
  if (host === quoteHost) continue;
637
683
  const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
638
- if (budget < MIN_CONFIRM_TIMEOUT_MS) break;
684
+ if (budget < MIN_CONFIRM_TIMEOUT_MS) {
685
+ failures.push({ reason: "upstream_unavailable" });
686
+ break;
687
+ }
639
688
  asked++;
640
689
  const node = nodeDeadline(budget, outer);
641
690
  try {
@@ -647,19 +696,22 @@ function thorchainProvider(config = {}) {
647
696
  return verdict;
648
697
  } catch (error) {
649
698
  if (outer?.aborted) throw error;
650
- if (error instanceof HostUnavailable) continue;
699
+ if (error instanceof HostUnavailable) {
700
+ failures.push((0, import_errors2.refusalOf)(error));
701
+ continue;
702
+ }
651
703
  throw error;
652
704
  } finally {
653
705
  node.clear();
654
706
  }
655
707
  }
656
708
  if (lagging) {
657
- return `no confirming node is within ${confirmMaxLagBlocks} blocks of the quoting node's THORChain height (behind or unreachable) \u2014 refused until one catches up`;
709
+ return { text: `no confirming node is within ${confirmMaxLagBlocks} blocks of the quoting node's THORChain height (behind or unreachable) \u2014 refused until one catches up`, info: { reason: "stale_node" } };
658
710
  }
659
711
  if (asked === 0) {
660
- 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`;
712
+ return { text: `the ${CONFIRM_BUDGET_MS / 1e3} s confirmation budget ran out before a second node could be asked to confirm the inbound vault \u2014 retry`, info: { reason: "upstream_unavailable" } };
661
713
  }
662
- return "no second node answered to confirm the inbound vault (configure confirmNodeUrl)";
714
+ return { text: "no second node answered to confirm the inbound vault (configure confirmNodeUrl)", info: nodesFailed(failures) };
663
715
  };
664
716
  const configuredAffiliate = config.affiliate ?? "";
665
717
  const configuredAffiliateBps = config.affiliateBps ?? "";
@@ -705,24 +757,27 @@ function thorchainProvider(config = {}) {
705
757
  const fromAsset = thorAsset(params.sourceChain, params.sourceToken);
706
758
  const dst = destAsset(params.destChain, params.destToken);
707
759
  if (!src || !fromAsset || !dst || fromAsset === dst.asset) {
708
- params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`);
760
+ params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`, { reason: "unsupported_pair" });
709
761
  return null;
710
762
  }
711
763
  const amountIn = BigInt(params.amountIn);
712
764
  const amount1e8 = amountIn * 100000000n / 10n ** BigInt(src.decimals);
713
765
  if (amount1e8 <= 0n) return null;
714
766
  if (amount1e8 * 10n ** BigInt(src.decimals) !== amountIn * 100000000n) {
715
- params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`);
767
+ params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`, { reason: "policy_refused" });
716
768
  return null;
717
769
  }
718
770
  const requestedTolerance = params.slippageBps ?? Number(TOLERANCE_BPS);
719
771
  if (!Number.isInteger(requestedTolerance) || requestedTolerance < 0 || requestedTolerance > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
720
- params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`);
772
+ params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`, { reason: "policy_refused" });
721
773
  return null;
722
774
  }
723
775
  const explicitSlippage = params.slippageBps !== void 0;
724
776
  const liquidityBps = explicitSlippage ? dst.decimals > 8 ? requestedTolerance - 1 : requestedTolerance : 0;
725
777
  const limitMode = liquidityBps > 0 ? "liquidity_tolerance_bps" : "tolerance_bps";
778
+ let quoteStatus = 0;
779
+ let quoteBodyless = false;
780
+ let quoteBodyAborted = false;
726
781
  const quoteOn = async (host, bps, limitMode2 = "tolerance_bps") => {
727
782
  const qs = new URLSearchParams({
728
783
  from_asset: fromAsset,
@@ -743,6 +798,9 @@ function thorchainProvider(config = {}) {
743
798
  }
744
799
  if (params.refundAddress) qs.set("refund_address", params.refundAddress);
745
800
  const res = await hostGet(host, `/thorchain/quote/swap?${qs.toString()}`, clientId, signal);
801
+ quoteStatus = res.status;
802
+ quoteBodyless = res.body === null && !res.ok;
803
+ quoteBodyAborted = res.body === null && signal?.aborted === true;
746
804
  if (Array.isArray(res.body)) return null;
747
805
  return res.body ?? (res.ok ? null : { error: `HTTP ${res.status}` });
748
806
  };
@@ -753,13 +811,14 @@ function thorchainProvider(config = {}) {
753
811
  let quoteHeight = 0;
754
812
  let confirmDeadlineAt = 0;
755
813
  let heightUnavailable = false;
814
+ const hostFailures = [];
756
815
  for (const host of hosts) {
757
816
  try {
758
817
  if (limitMode === "liquidity_tolerance_bps") {
759
818
  q = await quoteOn(host, String(liquidityBps), "liquidity_tolerance_bps");
760
819
  const feeBps = Number(q?.fees?.total_bps);
761
820
  if (Number.isFinite(feeBps) && feeBps > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
762
- params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${(0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS) / 100}%)`);
821
+ params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${(0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS) / 100}%)`, { reason: "policy_refused" });
763
822
  return null;
764
823
  }
765
824
  } else {
@@ -776,25 +835,28 @@ function thorchainProvider(config = {}) {
776
835
  }
777
836
  }
778
837
  if (q?.error || q?.message) {
779
- params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}`);
838
+ params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}`, quoteBodyAborted ? (0, import_errors2.httpRefusal)(quoteStatus) : quoteRejection(String(q.error ?? q.message), quoteStatus, quoteBodyless));
780
839
  return null;
781
840
  }
782
841
  if (!q || !q.expected_amount_out || !q.inbound_address || !q.memo || !q.expiry) {
783
- params.onRefusal?.(`thorchain: incomplete quote from ${host} (expected_amount_out / inbound_address / memo / expiry missing)`);
842
+ params.onRefusal?.(`thorchain: incomplete quote from ${host} (expected_amount_out / inbound_address / memo / expiry missing)`, quoteBodyAborted ? void 0 : { reason: "upstream_unavailable", ...quoteStatus >= 300 ? { httpStatus: quoteStatus } : {} });
784
843
  return null;
785
844
  }
786
845
  if (chainId ? !q.router : !!q.router) {
787
- params.onRefusal?.(chainId ? "thorchain: quote has no router for an EVM source" : "thorchain: quote names a router for a deposit source \u2014 execution model changed");
846
+ params.onRefusal?.(chainId ? "thorchain: quote has no router for an EVM source" : "thorchain: quote names a router for a deposit source \u2014 execution model changed", { reason: chainId ? "upstream_unavailable" : "policy_refused" });
788
847
  return null;
789
848
  }
790
- checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps);
849
+ let memoRefusal = "recipient_mismatch";
850
+ checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps, (why) => {
851
+ memoRefusal = why;
852
+ });
791
853
  if (!checkedMemo) {
792
- params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`);
854
+ params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`, { reason: memoRefusal });
793
855
  return null;
794
856
  }
795
857
  const floor1e8 = params.minAmountOut !== void 0 ? toThorUnitsCeil(BigInt(params.minAmountOut), dst.decimals) : void 0;
796
858
  if (floor1e8 !== void 0 && checkedMemo.limit < floor1e8) {
797
- params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`);
859
+ params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`, { reason: "price_check_failed" });
798
860
  return null;
799
861
  }
800
862
  const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
@@ -810,6 +872,7 @@ function thorchainProvider(config = {}) {
810
872
  break;
811
873
  } catch (error) {
812
874
  if (error instanceof HostUnavailable) {
875
+ hostFailures.push((0, import_errors2.refusalOf)(error));
813
876
  q = null;
814
877
  checkedMemo = null;
815
878
  inbounds = null;
@@ -820,7 +883,8 @@ function thorchainProvider(config = {}) {
820
883
  }
821
884
  if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
822
885
  params.onRefusal?.(
823
- 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"
886
+ inbounds ? "thorchain: no quote from any node" : heightUnavailable ? "thorchain: THORChain height (/thorchain/lastblock) unavailable from every node that answered the inbound addresses" : "thorchain: inbound addresses unavailable from every node",
887
+ inbounds ? { reason: "no_route" } : nodesFailed(hostFailures)
824
888
  );
825
889
  return null;
826
890
  }
@@ -828,19 +892,19 @@ function thorchainProvider(config = {}) {
828
892
  const srcInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.sourceChain]);
829
893
  const dstInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.destChain]);
830
894
  if (paused(srcInbound) || paused(dstInbound)) {
831
- params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`);
895
+ params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`, { reason: "chain_halted" });
832
896
  return null;
833
897
  }
834
898
  if (!sameTarget(srcInbound?.address, q.inbound_address)) {
835
- params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry");
899
+ params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry", { reason: "vault_mismatch" });
836
900
  return null;
837
901
  }
838
902
  if (chainId && !sameTarget(srcInbound?.router, q.router)) {
839
- params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry");
903
+ params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry", { reason: "vault_mismatch" });
840
904
  return null;
841
905
  }
842
906
  if (chainId && !pinnedRouter(params.sourceChain, q.router)) {
843
- params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`);
907
+ params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`, { reason: "vault_mismatch" });
844
908
  return null;
845
909
  }
846
910
  const unconfirmed = await confirmedElsewhere(
@@ -854,36 +918,36 @@ function thorchainProvider(config = {}) {
854
918
  signal
855
919
  );
856
920
  if (unconfirmed) {
857
- params.onRefusal?.(`thorchain: ${unconfirmed}`);
921
+ params.onRefusal?.(`thorchain: ${unconfirmed.text}`, unconfirmed.info);
858
922
  return null;
859
923
  }
860
924
  const tooSmall = belowThorMinimums(amount1e8, srcInbound, q);
861
925
  if (tooSmall) {
862
- params.onRefusal?.(`thorchain: ${tooSmall}`);
926
+ params.onRefusal?.(`thorchain: ${tooSmall}`, { reason: "amount_too_small" });
863
927
  return null;
864
928
  }
865
929
  const outUnits = BigInt(q.expected_amount_out) * 10n ** BigInt(dst.decimals) / 100000000n;
866
930
  if (outUnits <= 0n) {
867
- params.onRefusal?.("thorchain: expected output rounds to zero");
931
+ params.onRefusal?.("thorchain: expected output rounds to zero", { reason: "amount_too_small" });
868
932
  return null;
869
933
  }
870
934
  const memoMinUnits = limit1e8 * 10n ** BigInt(dst.decimals) / 100000000n;
871
935
  const minUnits = memoMinUnits;
872
936
  if (minUnits <= 0n || minUnits > outUnits) {
873
- params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`);
937
+ params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`, { reason: "price_check_failed" });
874
938
  return null;
875
939
  }
876
940
  if (params.slippageBps !== void 0) {
877
941
  const slippageFloor = outUnits * BigInt(1e4 - params.slippageBps) / 10000n;
878
942
  if (minUnits < slippageFloor) {
879
- params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`);
943
+ params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`, { reason: "price_check_failed" });
880
944
  return null;
881
945
  }
882
946
  }
883
947
  const nowSeconds = Math.floor(Date.now() / 1e3);
884
948
  const executionExpiry = Math.min(q.expiry, nowSeconds + 15 * 60);
885
949
  if (!Number.isInteger(executionExpiry) || executionExpiry <= nowSeconds + 30) {
886
- params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`);
950
+ params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`, { reason: "quote_expired" });
887
951
  return null;
888
952
  }
889
953
  const assetAddr = src.contract ? src.contract : NATIVE;
@@ -939,7 +1003,7 @@ function thorchainProvider(config = {}) {
939
1003
  if (!quote.txRequest) {
940
1004
  const sourceChain = quote.providerData?.sourceChain ?? "";
941
1005
  if (!isDepositSource(sourceChain) || !quote.depositAddress || !quote.depositMemo) {
942
- throw new Error("thorchain: deposit-address execution is only supported for an opted-in Tron source");
1006
+ throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: deposit-address execution is only supported for an opted-in Tron source");
943
1007
  }
944
1008
  return { chain: sourceChain, to: quote.depositAddress, value: quote.sourceAmount, memo: quote.depositMemo };
945
1009
  }
@@ -956,26 +1020,28 @@ function thorchainProvider(config = {}) {
956
1020
  async preflightDeposit(quote, tx) {
957
1021
  const data = quote.providerData;
958
1022
  if (!data?.sourceChain || !data.destChain || !data.inboundAddress || !data.assetAddress || !data.expiry || !data.memo) {
959
- throw new Error("thorchain: quote is missing execution-bound metadata");
1023
+ throw (0, import_errors2.refusal)("policy_refused", "thorchain: quote is missing execution-bound metadata");
960
1024
  }
961
1025
  const evmSource = CHAIN_ID[data.sourceChain] !== void 0;
962
- if (evmSource && !data.router) throw new Error("thorchain: quote is missing execution-bound metadata");
963
- if (!evmSource && !isDepositSource(data.sourceChain)) throw new Error("thorchain: source chain has no executor");
1026
+ if (evmSource && !data.router) throw (0, import_errors2.refusal)("policy_refused", "thorchain: quote is missing execution-bound metadata");
1027
+ if (!evmSource && !isDepositSource(data.sourceChain)) throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: source chain has no executor");
964
1028
  if (!/^\d+$/.test(data.expiry) || BigInt(data.expiry) <= BigInt(Math.floor(Date.now() / 1e3))) {
965
- throw new Error("thorchain: quote expired before broadcast");
1029
+ throw (0, import_errors2.refusal)("quote_expired", "thorchain: quote expired before broadcast");
966
1030
  }
967
- if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw new Error("thorchain: the quote's router is not one this module trusts");
1031
+ if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: the quote's router is not one this module trusts");
968
1032
  let inbounds = null;
969
1033
  let snapshotHost = "";
970
1034
  let snapshotHeight = 0;
971
1035
  let confirmDeadlineAt = 0;
972
1036
  let heightUnavailable = false;
1037
+ const hostFailures = [];
973
1038
  for (const host of hosts) {
974
1039
  const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
975
1040
  try {
976
1041
  const snapshot = await nodeSnapshot(host);
977
1042
  if ("heightError" in snapshot) {
978
1043
  heightUnavailable = true;
1044
+ hostFailures.push((0, import_errors2.refusalOf)(snapshot.heightError));
979
1045
  continue;
980
1046
  }
981
1047
  inbounds = snapshot.inbounds;
@@ -986,12 +1052,13 @@ function thorchainProvider(config = {}) {
986
1052
  } catch (error) {
987
1053
  inbounds = null;
988
1054
  if (!(error instanceof HostUnavailable)) throw error;
1055
+ hostFailures.push((0, import_errors2.refusalOf)(error));
989
1056
  }
990
1057
  }
991
1058
  if (!inbounds) {
992
- throw new Error(
1059
+ throw (0, import_errors2.withReason)(new Error(
993
1060
  heightUnavailable ? "thorchain: cannot read THORChain height (/thorchain/lastblock) before broadcast from any node that answered the inbound addresses" : "thorchain: cannot refresh inbound addresses before broadcast"
994
- );
1061
+ ), nodesFailed(hostFailures));
995
1062
  }
996
1063
  const confirm = async () => {
997
1064
  const unconfirmed = await confirmedElsewhere(
@@ -1003,44 +1070,46 @@ function thorchainProvider(config = {}) {
1003
1070
  evmSource ? data.router : void 0,
1004
1071
  confirmDeadlineAt
1005
1072
  );
1006
- if (unconfirmed) throw new Error(`thorchain: ${unconfirmed}`);
1073
+ if (unconfirmed) throw (0, import_errors2.withReason)(new Error(`thorchain: ${unconfirmed.text}`), unconfirmed.info);
1007
1074
  };
1008
1075
  const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);
1009
1076
  const dst = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.destChain]);
1010
- if (paused(src) || paused(dst)) throw new Error("thorchain: source or destination chain trading is paused");
1011
- if (!sameTarget(src?.address, data.inboundAddress)) throw new Error("thorchain: inbound vault rotated after quote");
1077
+ if (paused(src) || paused(dst)) throw (0, import_errors2.refusal)("chain_halted", "thorchain: source or destination chain trading is paused");
1078
+ if (!sameTarget(src?.address, data.inboundAddress)) throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: inbound vault rotated after quote");
1012
1079
  if (!evmSource) {
1013
1080
  if (tx.data !== void 0 || tx.approvalAddress !== void 0 || tx.bodyBoc !== void 0 || tx.typedData !== void 0 || tx.serializedTx !== void 0 || tx.stateInitBoc !== void 0) {
1014
- throw new Error("thorchain: deposit-address execution must be a plain send with memo");
1081
+ throw (0, import_errors2.refusal)("policy_refused", "thorchain: deposit-address execution must be a plain send with memo");
1015
1082
  }
1016
1083
  if (!sameTarget(tx.to, data.inboundAddress) || tx.memo !== data.memo || tx.value !== quote.sourceAmount || tx.chain && tx.chain !== data.sourceChain) {
1017
- throw new Error("thorchain: deposit does not match the validated quote");
1084
+ throw (0, import_errors2.refusal)(!sameTarget(tx.to, data.inboundAddress) ? "vault_mismatch" : "policy_refused", "thorchain: deposit does not match the validated quote");
1018
1085
  }
1019
1086
  const srcAsset = (0, import_swap2_assets.ccAssetByToken)(data.sourceChain, sameTarget(data.assetAddress, NATIVE) ? "" : data.assetAddress);
1020
- if (!srcAsset) throw new Error("thorchain: unknown source asset");
1087
+ if (!srcAsset) throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: unknown source asset");
1021
1088
  const amount1e8 = BigInt(quote.sourceAmount) * 100000000n / 10n ** BigInt(srcAsset.decimals);
1022
1089
  const tooSmall = belowThorMinimums(amount1e8, src, null);
1023
- if (tooSmall) throw new Error(`thorchain: ${tooSmall}`);
1090
+ if (tooSmall) throw (0, import_errors2.refusal)("amount_too_small", `thorchain: ${tooSmall}`);
1024
1091
  await confirm();
1025
1092
  return;
1026
1093
  }
1027
1094
  if (!sameTarget(src?.router, data.router) || !sameTarget(tx.to, data.router)) {
1028
- throw new Error("thorchain: router rotated after quote");
1095
+ throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: router rotated after quote");
1029
1096
  }
1030
- if (!tx.data) throw new Error("thorchain: missing depositWithExpiry calldata");
1097
+ if (!tx.data) throw (0, import_errors2.refusal)("policy_refused", "thorchain: missing depositWithExpiry calldata");
1031
1098
  let decoded;
1032
1099
  try {
1033
1100
  decoded = (0, import_viem.decodeFunctionData)({ abi: ROUTER_ABI, data: tx.data });
1034
1101
  } catch {
1035
- throw new Error("thorchain: malformed depositWithExpiry calldata");
1102
+ throw (0, import_errors2.refusal)("policy_refused", "thorchain: malformed depositWithExpiry calldata");
1036
1103
  }
1037
1104
  const args = decoded.args;
1038
1105
  if (decoded.functionName !== "depositWithExpiry" || !args || args.length !== 5 || !sameTarget(String(args[0]), data.inboundAddress) || !sameTarget(String(args[1]), data.assetAddress) || BigInt(args[2]) !== BigInt(quote.sourceAmount) || String(args[3]) !== data.memo || BigInt(args[4]) !== BigInt(data.expiry) || tx.chainId !== CHAIN_ID[data.sourceChain]) {
1039
- throw new Error("thorchain: calldata does not match the validated quote");
1106
+ const vaultChanged = decoded.functionName === "depositWithExpiry" && !!args && args.length === 5 && !sameTarget(String(args[0]), data.inboundAddress);
1107
+ throw (0, import_errors2.refusal)(vaultChanged ? "vault_mismatch" : "policy_refused", "thorchain: calldata does not match the validated quote");
1040
1108
  }
1041
1109
  const tokenSource = !sameTarget(data.assetAddress, NATIVE);
1042
1110
  if (tx.value !== (tokenSource ? "0" : quote.sourceAmount) || (tokenSource ? !sameTarget(tx.approvalAddress, data.router) : tx.approvalAddress !== void 0)) {
1043
- throw new Error("thorchain: value/approval does not match the validated source asset");
1111
+ const spenderChanged = tokenSource && tx.approvalAddress !== void 0 && !sameTarget(tx.approvalAddress, data.router);
1112
+ throw (0, import_errors2.refusal)(spenderChanged ? "vault_mismatch" : "policy_refused", "thorchain: value/approval does not match the validated source asset");
1044
1113
  }
1045
1114
  await confirm();
1046
1115
  },