@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.
@@ -8,10 +8,12 @@ import {
8
8
  // src/provider.ts
9
9
  import { decodeFunctionData, encodeFunctionData } from "viem";
10
10
  import { timeoutSignal } from "@anywalletkit/protocol-swap2-core/util";
11
+ import { combinedRefusal, httpRefusal as httpRefusal2, refusal, refusalNotes, refusalOf, withReason as withReason2 } from "@anywalletkit/protocol-swap2-core/errors";
11
12
  import { ccAsset, ccAssetByToken } from "@anywalletkit/swap2-assets";
12
13
  import { bigintToNumber } from "@anywalletkit/core";
13
14
 
14
15
  // src/confirm.ts
16
+ import { httpRefusal, withReason } from "@anywalletkit/protocol-swap2-core/errors";
15
17
  var THORNODE_CONFIRM_PATHS = Object.freeze({
16
18
  lastblock: "/thorchain/lastblock",
17
19
  inboundAddresses: "/thorchain/inbound_addresses",
@@ -34,10 +36,18 @@ function confirmOptions(o) {
34
36
  return { maxLagBlocks, timeoutMs };
35
37
  }
36
38
  var HostUnavailable = class extends Error {
37
- constructor(host, reason) {
39
+ /**
40
+ * `info` classifies the failure (`refusalOf` reads it back): what the HTTP status says (429 `rate_limited`, 401 / 403
41
+ * `key_rejected`), else `upstream_unavailable` — unreachable, timed out, a 5xx, redirected or another shape.
42
+ */
43
+ constructor(host, reason, info) {
38
44
  super(`thorchain: ${host} unavailable (${reason})`);
45
+ withReason(this, info ?? "upstream_unavailable");
39
46
  }
40
47
  };
48
+ function statusRefusal(status, retryAfter) {
49
+ return httpRefusal(status, retryAfter) ?? { reason: "upstream_unavailable", httpStatus: status };
50
+ }
41
51
  function paused(entry) {
42
52
  return !entry || entry.halted === true || entry.global_trading_paused === true || entry.chain_trading_paused === true;
43
53
  }
@@ -83,7 +93,7 @@ async function nodeRead(host, path, clientId, signal, outer) {
83
93
  throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
84
94
  }
85
95
  if (res.redirected === true || !sameRequestUrl(res.url, url)) throw new HostUnavailable(host, `${path} redirected`);
86
- if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`);
96
+ if (!res.ok) throw new HostUnavailable(host, `${path} HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
87
97
  try {
88
98
  return await res.json();
89
99
  } catch (error) {
@@ -315,7 +325,7 @@ async function hostGet(host, path, clientId, signal) {
315
325
  throw new HostUnavailable(host, error instanceof Error ? error.message : "fetch failed");
316
326
  }
317
327
  if (res.status >= 500 || res.status === 429 || res.status === 403) {
318
- throw new HostUnavailable(host, `HTTP ${res.status}`);
328
+ throw new HostUnavailable(host, `HTTP ${res.status}`, statusRefusal(res.status, res.headers?.get?.("retry-after")));
319
329
  }
320
330
  const body = await res.json().catch(() => null);
321
331
  return { ok: res.ok, status: res.status, body };
@@ -340,30 +350,53 @@ function belowThorMinimums(amount1e8, inbound, quote) {
340
350
  if (min !== void 0 && (!/^\d+$/.test(String(min)) || amount1e8 < BigInt(min))) return "amount below THORChain recommended minimum";
341
351
  return null;
342
352
  }
353
+ function nodesFailed(failures) {
354
+ const known = failures.filter((f) => f !== void 0);
355
+ if (!known.length || known.length !== failures.length) return { reason: "upstream_unavailable" };
356
+ const combined = combinedRefusal(known.map((f) => ({ message: "", ...f })));
357
+ return combined.reason === "no_route" ? { reason: "upstream_unavailable" } : combined;
358
+ }
359
+ function quoteRejection(text, status, bodyless) {
360
+ if (/emit asset 0 less than price limit/.test(text)) return { reason: "amount_too_small" };
361
+ if (/emit asset \d+ less than price limit/.test(text)) return { reason: "price_check_failed" };
362
+ if (/halt|paused/i.test(text)) return { reason: "chain_halted" };
363
+ if (/dust|too small|not enough [a-z ]*(?:to pay|for) (?:the )?fee/i.test(text)) return { reason: "amount_too_small" };
364
+ if (/insufficient liquidity|not enough liquidity/i.test(text)) return { reason: "insufficient_liquidity" };
365
+ 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" };
366
+ if (/rate limit|too many requests/i.test(text)) return { reason: "rate_limited" };
367
+ if (/api key|unauthori[sz]ed/i.test(text)) return { reason: "key_rejected" };
368
+ const byStatus = status >= 400 ? httpRefusal2(status) : void 0;
369
+ if (byStatus) return byStatus;
370
+ return bodyless ? { reason: "upstream_unavailable", httpStatus: status } : { reason: "no_route" };
371
+ }
343
372
  async function inboundAddressesOn(host, clientId) {
344
373
  const res = await hostGet(host, "/thorchain/inbound_addresses", clientId);
345
- if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`);
374
+ if (!res.ok || !Array.isArray(res.body)) throw new HostUnavailable(host, `inbound_addresses HTTP ${res.status}`, res.ok ? void 0 : statusRefusal(res.status));
346
375
  const entries = inboundAddresses(res.body);
347
376
  if (!entries) throw new HostUnavailable(host, "inbound_addresses is not a THORNode inbound address list");
348
377
  return entries;
349
378
  }
350
- function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps) {
351
- if (memo.includes("|")) return null;
379
+ function verifiedMemo(memo, expectedAsset, expectedDestination, expectedRefund, expectedAffiliate, expectedAffiliateBps, why) {
380
+ const refused = (reason) => {
381
+ why?.(reason);
382
+ return null;
383
+ };
384
+ if (memo.includes("|")) return refused("recipient_unverifiable");
352
385
  const parts = memo.split(":");
353
- if (parts.length !== 4 && parts.length !== 6) return null;
386
+ if (parts.length !== 4 && parts.length !== 6) return refused("policy_refused");
354
387
  const [op, asset, destination, limit, affiliate, bps] = parts;
355
- if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return null;
388
+ if (op !== "=" || !asset || !/^[A-Za-z0-9._~\-]+$/.test(asset)) return refused("policy_refused");
356
389
  const expectedTarget = expectedRefund ? `${expectedDestination}/${expectedRefund}` : expectedDestination;
357
- if (!sameTarget(destination, expectedTarget)) return null;
390
+ if (!sameTarget(destination, expectedTarget)) return refused("recipient_mismatch");
358
391
  if (parts.length === 6) {
359
- if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return null;
392
+ if (!expectedAffiliate || affiliate !== expectedAffiliate || bps !== expectedAffiliateBps) return refused("policy_refused");
360
393
  } else if (expectedAffiliate || expectedAffiliateBps) {
361
- return null;
394
+ return refused("policy_refused");
362
395
  }
363
396
  const stream = (limit ?? "").split("/");
364
- if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return null;
397
+ if (stream.length !== 1 || stream.some((v) => !/^\d+$/.test(v))) return refused("price_check_failed");
365
398
  const floor = BigInt(stream[0] ?? "0");
366
- if (floor <= 0n) return null;
399
+ if (floor <= 0n) return refused("price_check_failed");
367
400
  const canonical = `=:${expectedAsset}:${expectedTarget}:${stream.join("/")}${expectedAffiliate ? `:${expectedAffiliate}:${expectedAffiliateBps}` : ""}`;
368
401
  return { limit: floor, canonical };
369
402
  }
@@ -376,7 +409,7 @@ function thorchainPins(opts = {}) {
376
409
  for (const n of nodes) if (!/^https:\/\//i.test(n)) throw new Error("thorchain: thorchainPins nodeUrl must use https");
377
410
  const clientId = opts.clientId?.trim() || DEFAULT_CLIENT_ID;
378
411
  const { maxLagBlocks, timeoutMs } = confirmOptions(opts);
379
- const askNode = async (host, claim) => {
412
+ const askNode = async (host, claim, failures) => {
380
413
  const node = nodeDeadline(timeoutMs);
381
414
  const read = (path) => nodeRead(host, path, clientId, node.signal);
382
415
  try {
@@ -384,59 +417,71 @@ function thorchainPins(opts = {}) {
384
417
  try {
385
418
  return { height: head.height, verdict: await vaultVerdict(host, read, head.inbound, claim) };
386
419
  } catch (error) {
387
- if (error instanceof HostUnavailable) return { height: head.height, verdict: null };
420
+ if (error instanceof HostUnavailable) {
421
+ failures.push(refusalOf(error));
422
+ return { height: head.height, verdict: null };
423
+ }
388
424
  throw error;
389
425
  }
390
426
  } catch (error) {
391
- if (error instanceof HostUnavailable) return null;
427
+ if (error instanceof HostUnavailable) {
428
+ failures.push(refusalOf(error));
429
+ return null;
430
+ }
392
431
  throw error;
393
432
  } finally {
394
433
  node.clear();
395
434
  }
396
435
  };
397
436
  const routersFor = (chain) => [...THORCHAIN_ROUTERS[chain] ?? [], ...opts.trustedRouters?.[chain] ?? []];
437
+ const { note, reasonOf } = refusalNotes();
398
438
  return Object.freeze({
399
439
  callTargets: routersFor,
400
440
  spenders: routersFor,
441
+ reasonOf,
401
442
  // A Tron source is a send to the inbound vault; verifyBuild confirms that vault with two THORNodes (scan 2026-10-28 F9).
402
443
  depositAddressRoutes: true,
403
444
  async verifyBuild(quote, tx, params) {
404
- if (!params) return "the request this quote answered is unknown";
445
+ if (!params) return note("the request this quote answered is unknown", "policy_refused");
405
446
  const src = ccAsset(params.sourceChain, params.sourceToken);
406
447
  const dst = destAsset(params.destChain, params.destToken);
407
- if (!src || !dst) return "unknown source or destination asset";
448
+ if (!src || !dst) return note("unknown source or destination asset", "unsupported_pair");
408
449
  const chainId = CHAIN_ID[params.sourceChain];
409
450
  let vault, memo, router;
410
451
  if (chainId) {
411
- if (!tx.data) return "no router calldata";
452
+ if (!tx.data) return note("no router calldata", "policy_refused");
412
453
  let args;
413
454
  try {
414
455
  const d = decodeFunctionData({ abi: ROUTER_ABI, data: tx.data });
415
- if (d.functionName !== "depositWithExpiry") return `${d.functionName} is not depositWithExpiry`;
456
+ if (d.functionName !== "depositWithExpiry") return note(`${d.functionName} is not depositWithExpiry`, "policy_refused");
416
457
  args = d.args;
417
458
  } catch {
418
- return "the router calldata does not decode";
459
+ return note("the router calldata does not decode", "policy_refused");
419
460
  }
420
- if (!args || args.length !== 5) return "the router calldata does not decode";
461
+ if (!args || args.length !== 5) return note("the router calldata does not decode", "policy_refused");
421
462
  router = tx.to;
422
- if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return `router ${tx.to} is not pinned`;
463
+ if (!routersFor(params.sourceChain).some((r) => sameTarget(r, router))) return note(`router ${tx.to} is not pinned`, "vault_mismatch");
423
464
  vault = String(args[0]);
424
465
  const asset = String(args[1]);
425
- if (!sameTarget(asset, src.contract ?? NATIVE)) return `the router call moves ${asset}, not the source asset`;
426
- if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return `the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input`;
466
+ if (!sameTarget(asset, src.contract ?? NATIVE)) return note(`the router call moves ${asset}, not the source asset`, "policy_refused");
467
+ if (BigInt(args[2]) !== BigInt(quote.sourceAmount)) return note(`the router call moves ${String(args[2])}, not the ${quote.sourceAmount} input`, "policy_refused");
427
468
  memo = String(args[3]);
428
469
  } else {
429
- if (!tx.memo || tx.value !== quote.sourceAmount) return "the deposit is not a send of the input with a memo";
470
+ if (!tx.memo || tx.value !== quote.sourceAmount) return note("the deposit is not a send of the input with a memo", "policy_refused");
430
471
  vault = tx.to;
431
472
  memo = tx.memo;
432
473
  }
433
- const checked = verifiedMemo(memo, dst.asset, params.destAddress, params.refundAddress ?? "", opts.affiliate ?? "", opts.affiliateBps ?? "");
434
- if (!checked) return `the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`;
435
- if (memo !== checked.canonical) return `the memo is not the canonical ${checked.canonical}`;
436
- if (checked.limit * 10n ** BigInt(dst.decimals) / 100000000n < BigInt(quote.minDestAmount)) return `the memo's limit ${checked.limit} is below the quoted minimum`;
474
+ let memoRefusal = "recipient_mismatch";
475
+ const checked = verifiedMemo(memo, dst.asset, params.destAddress, params.refundAddress ?? "", opts.affiliate ?? "", opts.affiliateBps ?? "", (why) => {
476
+ memoRefusal = why;
477
+ });
478
+ if (!checked) return note(`the memo does not pay ${params.destAddress} in ${dst.asset} with this app's refund and affiliate`, memoRefusal);
479
+ if (memo !== checked.canonical) return note(`the memo is not the canonical ${checked.canonical}`, "policy_refused");
480
+ 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");
437
481
  const sourceChain = THOR_CHAIN[params.sourceChain] ?? params.sourceChain;
438
482
  const claim = { chain: sourceChain, destChain: THOR_CHAIN[params.destChain] ?? params.destChain, vault, ...router !== void 0 ? { router } : {} };
439
- const views = await Promise.all(nodes.map((host) => askNode(host, claim)));
483
+ const failures = [];
484
+ const views = await Promise.all(nodes.map((host) => askNode(host, claim, failures)));
440
485
  const top = views.reduce((max, v) => v && v.height > max ? v.height : max, -1);
441
486
  let agreeing = 0;
442
487
  let lagging = false;
@@ -450,20 +495,20 @@ function thorchainPins(opts = {}) {
450
495
  if (!v.ok) {
451
496
  switch (v.why) {
452
497
  case "halted":
453
- return `${v.chain} is halted / paused`;
498
+ return note(`${v.chain} is halted / paused`, "chain_halted");
454
499
  case "another-vault":
455
- return `a THORNode reports another inbound vault than ${vault}`;
500
+ return note(`a THORNode reports another inbound vault than ${vault}`, "vault_mismatch");
456
501
  case "not-active":
457
- return `a THORNode lists the inbound vault as ${v.status}, not ActiveVault`;
502
+ return note(`a THORNode lists the inbound vault as ${v.status}, not ActiveVault`, "vault_mismatch");
458
503
  case "flagged":
459
- return `a THORNode's mimir flags the inbound vault as ${v.flag}`;
504
+ return note(`a THORNode's mimir flags the inbound vault as ${v.flag}`, "vault_mismatch");
460
505
  case "another-router":
461
- return "a THORNode reports another router";
506
+ return note("a THORNode reports another router", "vault_mismatch");
462
507
  }
463
508
  }
464
509
  if (++agreeing >= 2) return null;
465
510
  }
466
- 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";
511
+ 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));
467
512
  }
468
513
  });
469
514
  }
