@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/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  ThorchainSwap2Protocol
3
- } from "./chunk-LP5BU2JS.js";
3
+ } from "./chunk-YKA6IYKX.js";
4
4
  import {
5
5
  THORCHAIN_ROUTERS,
6
6
  THORNODE_CONFIRM_PATHS,
7
7
  thorchainPins,
8
8
  thorchainProvider
9
- } from "./chunk-AYP7O2NT.js";
9
+ } from "./chunk-EIAXGWNG.js";
10
10
  import {
11
11
  THORCHAIN_DEPOSIT_WITH_EXPIRY,
12
12
  THORCHAIN_MODULE,
package/dist/protocol.cjs CHANGED
@@ -67,10 +67,12 @@ 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");
72
73
 
73
74
  // src/confirm.ts
75
+ var import_errors = require("@anywalletkit/protocol-swap2-core/errors");
74
76
  var THORNODE_CONFIRM_PATHS = Object.freeze({
75
77
  lastblock: "/thorchain/lastblock",
76
78
  inboundAddresses: "/thorchain/inbound_addresses",
@@ -93,10 +95,18 @@ function confirmOptions(o) {
93
95
  return { maxLagBlocks, timeoutMs };
94
96
  }
95
97
  var HostUnavailable = class extends Error {
96
- constructor(host, reason) {
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) {
97
103
  super(`thorchain: ${host} unavailable (${reason})`);
104
+ (0, import_errors.withReason)(this, info ?? "upstream_unavailable");
98
105
  }
99
106
  };
107
+ function statusRefusal(status, retryAfter) {
108
+ return (0, import_errors.httpRefusal)(status, retryAfter) ?? { reason: "upstream_unavailable", httpStatus: status };
109
+ }
100
110
  function paused(entry) {
101
111
  return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
102
112
  }
@@ -142,7 +152,7 @@ async function nodeRead(host, path, clientId, signal, outer) {
142
152
  throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
143
153
  }
144
154
  if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
145
- if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`);
155
+ if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
146
156
  try {
147
157
  return await res.json();
148
158
  } catch (error) {
@@ -374,7 +384,7 @@ async function hostGet(host, path, clientId, signal) {
374
384
  throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
375
385
  }
376
386
  if (res.status >= 500 || res.status === 429 || res.status === 403) {
377
- throw new HostUnavailable(host, `HTTP ${res.status}`);
387
+ throw new HostUnavailable(host, `HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
378
388
  }
379
389
  const body = await res.json().catch(() => null);
380
390
  return { ok: res.ok, status: res.status, body };
@@ -399,30 +409,53 @@ function belowThorMinimums(amount1e8, inbound, quote) {
399
409
  if (min !== void 0 && (!/^\d+$/.test(String(min)) || amount1e8 < BigInt(min))) return "amount below THORChain recommended minimum";
400
410
  return null;
401
411
  }
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;
417
+ }
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" };
430
+ }
402
431
  async function inboundAddressesOn(host, clientId) {
403
432
  const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
404
- if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`);
433
+ if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`, res.ok ? void 0 : statusRefusal(res.status));
405
434
  const entries = inboundAddresses(res.body);
406
435
  if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
407
436
  return entries;
408
437
  }
409
- function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
410
- 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");
411
444
  const parts = memo.split(":");
412
- if (parts.length !== 4 && parts.length !== 6) return null;
445
+ if (parts.length !== 4 && parts.length !== 6) return refused("policy_refused");
413
446
  const [op, asset, destination, limit, affiliate, bps] = parts;
414
- 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");
415
448
  const expectedTarget = expectedRefund ? `${expectedDestination}/${expectedRefund}` : expectedDestination;
416
- if (!sameTarget(destination, expectedTarget)) return null;
449
+ if (!sameTarget(destination, expectedTarget)) return refused("recipient_mismatch");
417
450
  if (parts.length === 6) {
418
- if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return null;
451
+ if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return refused("policy_refused");
419
452
  } else if (expectedAffiliate || expectedAffiliateBps) {
420
- return null;
453
+ return refused("policy_refused");
421
454
  }
422
455
  const stream = (limit ?? "").split("/");