@@ -544,15 +589,15 @@ function thorchainProvider(config = {}) {
544
589
  const confirmText = (v) => {
545
590
  switch (v.why) {
546
591
  case "halted":
547
- return `${v.chain} is halted / paused on the confirming node`;
592
+ return { text: `${v.chain} is halted / paused on the confirming node`, info: { reason: "chain_halted" } };
548
593
  case "another-vault":
549
- return "a second node reports another inbound vault \u2014 refused until they agree";
594
+ return { text: "a second node reports another inbound vault \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
550
595
  case "not-active":
551
- return `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`;
596
+ return { text: `a second node lists the inbound vault as ${v.status}, not ActiveVault \u2014 refused`, info: { reason: "vault_mismatch" } };
552
597
  case "flagged":
553
- return `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`;
598
+ return { text: `a second node's mimir flags the inbound vault as ${v.flag} \u2014 refused`, info: { reason: "vault_mismatch" } };
554
599
  case "another-router":
555
- return "a second node reports another router \u2014 refused until they agree";
600
+ return { text: "a second node reports another router \u2014 refused until they agree", info: { reason: "vault_mismatch" } };
556
601
  }
557
602
  };
558
603
  const confirmOn = async (host, signal, outer, quoteHeight, claim) => {
@@ -571,10 +616,14 @@ function thorchainProvider(config = {}) {
571
616
  };
572
617
  let lagging = false;
573
618
  let asked = 0;
619
+ const failures = [];
574
620
  for (const host of confirmHosts) {
575
621
  if (host === quoteHost) continue;
576
622
  const budget = Math.min(confirmTimeoutMs, deadlineAt - Date.now());
577
- if (budget < MIN_CONFIRM_TIMEOUT_MS) break;
623
+ if (budget < MIN_CONFIRM_TIMEOUT_MS) {
624
+ failures.push({ reason: "upstream_unavailable" });
625
+ break;
626
+ }
578
627
  asked++;
579
628
  const node = nodeDeadline(budget, outer);
580
629
  try {
@@ -586,19 +635,22 @@ function thorchainProvider(config = {}) {
586
635
  return verdict;
587
636
  } catch (error) {
588
637
  if (outer?.aborted) throw error;
589
- if (error instanceof HostUnavailable) continue;
638
+ if (error instanceof HostUnavailable) {
639
+ failures.push(refusalOf(error));
640
+ continue;
641
+ }
590
642
  throw error;
591
643
  } finally {
592
644
  node.clear();
593
645
  }
594
646
  }
595
647
  if (lagging) {
596
- return `no confirming node is within ${confirmMaxLagBlocks} blocks of the quoting node's THORChain height (behind or unreachable) \u2014 refused until one catches up`;
648
+ 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" } };
597
649
  }
598
650
  if (asked === 0) {
599
- 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`;
651
+ 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" } };
600
652
  }
601
- return "no second node answered to confirm the inbound vault (configure confirmNodeUrl)";
653
+ return { text: "no second node answered to confirm the inbound vault (configure confirmNodeUrl)", info: nodesFailed(failures) };
602
654
  };
603
655
  const configuredAffiliate = config.affiliate ?? "";
604
656
  const configuredAffiliateBps = config.affiliateBps ?? "";
@@ -644,24 +696,27 @@ function thorchainProvider(config = {}) {
644
696
  const fromAsset = thorAsset(params.sourceChain, params.sourceToken);
645
697
  const dst = destAsset(params.destChain, params.destToken);
646
698
  if (!src || !fromAsset || !dst || fromAsset === dst.asset) {
647
- params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`);
699
+ params.onRefusal?.(`thorchain: no pool for ${params.sourceToken} on ${params.sourceChain} \u2192 ${params.destToken} on ${params.destChain}`, { reason: "unsupported_pair" });
648
700
  return null;
649
701
  }
650
702
  const amountIn = BigInt(params.amountIn);
651
703
  const amount1e8 = amountIn * 100000000n / 10n ** BigInt(src.decimals);
652
704
  if (amount1e8 <= 0n) return null;
653
705
  if (amount1e8 * 10n ** BigInt(src.decimals) !== amountIn * 100000000n) {
654
- params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`);
706
+ params.onRefusal?.(`thorchain: amount has more precision than THORChain's 8 decimals \u2014 round it to 8 decimals`, { reason: "policy_refused" });
655
707
  return null;
656
708
  }
657
709
  const requestedTolerance = params.slippageBps ?? Number(TOLERANCE_BPS);
658
710
  if (!Number.isInteger(requestedTolerance) || requestedTolerance < 0 || requestedTolerance > bigintToNumber(MAX_TOLERANCE_BPS)) {
659
- params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`);
711
+ params.onRefusal?.(`thorchain: slippage ${String(params.slippageBps)} bps is outside 0\u2013${MAX_TOLERANCE_BPS} bps`, { reason: "policy_refused" });
660
712
  return null;
661
713
  }
662
714
  const explicitSlippage = params.slippageBps !== void 0;
663
715
  const liquidityBps = explicitSlippage ? dst.decimals > 8 ? requestedTolerance - 1 : requestedTolerance : 0;
664
716
  const limitMode = liquidityBps > 0 ? "liquidity_tolerance_bps" : "tolerance_bps";
717
+ let quoteStatus = 0;
718
+ let quoteBodyless = false;
719
+ let quoteBodyAborted = false;
665
720
  const quoteOn = async (host, bps, limitMode2 = "tolerance_bps") => {
666
721
  const qs = new URLSearchParams({
667
722
  from_asset: fromAsset,
@@ -682,6 +737,9 @@ function thorchainProvider(config = {}) {
682
737
  }
683
738
  if (params.refundAddress) qs.set("refund_address", params.refundAddress);
684
739
  const res = await hostGet(host, `/thorchain/quote/swap?${qs.toString()}`, clientId, signal);
740
+ quoteStatus = res.status;
741
+ quoteBodyless = res.body === null && !res.ok;
742
+ quoteBodyAborted = res.body === null && signal?.aborted === true;
685
743
  if (Array.isArray(res.body)) return null;
686
744
  return res.body ?? (res.ok ? null : { error: `HTTP ${res.status}` });
687
745
  };
@@ -692,13 +750,14 @@ function thorchainProvider(config = {}) {
692
750
  let quoteHeight = 0;
693
751
  let confirmDeadlineAt = 0;
694
752
  let heightUnavailable = false;
753
+ const hostFailures = [];
695
754
  for (const host of hosts) {
696
755
  try {
697
756
  if (limitMode === "liquidity_tolerance_bps") {
698
757
  q = await quoteOn(host, String(liquidityBps), "liquidity_tolerance_bps");
699
758
  const feeBps = Number(q?.fees?.total_bps);
700
759
  if (Number.isFinite(feeBps) && feeBps > bigintToNumber(MAX_TOLERANCE_BPS)) {
701
- params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${bigintToNumber(MAX_TOLERANCE_BPS) / 100}%)`);
760
+ params.onRefusal?.(`thorchain: THORChain fees ${(feeBps / 100).toFixed(2)}% \u2014 too expensive at this amount (cap ${bigintToNumber(MAX_TOLERANCE_BPS) / 100}%)`, { reason: "policy_refused" });
702
761
  return null;
703
762
  }
704
763
  } else {
@@ -715,25 +774,28 @@ function thorchainProvider(config = {}) {
715
774
  }
716
775
  }
717
776
  if (q?.error || q?.message) {
718
- params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}`);
777
+ params.onRefusal?.(`thorchain: ${String(q.error ?? q.message).slice(0, 200)}`, quoteBodyAborted ? httpRefusal2(quoteStatus) : quoteRejection(String(q.error ?? q.message), quoteStatus, quoteBodyless));
719
778
  return null;
720
779
  }
721
780
  if (!q || !q.expected_amount_out || !q.inbound_address || !q.memo || !q.expiry) {
722
- params.onRefusal?.(`thorchain: incomplete quote from ${host} (expected_amount_out / inbound_address / memo / expiry missing)`);
781
+ 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 } : {} });
723
782
  return null;
724
783
  }
725
784
  if (chainId ? !q.router : !!q.router) {
726
- 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");
785
+ 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" });
727
786
  return null;
728
787
  }
729
- checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps);
788
+ let memoRefusal = "recipient_mismatch";
789
+ checkedMemo = verifiedMemo(q.memo, dst.asset, params.destAddress, params.refundAddress ?? "", configuredAffiliate, configuredAffiliateBps, (why) => {
790
+ memoRefusal = why;
791
+ });
730
792
  if (!checkedMemo) {
731
- params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`);
793
+ params.onRefusal?.(`thorchain: memo failed verification (${String(q.memo).slice(0, 80)})`, { reason: memoRefusal });
732
794
  return null;