423
- 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");
424
457
  const floor = BigInt(stream[0] ?? "0");
425
- if (floor <= 0n) return null;
458
+ if (floor <= 0n) return refused("price_check_failed");
426
459
  const canonical = `=:${expectedAsset}:${expectedTarget}:${stream.join("/")}${expectedAffiliate ? `:${expectedAffiliate}:${expectedAffiliateBps}` : ""}`;
427
460
  return { limit: floor, canonical };
428
461
  }
@@ -507,15 +540,15 @@ function thorchainProvider(config = {}) {
507
540
  const confirmText = (v) => {
508
541
  switch (v.why) {
509
542
  case "halted":
510
- return `${v.chain} is halted / paused on the confirming node`;
543
+ return { text: `${v.chain} is halted / paused on the confirming node`, info: { reason: "chain_halted" } };
511
544
  case "another-vault":
512
- return "a second node reports another inbound vault \u2014 refused until they agree";
545
+ return { text: "a second node reports another inbound vault \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
513
546
  case "not-active":
514
- return `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`;
547
+ return { text: `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`, info: { reason: "vault_mismatch" } };
515
548
  case "flagged":
516
- return `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`;
549
+ return { text: `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`, info: { reason: "vault_mismatch" } };
517
550
  case "another-router":
518
- return "a second node reports another router \u2014 refused until they agree";
551
+ return { text: "a second node reports another router \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
519
552
  }
520
553
  };
521
554
  const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
@@ -534,10 +567,14 @@ function thorchainProvider(config = {}) {
534
567
  };
535
568
  let lagging = false;
536
569
  let asked = 0;
570
+ const failures = [];
537
571
  for (const host of confirmHosts) {
538
572
  if (host === quoteHost) continue;
539
573
  const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
540
- if (budget < MIN_CONFIRM_TIMEOUT_MS) break;
574
+ if (budget < MIN_CONFIRM_TIMEOUT_MS) {
575
+ failures.push({ reason: "upstream_unavailable" });
576
+ break;
577
+ }
541
578
  asked++;
542
579
  const node = nodeDeadline(budget, outer);
543
580
  try {
@@ -549,19 +586,22 @@ function thorchainProvider(config = {}) {
549
586
  return verdict;
550
587
  } catch (error) {
551
588
  if (outer?.aborted) throw error;
552
- if (error instanceof HostUnavailable) continue;
589
+ if (error instanceof HostUnavailable) {
590
+ failures.push((0, import_errors2.refusalOf)(error));
591
+ continue;
592
+ }
553
593
  throw error;
554
594
  } finally {
555
595
  node.clear();
556
596
  }
557
597
  }
558
598
  if (lagging) {
559
- return `no confirming node is within ${confirmMaxLagBlocks} blocks of the quoting node's THORChain height (behind or unreachable) \u2014 refused until one catches up`;
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" } };
560
600
  }
561
601
  if (asked === 0) {
562
- 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`;
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" } };
563
603
  }
564
- return "no second node answered to confirm the inbound vault (configure confirmNodeUrl)";
604
+ return { text: "no second node answered to confirm the inbound vault (configure confirmNodeUrl)", info: nodesFailed(failures) };
565
605
  };
566
606
  const configuredAffiliate = config.affiliate ?? "";
567
607
  const configuredAffiliateBps = config.affiliateBps ?? "";
@@ -607,24 +647,27 @@ function thorchainProvider(config = {}) {
607
647
  const fromAsset = thorAsset(params.sourceChain, params.sourceToken);
608
648
  const dst = destAsset(params.destChain, params.destToken);
609
649
  if (!src || !fromAsset || !dst || fromAsset === dst.asset) {
610
- 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" });
611
651
  return null;
612
652
  }
613
653
  const amountIn = BigInt(params.amountIn);
614
654
  const amount1e8 = amountIn * 100000000n / 10n ** BigInt(src.decimals);
615
655
  if (amount1e8 <= 0n) return null;
616
656
  if (amount1e8 * 10n ** BigInt(src.decimals) !== amountIn * 100000000n) {
617
- 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" });
618
658
  return null;
619
659
  }
620
660
  const requestedTolerance = params.slippageBps ?? Number(TOLERANCE_BPS);
621
661
  if (!Number.isInteger(requestedTolerance) || requestedTolerance < 0 || requestedTolerance > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
622
- 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" });
623
663
  return null;
624
664
  }
625
665
  const explicitSlippage = params.slippageBps !== void 0;
626
666
  const liquidityBps = explicitSlippage ? dst.decimals > 8 ? requestedTolerance - 1 : requestedTolerance : 0;
627
667
  const limitMode = liquidityBps > 0 ? "liquidity_tolerance_bps" : "tolerance_bps";
668
+ let quoteStatus = 0;
669
+ let quoteBodyless = false;
670
+ let quoteBodyAborted = false;
628
671
  const quoteOn = async (host, bps, limitMode2 = "tolerance_bps") => {
629
672
  const qs = new URLSearchParams({
630
673
  from_asset: fromAsset,
@@ -645,6 +688,9 @@ function thorchainProvider(config = {}) {
645
688
  }
646
689
  if (params.refundAddress) qs.set("refund_address", params.refundAddress);
647
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;
648
694
  if (Array.isArray(res.body)) return null;
649
695
  return res.body ?? (res.ok ? null : { error: `HTTP ${res.status}` });
650
696
  };
@@ -655,13 +701,14 @@ function thorchainProvider(config = {}) {
655
701
  let quoteHeight = 0;
656
702
  let confirmDeadlineAt = 0;
657
703
  let heightUnavailable = false;
704
+ const hostFailures = [];
658
705
  for (const host of hosts) {
659
706
  try {
660
707
  if (limitMode === "liquidity_tolerance_bps") {
661
708
  q = await quoteOn(host, String(liquidityBps), "liquidity_tolerance_bps");
662
709
  const feeBps = Number(q?.fees?.total_bps);
663
710
  if (Number.isFinite(feeBps) && feeBps > (0, import_core.bigintToNumber)(MAX_TOLERANCE_BPS)) {
664
- 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" });
665
712
  return null;
666
713
  }
667
714
  } else {
@@ -678,25 +725,28 @@ function thorchainProvider(config = {}) {
678
725
  }
679
726
  }
680
727
  if (q?.error || q?.message) {
681
- 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));
682
729
  return null;
683
730
  }
684
731
  if (!q || !q.expected_amount_out || !q.inbound_address || !q.memo || !q.expiry) {
685
- 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 } : {} });
686
733
  return null;
687
734
  }
688
735
  if (chainId ? !q.router : !!q.router) {
689
- 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" });
690
737
  return null;
691
738
  }
692
- 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
+ });
693
743
  if (!checkedMemo) {
694
- 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 });
695
745
  return null;
696
746
  }
697
747
  const floor1e8 = params.minAmountOut !== void 0 ? toThorUnitsCeil(BigInt(params.minAmountOut), dst.decimals) : void 0;
698
748
  if (floor1e8 !== void 0 && checkedMemo.limit < floor1e8) {
699
- 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" });
700
750
  return null;
701
751
  }
702
752
  const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
@@ -712,6 +762,7 @@ function thorchainProvider(config = {}) {
712
762
  break;
713
763
  } catch (error) {
714
764
  if (error instanceof HostUnavailable) {
765
+ hostFailures.push((0, import_errors2.refusalOf)(error));
715
766
  q = null;
716
767
  checkedMemo = null;
717
768
  inbounds = null;
@@ -722,7 +773,8 @@ function thorchainProvider(config = {}) {
722
773
  }
723
774
  if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
724
775
  params.onRefusal?.(
725
- 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"
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)
726
778
  );
727
779
  return null;
728
780
  }
@@ -730,19 +782,19 @@ function thorchainProvider(config = {}) {
730
782
  const srcInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.sourceChain]);
731
783
  const dstInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.destChain]);
732
784
  if (paused(srcInbound) || paused(dstInbound)) {
733
- 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" });
734
786
  return null;
735
787
  }
736
788
  if (!sameTarget(srcInbound?.address, q.inbound_address)) {
737
- 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" });
738
790
  return null;
739
791
  }
740
792
  if (chainId && !sameTarget(srcInbound?.router, q.router)) {
741
- 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" });
742
794
  return null;
743
795
  }
744
796
  if (chainId && !pinnedRouter(params.sourceChain, q.router)) {
745
- 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" });
746
798
  return null;
747
799
  }
748
800
  const unconfirmed = await confirmedElsewhere(
@@ -756,36 +808,36 @@ function thorchainProvider(config = {}) {
756
808
  signal
757
809
  );
758
810
  if (unconfirmed) {
759
- params.onRefusal?.(`thorchain: ${unconfirmed}`);
811
+ params.onRefusal?.(`thorchain: ${unconfirmed.text}`, unconfirmed.info);
760
812
  return null;
761
813
  }
762
814
  const tooSmall = belowThorMinimums(amount1e8, srcInbound, q);
763
815
  if (tooSmall) {
764
- params.onRefusal?.(`thorchain: ${tooSmall}`);
816
+ params.onRefusal?.(`thorchain: ${tooSmall}`, { reason: "amount_too_small" });
765
817
  return null;
766
818
  }
767
819
  const outUnits = BigInt(q.expected_amount_out) * 10n ** BigInt(dst.decimals) / 100000000n;
768
820
  if (outUnits <= 0n) {
769
- params.onRefusal?.("thorchain: expected output rounds to zero");
821
+ params.onRefusal?.("thorchain: expected output rounds to zero", { reason: "amount_too_small" });
770
822
  return null;
771
823
  }
772
824
  const memoMinUnits = limit1e8 * 10n ** BigInt(dst.decimals) / 100000000n;
773
825
  const minUnits = memoMinUnits;
774
826
  if (minUnits <= 0n || minUnits > outUnits) {
775
- 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" });
776
828
  return null;
777
829
  }
778
830
  if (params.slippageBps !== void 0) {
779
831
  const slippageFloor = outUnits * BigInt(1e4 - params.slippageBps) / 10000n;
780
832
  if (minUnits < slippageFloor) {
781
- 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" });
782
834
  return null;
783
835
  }
784
836
  }
785
837
  const nowSeconds = Math.floor(Date.now() / 1e3);
786
838
  const executionExpiry = Math.min(q.expiry, nowSeconds + 15 * 60);
787
839
  if (!Number.isInteger(executionExpiry) || executionExpiry <= nowSeconds + 30) {
788
- 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" });
789
841
  return null;
790
842
  }
791
843
  const assetAddr = src.contract ? src.contract : NATIVE;
@@ -841,7 +893,7 @@ function thorchainProvider(config = {}) {
841
893
  if (!quote.txRequest) {
842
894
  const sourceChain = quote.providerData?.sourceChain ?? "";
843
895
  if (!isDepositSource(sourceChain) || !quote.depositAddress || !quote.depositMemo) {
844
- 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");
845
897
  }
846
898
  return { chain: sourceChain, to: quote.depositAddress, value: quote.sourceAmount, memo: quote.depositMemo };
847
899
  }
@@ -858,26 +910,28 @@ function thorchainProvider(config = {}) {
858
910
  async preflightDeposit(quote, tx) {
859
911
  const data = quote.providerData;
860
912
  if (!data?.sourceChain || !data.destChain || !data.inboundAddress || !data.assetAddress || !data.expiry || !data.memo) {
861
- 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");
862
914
  }
863
915
  const evmSource = CHAIN_ID[data.sourceChain] !== void 0;
864
- if (evmSource && !data.router) throw new Error("thorchain: quote is missing execution-bound metadata");
865
- 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");
866
918
  if (!/^\d+$/.test(data.expiry) || BigInt(data.expiry) <= BigInt(Math.floor(Date.now() / 1e3))) {
867
- throw new Error("thorchain: quote expired before broadcast");
919
+ throw (0, import_errors2.refusal)("quote_expired", "thorchain: quote expired before broadcast");
868
920
  }
869
- 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");
870
922
  let inbounds = null;
871
923
  let snapshotHost = "";
872
924
  let snapshotHeight = 0;
873
925
  let confirmDeadlineAt = 0;
874
926
  let heightUnavailable = false;
927
+ const hostFailures = [];
875
928
  for (const host of hosts) {
876
929
  const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
877
930
  try {
878
931
  const snapshot = await nodeSnapshot(host);
879
932
  if ("heightError" in snapshot) {
880
933
  heightUnavailable = true;
934
+ hostFailures.push((0, import_errors2.refusalOf)(snapshot.heightError));
881
935
  continue;
882
936
  }
883
937
  inbounds = snapshot.inbounds;
@@ -888,12 +942,13 @@ function thorchainProvider(config = {}) {
888
942
  } catch (error) {
889
943
  inbounds = null;
890
944
  if (!(error instanceof HostUnavailable)) throw error;
945
+ hostFailures.push((0, import_errors2.refusalOf)(error));
891
946
  }
892
947
  }
893
948
  if (!inbounds) {
894
- throw new Error(
949
+ throw (0, import_errors2.withReason)(new Error(
895
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"
896
- );
951
+ ), nodesFailed(hostFailures));
897
952
  }
898
953
  const confirm = async () => {
899
954
  const unconfirmed = await confirmedElsewhere(
@@ -905,44 +960,46 @@ function thorchainProvider(config = {}) {
905
960
  evmSource ? data.router : void 0,
906
961
  confirmDeadlineAt
907
962
  );
908
- if (unconfirmed) throw new Error(`thorchain: ${unconfirmed}`);
963
+ if (unconfirmed) throw (0, import_errors2.withReason)(new Error(`thorchain: ${unconfirmed.text}`), unconfirmed.info);
909
964
  };
910
965
  const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);
911
966
  const dst = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.destChain]);
912
- if (paused(src) || paused(dst)) throw new Error("thorchain: source or destination chain trading is paused");
913
- 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");
914
969
  if (!evmSource) {
915
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) {
916
- 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");
917
972
  }
918
973
  if (!sameTarget(tx.to, data.inboundAddress) || tx.memo !== data.memo || tx.value !== quote.sourceAmount || tx.chain && tx.chain !== data.sourceChain) {
919
- 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");
920
975
  }
921
976
  const srcAsset = (0, import_swap2_assets.ccAssetByToken)(data.sourceChain, sameTarget(data.assetAddress, NATIVE) ? "" : data.assetAddress);
922
- if (!srcAsset) throw new Error("thorchain: unknown source asset");
977
+ if (!srcAsset) throw (0, import_errors2.refusal)("unsupported_pair", "thorchain: unknown source asset");
923
978
  const amount1e8 = BigInt(quote.sourceAmount) * 100000000n / 10n ** BigInt(srcAsset.decimals);
924
979
  const tooSmall = belowThorMinimums(amount1e8, src, null);
925
- if (tooSmall) throw new Error(`thorchain: ${tooSmall}`);
980
+ if (tooSmall) throw (0, import_errors2.refusal)("amount_too_small", `thorchain: ${tooSmall}`);
926
981
  await confirm();
927
982
  return;
928
983
  }
929
984
  if (!sameTarget(src?.router, data.router) || !sameTarget(tx.to, data.router)) {
930
- throw new Error("thorchain: router rotated after quote");
985
+ throw (0, import_errors2.refusal)("vault_mismatch", "thorchain: router rotated after quote");
931
986
  }
932
- 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");
933
988
  let decoded;
934
989
  try {
935
990
  decoded = (0, import_viem.decodeFunctionData)({ abi: ROUTER_ABI, data: tx.data });
936
991
  } catch {
937
- throw new Error("thorchain: malformed depositWithExpiry calldata");
992
+ throw (0, import_errors2.refusal)("policy_refused", "thorchain: malformed depositWithExpiry calldata");
938
993
  }
939
994
  const args = decoded.args;
940
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]) {
941
- 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");
942
998
  }
943
999
  const tokenSource = !sameTarget(data.assetAddress, NATIVE);
944
1000
  if (tx.value !== (tokenSource ? "0" : quote.sourceAmount) || (tokenSource ? !sameTarget(tx.approvalAddress, data.router) : tx.approvalAddress !== void 0)) {
945
- 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");
946
1003
  }
947
1004
  await confirm();
948
1005
  },