733
795
  }
734
796
  const floor1e8 = params.minAmountOut !== void 0 ? toThorUnitsCeil(BigInt(params.minAmountOut), dst.decimals) : void 0;
735
797
  if (floor1e8 !== void 0 && checkedMemo.limit < floor1e8) {
736
- params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`);
798
+ params.onRefusal?.(`thorchain: floor breach (limit=${checkedMemo.limit} < floor=${floor1e8}, 1e8 units)`, { reason: "price_check_failed" });
737
799
  return null;
738
800
  }
739
801
  const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
@@ -749,6 +811,7 @@ function thorchainProvider(config = {}) {
749
811
  break;
750
812
  } catch (error) {
751
813
  if (error instanceof HostUnavailable) {
814
+ hostFailures.push(refusalOf(error));
752
815
  q = null;
753
816
  checkedMemo = null;
754
817
  inbounds = null;
@@ -759,7 +822,8 @@ function thorchainProvider(config = {}) {
759
822
  }
760
823
  if (!q?.expected_amount_out || !q.inbound_address || !q.expiry || !checkedMemo || !inbounds) {
761
824
  params.onRefusal?.(
762
- 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"
825
+ 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",
826
+ inbounds ? { reason: "no_route" } : nodesFailed(hostFailures)
763
827
  );
764
828
  return null;
765
829
  }
@@ -767,19 +831,19 @@ function thorchainProvider(config = {}) {
767
831
  const srcInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.sourceChain]);
768
832
  const dstInbound = inbounds.find((entry) => entry.chain === THOR_CHAIN[params.destChain]);
769
833
  if (paused(srcInbound) || paused(dstInbound)) {
770
- params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`);
834
+ params.onRefusal?.(`thorchain: ${paused(srcInbound) ? THOR_CHAIN[params.sourceChain] : THOR_CHAIN[params.destChain]} chain is halted / paused on THORChain`, { reason: "chain_halted" });
771
835
  return null;
772
836
  }
773
837
  if (!sameTarget(srcInbound?.address, q.inbound_address)) {
774
- params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry");
838
+ params.onRefusal?.("thorchain: quote inbound address differs from the node's current inbound (vault rotated) \u2014 retry", { reason: "vault_mismatch" });
775
839
  return null;
776
840
  }
777
841
  if (chainId && !sameTarget(srcInbound?.router, q.router)) {
778
- params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry");
842
+ params.onRefusal?.("thorchain: quote router differs from the node's current router \u2014 retry", { reason: "vault_mismatch" });
779
843
  return null;
780
844
  }
781
845
  if (chainId && !pinnedRouter(params.sourceChain, q.router)) {
782
- params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`);
846
+ params.onRefusal?.(`thorchain: router ${String(q.router)} is not a THORChain router this module trusts on ${params.sourceChain} (THORCHAIN_ROUTERS / trustedRouters)`, { reason: "vault_mismatch" });
783
847
  return null;
784
848
  }
785
849
  const unconfirmed = await confirmedElsewhere(
@@ -793,36 +857,36 @@ function thorchainProvider(config = {}) {
793
857
  signal
794
858
  );
795
859
  if (unconfirmed) {
796
- params.onRefusal?.(`thorchain: ${unconfirmed}`);
860
+ params.onRefusal?.(`thorchain: ${unconfirmed.text}`, unconfirmed.info);
797
861
  return null;
798
862
  }
799
863
  const tooSmall = belowThorMinimums(amount1e8, srcInbound, q);
800
864
  if (tooSmall) {
801
- params.onRefusal?.(`thorchain: ${tooSmall}`);
865
+ params.onRefusal?.(`thorchain: ${tooSmall}`, { reason: "amount_too_small" });
802
866
  return null;
803
867
  }
804
868
  const outUnits = BigInt(q.expected_amount_out) * 10n ** BigInt(dst.decimals) / 100000000n;
805
869
  if (outUnits <= 0n) {
806
- params.onRefusal?.("thorchain: expected output rounds to zero");
870
+ params.onRefusal?.("thorchain: expected output rounds to zero", { reason: "amount_too_small" });
807
871
  return null;
808
872
  }
809
873
  const memoMinUnits = limit1e8 * 10n ** BigInt(dst.decimals) / 100000000n;
810
874
  const minUnits = memoMinUnits;
811
875
  if (minUnits <= 0n || minUnits > outUnits) {
812
- params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`);
876
+ params.onRefusal?.(`thorchain: memo limit ${limit1e8} inconsistent with expected output ${q.expected_amount_out}`, { reason: "price_check_failed" });
813
877
  return null;
814
878
  }
815
879
  if (params.slippageBps !== void 0) {
816
880
  const slippageFloor = outUnits * BigInt(1e4 - params.slippageBps) / 10000n;
817
881
  if (minUnits < slippageFloor) {
818
- params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`);
882
+ params.onRefusal?.(`thorchain: memo limit ${limit1e8} is below expected ${q.expected_amount_out} minus ${params.slippageBps} bps slippage (1e8 units)`, { reason: "price_check_failed" });
819
883
  return null;
820
884
  }
821
885
  }
822
886
  const nowSeconds = Math.floor(Date.now() / 1e3);
823
887
  const executionExpiry = Math.min(q.expiry, nowSeconds + 15 * 60);
824
888
  if (!Number.isInteger(executionExpiry) || executionExpiry <= nowSeconds + 30) {
825
- params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`);
889
+ params.onRefusal?.(`thorchain: quote expires in ${Number.isInteger(executionExpiry) ? executionExpiry - nowSeconds : "?"} s \u2014 too soon to execute`, { reason: "quote_expired" });
826
890
  return null;
827
891
  }
828
892
  const assetAddr = src.contract ? src.contract : NATIVE;
@@ -878,7 +942,7 @@ function thorchainProvider(config = {}) {
878
942
  if (!quote.txRequest) {
879
943
  const sourceChain = quote.providerData?.sourceChain ?? "";
880
944
  if (!isDepositSource(sourceChain) || !quote.depositAddress || !quote.depositMemo) {
881
- throw new Error("thorchain: deposit-address execution is only supported for an opted-in Tron source");
945
+ throw refusal("unsupported_pair", "thorchain: deposit-address execution is only supported for an opted-in Tron source");
882
946
  }
883
947
  return { chain: sourceChain, to: quote.depositAddress, value: quote.sourceAmount, memo: quote.depositMemo };
884
948
  }
@@ -895,26 +959,28 @@ function thorchainProvider(config = {}) {
895
959
  async preflightDeposit(quote, tx) {
896
960
  const data = quote.providerData;
897
961
  if (!data?.sourceChain || !data.destChain || !data.inboundAddress || !data.assetAddress || !data.expiry || !data.memo) {
898
- throw new Error("thorchain: quote is missing execution-bound metadata");
962
+ throw refusal("policy_refused", "thorchain: quote is missing execution-bound metadata");
899
963
  }
900
964
  const evmSource = CHAIN_ID[data.sourceChain] !== void 0;
901
- if (evmSource && !data.router) throw new Error("thorchain: quote is missing execution-bound metadata");
902
- if (!evmSource && !isDepositSource(data.sourceChain)) throw new Error("thorchain: source chain has no executor");
965
+ if (evmSource && !data.router) throw refusal("policy_refused", "thorchain: quote is missing execution-bound metadata");
966
+ if (!evmSource && !isDepositSource(data.sourceChain)) throw refusal("unsupported_pair", "thorchain: source chain has no executor");
903
967
  if (!/^\d+$/.test(data.expiry) || BigInt(data.expiry) <= BigInt(Math.floor(Date.now() / 1e3))) {
904
- throw new Error("thorchain: quote expired before broadcast");
968
+ throw refusal("quote_expired", "thorchain: quote expired before broadcast");
905
969
  }
906
- if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw new Error("thorchain: the quote's router is not one this module trusts");
970
+ if (evmSource && !pinnedRouter(data.sourceChain, data.router)) throw refusal("vault_mismatch", "thorchain: the quote's router is not one this module trusts");
907
971
  let inbounds = null;
908
972
  let snapshotHost = "";
909
973
  let snapshotHeight = 0;
910
974
  let confirmDeadlineAt = 0;
911
975
  let heightUnavailable = false;
976
+ const hostFailures = [];
912
977
  for (const host of hosts) {
913
978
  const deadlineAt = Date.now() + CONFIRM_BUDGET_MS;
914
979
  try {
915
980
  const snapshot = await nodeSnapshot(host);
916
981
  if ("heightError" in snapshot) {
917
982
  heightUnavailable = true;
983
+ hostFailures.push(refusalOf(snapshot.heightError));
918
984
  continue;
919
985
  }
920
986
  inbounds = snapshot.inbounds;
@@ -925,12 +991,13 @@ function thorchainProvider(config = {}) {
925
991
  } catch (error) {
926
992
  inbounds = null;
927
993
  if (!(error instanceof HostUnavailable)) throw error;
994
+ hostFailures.push(refusalOf(error));
928
995
  }
929
996
  }
930
997
  if (!inbounds) {
931
- throw new Error(
998
+ throw withReason2(new Error(
932
999
  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"
933
- );
1000
+ ), nodesFailed(hostFailures));
934
1001
  }
935
1002
  const confirm = async () => {
936
1003
  const unconfirmed = await confirmedElsewhere(
@@ -942,44 +1009,46 @@ function thorchainProvider(config = {}) {
942
1009
  evmSource ? data.router : void 0,
943
1010
  confirmDeadlineAt
944
1011
  );
945
- if (unconfirmed) throw new Error(`thorchain: ${unconfirmed}`);
1012
+ if (unconfirmed) throw withReason2(new Error(`thorchain: ${unconfirmed.text}`), unconfirmed.info);
946
1013
  };
947
1014
  const src = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.sourceChain]);
948
1015
  const dst = inbounds.find((entry) => entry.chain === THOR_CHAIN[data.destChain]);
949
- if (paused(src) || paused(dst)) throw new Error("thorchain: source or destination chain trading is paused");
950
- if (!sameTarget(src?.address, data.inboundAddress)) throw new Error("thorchain: inbound vault rotated after quote");
1016
+ if (paused(src) || paused(dst)) throw refusal("chain_halted", "thorchain: source or destination chain trading is paused");
1017
+ if (!sameTarget(src?.address, data.inboundAddress)) throw refusal("vault_mismatch", "thorchain: inbound vault rotated after quote");
951
1018
  if (!evmSource) {
952
1019
  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) {
953
- throw new Error("thorchain: deposit-address execution must be a plain send with memo");
1020
+ throw refusal("policy_refused", "thorchain: deposit-address execution must be a plain send with memo");
954
1021
  }
955
1022
  if (!sameTarget(tx.to, data.inboundAddress) || tx.memo !== data.memo || tx.value !== quote.sourceAmount || tx.chain && tx.chain !== data.sourceChain) {
956
- throw new Error("thorchain: deposit does not match the validated quote");
1023
+ throw refusal(!sameTarget(tx.to, data.inboundAddress) ? "vault_mismatch" : "policy_refused", "thorchain: deposit does not match the validated quote");
957
1024
  }
958
1025
  const srcAsset = ccAssetByToken(data.sourceChain, sameTarget(data.assetAddress, NATIVE) ? "" : data.assetAddress);
959
- if (!srcAsset) throw new Error("thorchain: unknown source asset");
1026
+ if (!srcAsset) throw refusal("unsupported_pair", "thorchain: unknown source asset");
960
1027
  const amount1e8 = BigInt(quote.sourceAmount) * 100000000n / 10n ** BigInt(srcAsset.decimals);
961
1028
  const tooSmall = belowThorMinimums(amount1e8, src, null);
962
- if (tooSmall) throw new Error(`thorchain: ${tooSmall}`);
1029
+ if (tooSmall) throw refusal("amount_too_small", `thorchain: ${tooSmall}`);
963
1030
  await confirm();
964
1031
  return;
965
1032
  }
966
1033
  if (!sameTarget(src?.router, data.router) || !sameTarget(tx.to, data.router)) {
967
- throw new Error("thorchain: router rotated after quote");
1034
+ throw refusal("vault_mismatch", "thorchain: router rotated after quote");
968
1035
  }
969
- if (!tx.data) throw new Error("thorchain: missing depositWithExpiry calldata");
1036
+ if (!tx.data) throw refusal("policy_refused", "thorchain: missing depositWithExpiry calldata");
970
1037
  let decoded;
971
1038
  try {
972
1039
  decoded = decodeFunctionData({ abi: ROUTER_ABI, data: tx.data });
973
1040
  } catch {
974
- throw new Error("thorchain: malformed depositWithExpiry calldata");
1041
+ throw refusal("policy_refused", "thorchain: malformed depositWithExpiry calldata");
975
1042
  }
976
1043
  const args = decoded.args;
977
1044
  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]) {
978
- throw new Error("thorchain: calldata does not match the validated quote");
1045
+ const vaultChanged = decoded.functionName === "depositWithExpiry" && !!args && args.length === 5 && !sameTarget(String(args[0]), data.inboundAddress);
1046
+ throw refusal(vaultChanged ? "vault_mismatch" : "policy_refused", "thorchain: calldata does not match the validated quote");
979
1047
  }
980
1048
  const tokenSource = !sameTarget(data.assetAddress, NATIVE);
981
1049
  if (tx.value !== (tokenSource ? "0" : quote.sourceAmount) || (tokenSource ? !sameTarget(tx.approvalAddress, data.router) : tx.approvalAddress !== void 0)) {
982
- throw new Error("thorchain: value/approval does not match the validated source asset");
1050
+ const spenderChanged = tokenSource && tx.approvalAddress !== void 0 && !sameTarget(tx.approvalAddress, data.router);
1051
+ throw refusal(spenderChanged ? "vault_mismatch" : "policy_refused", "thorchain: value/approval does not match the validated source asset");
983
1052
  }
984
1053
  await confirm();
985
1054
  },
@@ -1038,4 +1107,4 @@ export {
1038
1107
  thorchainPins,
1039
1108
  thorchainProvider
1040
1109
  };
1041
- //# sourceMappingURL=chunk-AYP7O2NT.js.map
1110
+ //# sourceMappingURL=chunk-EIAXGWNG.js.map