@continuumdao/ctm-mpc-defi 0.2.59 → 0.2.61

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.
@@ -1,6 +1,6 @@
1
- import { getAddress, parseAbi, zeroAddress, encodeFunctionData, defineChain, createPublicClient, parseGwei, serializeTransaction, keccak256, hashMessage, stringToHex, http } from 'viem';
1
+ import { getAddress, parseAbi, zeroAddress, keccak256, hashMessage, recoverAddress, encodeFunctionData, defineChain, createPublicClient, parseGwei, serializeTransaction, stringToHex, http } from 'viem';
2
2
  import { fetchChainFeeParams, gasLimitFromEstimateAndChainConfig, gweiToDecimalString, proposalTxParamsToFeeSnapshot, alignEip1559FeesWithLatestBase, getClientIdFromKeyGenResult } from '@continuumdao/continuum-node-sdk';
3
- import { TXIDVersion, calculateGasPrice, ChainType, EVMGasType, RailgunWalletBalanceBucket } from '@railgun-community/shared-models';
3
+ import { TXIDVersion, RailgunWalletBalanceBucket, ChainType, EVMGasType, getEVMGasTypeForTransaction, calculateGasPrice, NETWORK_CONFIG } from '@railgun-community/shared-models';
4
4
 
5
5
  // src/core/registry.ts
6
6
  var modules = [];
@@ -540,11 +540,44 @@ async function buildEvmMultisignBatch(args) {
540
540
  return result;
541
541
  }
542
542
 
543
+ // src/protocols/evm/railgun/data-dir.ts
544
+ var RAILGUN_CONTAINER_DATA_DIR = "/app/railgun_data";
545
+ function railgunDataRoot() {
546
+ if (typeof process === "undefined") return "";
547
+ const fromEnv = process.env.RAILGUN_DATA_DIR?.trim() ?? "";
548
+ if (fromEnv) return fromEnv;
549
+ if (process.versions?.node && process.env.MCP_HTTP_PORT?.trim() && process.cwd() === "/app") {
550
+ return RAILGUN_CONTAINER_DATA_DIR;
551
+ }
552
+ return "";
553
+ }
554
+ async function resolveRailgunDataRoot() {
555
+ return railgunDataRoot();
556
+ }
557
+ function safeKeyGenFolderName(keyGenId) {
558
+ const name = keyGenId.trim();
559
+ if (!/^[A-Za-z0-9_-]{1,80}$/.test(name)) {
560
+ throw new Error("KeyGen id cannot be used as a Railgun data folder name.");
561
+ }
562
+ return name;
563
+ }
564
+ function under(root, ...parts) {
565
+ return [root.replace(/\/+$/, ""), ...parts].join("/");
566
+ }
567
+ function railgunEnginePath(root) {
568
+ return under(root, "engine");
569
+ }
570
+ function railgunArtifactPath(root) {
571
+ return under(root, "artifacts");
572
+ }
573
+ function railgunKeyGenWalletPath(root, keyGenId) {
574
+ return under(root, "keygens", safeKeyGenFolderName(keyGenId), "wallet.json");
575
+ }
576
+
543
577
  // src/protocols/evm/railgun/engine.ts
544
578
  var artifacts = /* @__PURE__ */ new Map();
545
579
  var started = false;
546
580
  var loadedProviders = /* @__PURE__ */ new Set();
547
- var lastEngineError = "";
548
581
  function errorChain(err) {
549
582
  const parts = [];
550
583
  let current = err;
@@ -559,24 +592,72 @@ function errorChain(err) {
559
592
  }
560
593
  return parts.join(": ");
561
594
  }
562
- function latestRailgunEngineError() {
563
- return lastEngineError;
564
- }
565
595
  var POI_NODE_URLS = ["https://ppoi.fdi.network"];
566
596
  async function walletModule() {
567
597
  return await import('@railgun-community/wallet');
568
598
  }
569
- var artifactStore = {
570
- async get(path) {
571
- return artifacts.get(path) ?? null;
572
- },
573
- async store(_dir, path, item) {
574
- artifacts.set(path, item);
575
- },
576
- async exists(path) {
577
- return artifacts.has(path);
599
+ function artifactFile(root, itemPath) {
600
+ const relative = itemPath.replace(/^\/+/, "");
601
+ if (!relative || relative.split("/").some((part) => part === ".." || part === "")) {
602
+ throw new Error("Railgun artifact path is not valid.");
578
603
  }
579
- };
604
+ return `${railgunArtifactPath(root)}/${relative}`;
605
+ }
606
+ function memoryArtifactStore() {
607
+ return {
608
+ async get(path) {
609
+ return artifacts.get(path) ?? null;
610
+ },
611
+ async store(_dir, path, item) {
612
+ artifacts.set(path, item);
613
+ },
614
+ async exists(path) {
615
+ return artifacts.has(path);
616
+ }
617
+ };
618
+ }
619
+ function diskArtifactStore(root) {
620
+ const load = new Function("id", "return import(id)");
621
+ return {
622
+ async get(itemPath) {
623
+ const fs = await load("node:fs/promises");
624
+ try {
625
+ return await fs.readFile(artifactFile(root, itemPath));
626
+ } catch {
627
+ return null;
628
+ }
629
+ },
630
+ async store(_dir, itemPath, item) {
631
+ const fs = await load("node:fs/promises");
632
+ const file = artifactFile(root, itemPath);
633
+ await fs.mkdir(file.slice(0, file.lastIndexOf("/")), { recursive: true });
634
+ await fs.writeFile(file, item);
635
+ },
636
+ async exists(itemPath) {
637
+ const fs = await load("node:fs/promises");
638
+ try {
639
+ await fs.access(artifactFile(root, itemPath));
640
+ return true;
641
+ } catch {
642
+ return false;
643
+ }
644
+ }
645
+ };
646
+ }
647
+ async function railgunDatabase(root) {
648
+ if (!root) {
649
+ const memdown = (await import('memdown')).default;
650
+ return new memdown();
651
+ }
652
+ const load = new Function("id", "return import(id)");
653
+ const fs = await load("node:fs/promises");
654
+ const engineDir = railgunEnginePath(root);
655
+ await fs.mkdir(engineDir, { recursive: true });
656
+ await fs.mkdir(railgunArtifactPath(root), { recursive: true });
657
+ const leveldownModule = await load("leveldown");
658
+ const open = leveldownModule.default ?? leveldownModule;
659
+ return open(engineDir);
660
+ }
580
661
  async function attachEngineErrorLogger() {
581
662
  if (typeof window !== "undefined") return;
582
663
  if (typeof process === "undefined" || !process.versions?.node) return;
@@ -589,21 +670,39 @@ async function attachEngineErrorLogger() {
589
670
  setLoggers(
590
671
  () => void 0,
591
672
  (err) => {
592
- lastEngineError = errorChain(err);
673
+ errorChain(err);
593
674
  }
594
675
  );
595
676
  }
677
+ async function attachSnarkJsGroth16() {
678
+ if (typeof window !== "undefined") return;
679
+ if (typeof process === "undefined" || !process.versions?.node) return;
680
+ const load = new Function("id", "return import(id)");
681
+ const nodeModule = await load("node:module");
682
+ const nodePath = await load("node:path");
683
+ const require2 = nodeModule.createRequire(import.meta.url);
684
+ const walletEntry = require2.resolve("@railgun-community/wallet");
685
+ const { getProver } = require2(nodePath.join(nodePath.dirname(walletEntry), "services/railgun/core/prover.js"));
686
+ const snark = require2("snarkjs");
687
+ const groth16 = snark.groth16;
688
+ if (!groth16 || typeof groth16.fullProve !== "function") {
689
+ throw new Error("snarkjs groth16 prover is missing.");
690
+ }
691
+ getProver().setSnarkJSGroth16(groth16);
692
+ }
596
693
  async function ensureRailgunEngine() {
597
694
  if (started) return;
598
695
  const wallet = await walletModule();
599
696
  await attachEngineErrorLogger();
600
- const memdown = (await import('memdown')).default;
697
+ const root = await resolveRailgunDataRoot();
698
+ const files = root ? diskArtifactStore(root) : memoryArtifactStore();
601
699
  const store = new wallet.ArtifactStore(
602
- (path) => artifactStore.get(path),
603
- (dir, path, item) => artifactStore.store(dir, path, item),
604
- (path) => artifactStore.exists(path)
700
+ (path) => files.get(path),
701
+ (dir, path, item) => files.store(dir, path, item),
702
+ (path) => files.exists(path)
605
703
  );
606
- await wallet.startRailgunEngine("continuum", new memdown(), true, store, false, false, POI_NODE_URLS, [], false);
704
+ await wallet.startRailgunEngine("continuum", await railgunDatabase(root), true, store, false, false, POI_NODE_URLS, [], false);
705
+ await attachSnarkJsGroth16();
607
706
  started = true;
608
707
  }
609
708
  async function ensureRailgunProvider(chainId, rpcUrl) {
@@ -625,21 +724,80 @@ async function ensureRailgunProvider(chainId, rpcUrl) {
625
724
  }
626
725
  return networkName;
627
726
  }
727
+ var RAILGUN_TRANSFER_GAS_ESTIMATE = 2900000n;
728
+ var RAILGUN_UNSHIELD_GAS_ESTIMATE = 3400000n;
729
+ function weiFromGwei(gwei, fallback = 1) {
730
+ return BigInt(Math.round(Number(gwei ?? fallback) * 1e9));
731
+ }
628
732
  function railgunGasDetails(chainDetail, gasEstimate) {
629
733
  if (chainDetail.legacy) {
630
734
  return {
631
735
  evmGasType: EVMGasType.Type0,
632
736
  gasEstimate,
633
- gasPrice: BigInt(Math.round(Number(chainDetail.gasPrice ?? 1) * 1e9))
737
+ gasPrice: weiFromGwei(chainDetail.gasPrice)
634
738
  };
635
739
  }
636
740
  return {
637
741
  evmGasType: EVMGasType.Type2,
638
742
  gasEstimate,
639
- maxFeePerGas: BigInt(Math.round(Number(chainDetail.baseFee ?? 1) * 1e9)),
640
- maxPriorityFeePerGas: BigInt(Math.round(Number(chainDetail.priorityFee ?? 1) * 1e9))
743
+ maxFeePerGas: weiFromGwei(chainDetail.baseFee),
744
+ maxPriorityFeePerGas: weiFromGwei(chainDetail.priorityFee)
745
+ };
746
+ }
747
+ function railgunBroadcasterGasDetails(networkName, chainDetail, gasEstimate) {
748
+ const evmGasType = getEVMGasTypeForTransaction(networkName, false);
749
+ if (evmGasType === EVMGasType.Type2 || evmGasType === EVMGasType.Type4) {
750
+ return {
751
+ evmGasType,
752
+ gasEstimate,
753
+ maxFeePerGas: weiFromGwei(chainDetail.baseFee),
754
+ maxPriorityFeePerGas: weiFromGwei(chainDetail.priorityFee)
755
+ };
756
+ }
757
+ const gasPrice = evmGasType === EVMGasType.Type0 || chainDetail.legacy ? weiFromGwei(chainDetail.gasPrice) : weiFromGwei(chainDetail.baseFee) + weiFromGwei(chainDetail.priorityFee);
758
+ return { evmGasType, gasEstimate, gasPrice };
759
+ }
760
+ function gweiFromWei(wei) {
761
+ return Number(wei) / 1e9;
762
+ }
763
+ function chainDetailWithFetchedGas(chainDetail, live) {
764
+ if (chainDetail.legacy) {
765
+ if (chainDetail.gasPrice != null && chainDetail.gasPrice > 0) return chainDetail;
766
+ if (live.gasPriceWei == null) return chainDetail;
767
+ return { ...chainDetail, gasPrice: gweiFromWei(live.gasPriceWei) };
768
+ }
769
+ if (chainDetail.baseFee != null && chainDetail.baseFee > 0) return chainDetail;
770
+ return {
771
+ ...chainDetail,
772
+ baseFee: live.baseFeeWei != null ? gweiFromWei(live.baseFeeWei) : chainDetail.baseFee,
773
+ priorityFee: chainDetail.priorityFee != null && chainDetail.priorityFee > 0 ? chainDetail.priorityFee : live.priorityFeeWei != null ? gweiFromWei(live.priorityFeeWei) : chainDetail.priorityFee
641
774
  };
642
775
  }
776
+ async function chainDetailWithLiveGas(rpcUrl, chainDetail) {
777
+ const needsLive = chainDetail.legacy ? chainDetail.gasPrice == null || chainDetail.gasPrice <= 0 : chainDetail.baseFee == null || chainDetail.baseFee <= 0;
778
+ if (!needsLive) return chainDetail;
779
+ const client = createPublicClient({ transport: defiEvmTransport(rpcUrl) });
780
+ if (chainDetail.legacy) {
781
+ const gasPriceWei = await client.getGasPrice();
782
+ return chainDetailWithFetchedGas(chainDetail, { gasPriceWei });
783
+ }
784
+ const block = await client.getBlock({ blockTag: "latest" });
785
+ let priorityFeeWei = 0n;
786
+ try {
787
+ priorityFeeWei = await client.estimateMaxPriorityFeePerGas();
788
+ } catch {
789
+ priorityFeeWei = 1000000000n;
790
+ }
791
+ return chainDetailWithFetchedGas(chainDetail, {
792
+ baseFeeWei: block.baseFeePerGas ?? 0n,
793
+ priorityFeeWei
794
+ });
795
+ }
796
+ function broadcasterTokenFee(feePerUnitGas, gasDetails) {
797
+ const price = gasDetails.evmGasType === EVMGasType.Type2 || gasDetails.evmGasType === EVMGasType.Type4 ? gasDetails.maxFeePerGas : gasDetails.gasPrice;
798
+ const maximumGas = gasDetails.gasEstimate * 12000n / 10000n * price;
799
+ return feePerUnitGas * maximumGas / 10n ** 18n;
800
+ }
643
801
 
644
802
  // src/protocols/evm/railgun/shield.ts
645
803
  var erc20Abi = parseAbi([
@@ -659,6 +817,42 @@ async function shieldSignatureMessage() {
659
817
  const wallet = await import('@railgun-community/wallet');
660
818
  return wallet.getShieldPrivateKeySignatureMessage();
661
819
  }
820
+ async function shieldPrivateKeyFromSignature(signature, executorAddress) {
821
+ return keccak256(await canonicalRailgunShieldSignature(signature, executorAddress));
822
+ }
823
+ async function canonicalRailgunShieldSignature(signature, executorAddress) {
824
+ const body = signature.trim().replace(/^0x/i, "").toLowerCase();
825
+ if (!/^[0-9a-f]+$/.test(body) || body.length % 2 !== 0) {
826
+ throw new Error("Shield-key signature must be hex.");
827
+ }
828
+ const rs = body.length === 130 ? body.slice(0, 128) : body;
829
+ if (rs.length !== 128) {
830
+ throw new Error("Shield-key signature must be the 65-byte personal_sign of RAILGUN_SHIELD.");
831
+ }
832
+ const stated = body.length === 130 ? body.slice(128) : "";
833
+ if (!executorAddress) {
834
+ if (stated === "00") return `0x${rs}1b`;
835
+ if (stated === "01") return `0x${rs}1c`;
836
+ if (body.length === 130) return `0x${body}`;
837
+ throw new Error("Shield-key signature must be the 65-byte personal_sign of RAILGUN_SHIELD.");
838
+ }
839
+ const preferred = stated === "01" || stated === "1c" ? ["1c", "1b"] : ["1b", "1c"];
840
+ const hash = hashMessage("RAILGUN_SHIELD");
841
+ const executor = getAddress(executorAddress).toLowerCase();
842
+ for (const v of preferred) {
843
+ const candidate = `0x${rs}${v}`;
844
+ try {
845
+ const recovered = await recoverAddress({ hash, signature: candidate });
846
+ if (recovered.toLowerCase() === executor) return candidate;
847
+ } catch {
848
+ continue;
849
+ }
850
+ }
851
+ if (body.length === 130 && stated !== "00" && stated !== "01" && stated !== "1b" && stated !== "1c") {
852
+ return `0x${body}`;
853
+ }
854
+ throw new Error("Shield-key signature does not recover to this KeyGen address.");
855
+ }
662
856
  function personalSignBody(args, message) {
663
857
  const executor = getAddress(args.executorAddress);
664
858
  const digest = hashMessage(message);
@@ -748,6 +942,7 @@ async function buildEvmMultisignBodyRailgunShield(args) {
748
942
  const amount = BigInt(args.amountWei);
749
943
  if (amount <= 0n) throw new Error("Amount must be greater than zero.");
750
944
  const executor = getAddress(args.executorAddress);
945
+ const shieldPrivateKey = await shieldPrivateKeyFromSignature(signature, executor);
751
946
  const spender = getAddress(network.proxyContract);
752
947
  const rpcUrl = args.rpcUrl.trim();
753
948
  if (!rpcUrl) throw new Error("rpcUrl is required.");
@@ -790,7 +985,7 @@ async function buildEvmMultisignBodyRailgunShield(args) {
790
985
  const populated = await populate(
791
986
  TXIDVersion.V2_PoseidonMerkle,
792
987
  networkName,
793
- signature,
988
+ shieldPrivateKey,
794
989
  [{ tokenAddress, amount, recipientAddress: args.railgunAddress.trim() }],
795
990
  [],
796
991
  railgunGasDetails(args.chainDetail, 1200000n)
@@ -825,6 +1020,105 @@ async function buildEvmMultisignBodyRailgunShield(args) {
825
1020
  })
826
1021
  });
827
1022
  }
1023
+
1024
+ // src/chains/evm/coingecko.ts
1025
+ var COINGECKO_PLATFORM_BY_CHAIN_ID = {
1026
+ "1": "ethereum",
1027
+ "10": "optimistic-ethereum",
1028
+ "30": "rootstock",
1029
+ "56": "binance-smart-chain",
1030
+ "100": "xdai",
1031
+ "137": "polygon-pos",
1032
+ "146": "sonic",
1033
+ "169": "manta-pacific",
1034
+ "196": "x-layer",
1035
+ "204": "op-bnb",
1036
+ "239": "tac",
1037
+ "250": "fantom",
1038
+ "252": "fraxtal",
1039
+ "288": "boba",
1040
+ "324": "zk-sync-era",
1041
+ "480": "world-chain",
1042
+ "999": "hyperevm",
1043
+ "1088": "metis-andromeda",
1044
+ "1101": "polygon-zkevm",
1045
+ "1116": "core",
1046
+ "1135": "lisk",
1047
+ "130": "unichain",
1048
+ "1868": "soneium",
1049
+ "1923": "swellchain",
1050
+ "2222": "kava",
1051
+ "2818": "morph-l2",
1052
+ "34443": "mode",
1053
+ "42161": "arbitrum-one",
1054
+ "42220": "celo",
1055
+ "43114": "avalanche",
1056
+ "4663": "robinhood",
1057
+ "5042": "arc",
1058
+ "48900": "zircuit",
1059
+ "5000": "mantle",
1060
+ "534352": "scroll",
1061
+ "57073": "ink",
1062
+ "59144": "linea",
1063
+ "60808": "bob-network",
1064
+ "80084": "berachain",
1065
+ "80094": "berachain",
1066
+ "81457": "blast",
1067
+ "8453": "base",
1068
+ "9745": "plasma",
1069
+ "167000": "taiko",
1070
+ "7777777": "zora"
1071
+ };
1072
+ function coingeckoPlatformForChainId(chainId) {
1073
+ return COINGECKO_PLATFORM_BY_CHAIN_ID[String(chainId).trim()];
1074
+ }
1075
+
1076
+ // src/protocols/evm/railgun/broadcaster-choice.ts
1077
+ var FEE_RATIO_ONE_TO_ONE_MILLI = 1e3;
1078
+ function feeRatioMilli(feePerUnitGas, tokenDecimals, tokenUsd, nativeUsd) {
1079
+ if (!Number.isInteger(tokenDecimals) || tokenDecimals < 0 || tokenDecimals > 36) return null;
1080
+ if (!(tokenUsd > 0) || !(nativeUsd > 0) || !Number.isFinite(tokenUsd) || !Number.isFinite(nativeUsd)) return null;
1081
+ const tokenMicro = BigInt(Math.round(tokenUsd * 1e6));
1082
+ const nativeMicro = BigInt(Math.round(nativeUsd * 1e6));
1083
+ if (tokenMicro <= 0n || nativeMicro <= 0n) return null;
1084
+ const denom = 10n ** BigInt(tokenDecimals) * nativeMicro;
1085
+ if (denom <= 0n) return null;
1086
+ const milli = feePerUnitGas * tokenMicro * 1000n / denom;
1087
+ if (milli > BigInt(Number.MAX_SAFE_INTEGER)) return null;
1088
+ return Number(milli);
1089
+ }
1090
+ function formatFeeRatioMilli(milli) {
1091
+ const abs = Math.abs(Math.trunc(milli));
1092
+ const whole = Math.floor(abs / 1e3);
1093
+ const frac = String(abs % 1e3).padStart(3, "0").slice(0, 2);
1094
+ return `${milli < 0 ? "-" : ""}${whole}.${frac}`;
1095
+ }
1096
+ function coversGasCost(ratioMilli) {
1097
+ return ratioMilli != null && ratioMilli >= FEE_RATIO_ONE_TO_ONE_MILLI;
1098
+ }
1099
+ function orderBroadcasterChoices(rows) {
1100
+ return [...rows].sort((left, right) => {
1101
+ if (left.coversGas !== right.coversGas) return left.coversGas ? -1 : 1;
1102
+ if (left.ratioMilli != null && right.ratioMilli != null && left.ratioMilli !== right.ratioMilli) {
1103
+ return left.coversGas ? left.ratioMilli - right.ratioMilli : right.ratioMilli - left.ratioMilli;
1104
+ }
1105
+ if (left.ratioMilli == null && right.ratioMilli != null) return 1;
1106
+ if (left.ratioMilli != null && right.ratioMilli == null) return -1;
1107
+ if (left.feePerUnitGas !== right.feePerUnitGas) return left.feePerUnitGas < right.feePerUnitGas ? -1 : 1;
1108
+ return right.reliability - left.reliability;
1109
+ });
1110
+ }
1111
+ function defaultBroadcasterChoice(rows) {
1112
+ return rows.find((row) => row.coversGas) ?? rows[0];
1113
+ }
1114
+ function nextCoveringBroadcaster(rows, refused) {
1115
+ return rows.find((row) => row.coversGas && !refused.has(row.feesId));
1116
+ }
1117
+ function isBroadcasterGasRefusal(message) {
1118
+ return /gas cost is too high/i.test(message);
1119
+ }
1120
+
1121
+ // src/protocols/evm/railgun/private-actions.ts
828
1122
  function chainFor(chainId) {
829
1123
  return { type: ChainType.EVM, id: chainId };
830
1124
  }
@@ -838,6 +1132,53 @@ function parsedAmount(amountWei) {
838
1132
  if (amount <= 0n) throw new Error("Amount must be greater than zero.");
839
1133
  return amount;
840
1134
  }
1135
+ function railgunDetailedError(err) {
1136
+ const seen = /* @__PURE__ */ new Set();
1137
+ let current = err;
1138
+ let fallback = "Railgun spend proof failed.";
1139
+ while (current != null && !seen.has(current)) {
1140
+ seen.add(current);
1141
+ if (!(current instanceof Error)) break;
1142
+ const message = current.message.trim();
1143
+ if (message && /amount required|balance:/i.test(message)) return message;
1144
+ if (message) fallback = message;
1145
+ current = current.cause;
1146
+ }
1147
+ return fallback;
1148
+ }
1149
+ function broadcasterAskedToRefreshProofStatus(err) {
1150
+ const message = railgunDetailedError(err);
1151
+ return message.includes(BROADCASTER_POI_INCOMPLETE) || /could not validate proof of innocence/i.test(message) || /failed to validate poi merkleroots/i.test(message) || /invalid poi merkleroots/i.test(message);
1152
+ }
1153
+ function broadcasterRefusedForGas(err) {
1154
+ return isBroadcasterGasRefusal(railgunDetailedError(err));
1155
+ }
1156
+ function chainForNetwork(networkName) {
1157
+ return NETWORK_CONFIG[networkName].chain;
1158
+ }
1159
+ async function refreshSpendingProofStatus(networkName, walletId) {
1160
+ const wallet = await import('@railgun-community/wallet');
1161
+ const chain = chainForNetwork(networkName);
1162
+ wallet.resumePPOIBatching(chain);
1163
+ await wallet.walletForID(walletId).refreshPOIsForTXIDVersion(chain, TXIDVersion.V2_PoseidonMerkle, true);
1164
+ }
1165
+ async function assertTokenSpendable(networkName, walletId, tokenAddress, amount) {
1166
+ const wallet = await import('@railgun-community/wallet');
1167
+ const balance = await wallet.walletForID(walletId).getBalanceERC20(
1168
+ TXIDVersion.V2_PoseidonMerkle,
1169
+ chainForNetwork(networkName),
1170
+ tokenAddress,
1171
+ ["Spendable"]
1172
+ );
1173
+ if ((balance ?? 0n) < amount) {
1174
+ throw new Error(
1175
+ "These notes are still waiting for proof-of-innocence. The private transfer can start once that status is spendable."
1176
+ );
1177
+ }
1178
+ }
1179
+ function delay(ms) {
1180
+ return new Promise((resolve) => setTimeout(resolve, ms));
1181
+ }
841
1182
  async function broadcasterClient() {
842
1183
  try {
843
1184
  return await import('@railgun-community/waku-broadcaster-client-web');
@@ -845,40 +1186,340 @@ async function broadcasterClient() {
845
1186
  throw new Error("Railgun broadcaster client is not available.");
846
1187
  }
847
1188
  }
848
- async function selectedBroadcaster(chain, tokenAddress) {
1189
+ var BROADCASTER_CACHE_MS = 6e4;
1190
+ var BROADCASTER_QUOTE_WAIT_MS = 2e4;
1191
+ var BROADCASTER_PROOF_WAIT_MS = 3 * 60 * 1e3;
1192
+ var BROADCASTER_FEE_MIN_REMAINING_MS = 4e4;
1193
+ var BROADCASTER_PROOF_ATTEMPTS = 3;
1194
+ var POI_STATUS_RETRY_MS = 2e4;
1195
+ var POI_ROOT_WAIT_MS = 3 * 60 * 1e3;
1196
+ var BROADCASTER_POI_INCOMPLETE = "Proof-of-innocence validation did not complete";
1197
+ function broadcasterFeeStillValid(expiration, now = Date.now()) {
1198
+ return Number.isFinite(expiration) && expiration >= now + BROADCASTER_FEE_MIN_REMAINING_MS;
1199
+ }
1200
+ var BROADCASTER_LIST_CACHE_MS = 15e3;
1201
+ var MAX_GAS_BROADCASTERS = 3;
1202
+ var NATIVE_COINGECKO_ID = {
1203
+ 1: "ethereum",
1204
+ 10: "ethereum",
1205
+ 56: "binancecoin",
1206
+ 100: "xdai",
1207
+ 137: "polygon-ecosystem-token",
1208
+ 250: "fantom",
1209
+ 324: "ethereum",
1210
+ 1101: "ethereum",
1211
+ 5e3: "mantle",
1212
+ 8453: "ethereum",
1213
+ 34443: "ethereum",
1214
+ 42161: "ethereum",
1215
+ 43114: "avalanche-2",
1216
+ 59144: "ethereum",
1217
+ 81457: "ethereum",
1218
+ 534352: "ethereum",
1219
+ 167e3: "ethereum"
1220
+ };
1221
+ var broadcasterListCache = /* @__PURE__ */ new Map();
1222
+ var tokenDecimalsCache = /* @__PURE__ */ new Map();
1223
+ var tokenUsdCache = /* @__PURE__ */ new Map();
1224
+ async function ensureBroadcasterWaku(chain) {
849
1225
  const { WakuBroadcasterClient } = await broadcasterClient();
850
1226
  if (!WakuBroadcasterClient.isStarted()) {
851
1227
  await WakuBroadcasterClient.start(chain, { useDNSDiscovery: true }, () => void 0);
852
1228
  } else {
853
1229
  await WakuBroadcasterClient.setChain(chain);
854
1230
  }
855
- const deadline = Date.now() + 2e4;
856
- let selected;
1231
+ }
1232
+ async function findTokenBroadcasters(chain, tokenAddress) {
1233
+ const node = await nodeRequire();
1234
+ if (!node) throw new Error("Railgun broadcasters are listed from the node.");
1235
+ const entry = node.require.resolve("@railgun-community/waku-broadcaster-client-web");
1236
+ const loaded = node.require(node.path.join(node.path.dirname(entry), "search/best-broadcaster.js"));
1237
+ return loaded.BroadcasterSearch.findBroadcastersForToken(chain, tokenAddress, false) ?? [];
1238
+ }
1239
+ async function erc20Decimals(rpcUrl, tokenAddress) {
1240
+ const key = tokenAddress.toLowerCase();
1241
+ const cached2 = tokenDecimalsCache.get(key);
1242
+ if (cached2 != null) return cached2;
1243
+ const client = createPublicClient({ transport: defiEvmTransport(rpcUrl) });
1244
+ const decimals = await client.readContract({
1245
+ address: getAddress(tokenAddress),
1246
+ abi: parseAbi(["function decimals() view returns (uint8)"]),
1247
+ functionName: "decimals"
1248
+ });
1249
+ const value = Number(decimals);
1250
+ if (!Number.isInteger(value) || value < 0 || value > 36) throw new Error("Token decimals are not available.");
1251
+ tokenDecimalsCache.set(key, value);
1252
+ return value;
1253
+ }
1254
+ async function readUsd(url) {
1255
+ try {
1256
+ const response = await fetch(url, { signal: AbortSignal.timeout(8e3) });
1257
+ if (!response.ok) return null;
1258
+ const body = await response.json();
1259
+ for (const row of Object.values(body)) {
1260
+ if (typeof row?.usd === "number" && row.usd > 0) return row.usd;
1261
+ }
1262
+ } catch {
1263
+ return null;
1264
+ }
1265
+ return null;
1266
+ }
1267
+ async function broadcasterUsdPrices(chainId, tokenAddress, wrappedAddress) {
1268
+ const key = `${chainId}:${tokenAddress.toLowerCase()}`;
1269
+ const cached2 = tokenUsdCache.get(key);
1270
+ if (cached2 && Date.now() - cached2.at < 6e4) return cached2;
1271
+ const nativeId = NATIVE_COINGECKO_ID[chainId];
1272
+ const nativeUsd = nativeId ? await readUsd(`https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(nativeId)}&vs_currencies=usd`) : null;
1273
+ const wrapped = wrappedAddress.toLowerCase() === tokenAddress.toLowerCase();
1274
+ let tokenUsd = wrapped ? nativeUsd : null;
1275
+ if (!wrapped) {
1276
+ const platform = coingeckoPlatformForChainId(chainId);
1277
+ tokenUsd = platform ? await readUsd(
1278
+ `https://api.coingecko.com/api/v3/simple/token_price/${encodeURIComponent(platform)}?contract_addresses=${encodeURIComponent(tokenAddress)}&vs_currencies=usd`
1279
+ ) : null;
1280
+ }
1281
+ const prices = { tokenUsd, nativeUsd };
1282
+ tokenUsdCache.set(key, { at: Date.now(), ...prices });
1283
+ return prices;
1284
+ }
1285
+ async function loadRailgunBroadcasters(args) {
1286
+ const tokenAddress = getAddress(args.tokenAddress);
1287
+ const cacheKey = `${args.kind}:${args.chainId}:${tokenAddress.toLowerCase()}`;
1288
+ const cached2 = broadcasterListCache.get(cacheKey);
1289
+ if (cached2 && Date.now() - cached2.at < BROADCASTER_LIST_CACHE_MS) return cached2.rows;
1290
+ const chain = chainFor(args.chainId);
1291
+ await ensureBroadcasterWaku(chain);
1292
+ const deadline = Date.now() + (args.waitMs ?? BROADCASTER_QUOTE_WAIT_MS);
1293
+ let found = [];
1294
+ while (Date.now() < deadline) {
1295
+ found = (await findTokenBroadcasters(chain, tokenAddress)).filter((row) => broadcasterFeeStillValid(row.tokenFee.expiration));
1296
+ if (found.length > 0) break;
1297
+ await delay(1e3);
1298
+ }
1299
+ if (found.length === 0) throw new Error("No Railgun broadcaster is available for this token.");
1300
+ const network = await railgunNetworkByChainId(args.chainId);
1301
+ const [decimals, prices, priced] = await Promise.all([
1302
+ erc20Decimals(args.rpcUrl, tokenAddress),
1303
+ broadcasterUsdPrices(args.chainId, tokenAddress, network?.wrappedAddress ?? ""),
1304
+ chainDetailWithLiveGas(args.rpcUrl, args.chainDetail)
1305
+ ]);
1306
+ const networkName = network?.networkName ?? "";
1307
+ const gasEstimate = args.kind === "unshield" ? RAILGUN_UNSHIELD_GAS_ESTIMATE : RAILGUN_TRANSFER_GAS_ESTIMATE;
1308
+ const gasDetails = railgunBroadcasterGasDetails(networkName, priced, gasEstimate);
1309
+ const byFeesId = /* @__PURE__ */ new Map();
1310
+ for (const selected of found) {
1311
+ const feesId = selected.tokenFee.feesID;
1312
+ if (!feesId) continue;
1313
+ const feePerUnitGas = BigInt(selected.tokenFee.feePerUnitGas);
1314
+ const ratioMilli = prices.tokenUsd != null && prices.nativeUsd != null ? feeRatioMilli(feePerUnitGas, decimals, prices.tokenUsd, prices.nativeUsd) : null;
1315
+ const row = {
1316
+ feesId,
1317
+ railgunAddress: selected.railgunAddress,
1318
+ feeWei: broadcasterTokenFee(feePerUnitGas, gasDetails).toString(),
1319
+ ratio: ratioMilli == null ? null : formatFeeRatioMilli(ratioMilli),
1320
+ coversGas: coversGasCost(ratioMilli),
1321
+ reliability: Number(selected.tokenFee.reliability) || 0,
1322
+ expiration: selected.tokenFee.expiration,
1323
+ tokenDecimals: decimals,
1324
+ feePerUnitGas,
1325
+ ratioMilli,
1326
+ selected
1327
+ };
1328
+ const previous = byFeesId.get(feesId);
1329
+ if (!previous || row.reliability > previous.reliability) byFeesId.set(feesId, row);
1330
+ }
1331
+ const rows = orderBroadcasterChoices([...byFeesId.values()]);
1332
+ broadcasterListCache.set(cacheKey, { at: Date.now(), rows });
1333
+ return rows;
1334
+ }
1335
+ async function listRailgunBroadcasters(args) {
1336
+ const kind = args.kind.trim();
1337
+ if (kind !== "transfer" && kind !== "unshield") throw new Error("kind must be transfer or unshield.");
1338
+ const rows = await loadRailgunBroadcasters({ ...args, kind });
1339
+ return rows.map((row) => ({
1340
+ feesId: row.feesId,
1341
+ railgunAddress: row.railgunAddress,
1342
+ feeWei: row.feeWei,
1343
+ ratio: row.ratio,
1344
+ coversGas: row.coversGas,
1345
+ reliability: row.reliability,
1346
+ expiration: row.expiration,
1347
+ tokenDecimals: row.tokenDecimals
1348
+ }));
1349
+ }
1350
+ function broadcasterNote(row, switching) {
1351
+ const ratioLabel = row.ratio ? `${row.ratio}:1` : "the selected fee";
1352
+ return switching ? `Trying the next broadcaster at ${ratioLabel}` : `Broadcaster ${ratioLabel}`;
1353
+ }
1354
+ function poiListsPresent(pois) {
1355
+ return Object.values(pois).some((leaves) => Object.keys(leaves).length > 0);
1356
+ }
1357
+ function poiRoots(poi) {
1358
+ if (!poi || typeof poi !== "object" || !("poiMerkleroots" in poi)) return [];
1359
+ const roots = poi.poiMerkleroots;
1360
+ return Array.isArray(roots) ? roots.filter((root) => typeof root === "string") : [];
1361
+ }
1362
+ async function nodeRequire() {
1363
+ if (typeof window !== "undefined") return null;
1364
+ if (typeof process === "undefined" || !process.versions?.node) return null;
1365
+ const load = new Function("id", "return import(id)");
1366
+ const nodeModule = await load("node:module");
1367
+ const nodePath = await load("node:path");
1368
+ return { require: nodeModule.createRequire(import.meta.url), path: nodePath };
1369
+ }
1370
+ async function poiRootValidator() {
1371
+ const node = await nodeRequire();
1372
+ if (!node) throw new Error("Proof-of-innocence checks need the node Railgun engine.");
1373
+ const walletEntry = node.require.resolve("@railgun-community/wallet");
1374
+ const engineEntry = node.require.resolve("@railgun-community/engine", {
1375
+ paths: [node.path.dirname(walletEntry)]
1376
+ });
1377
+ const loaded = node.require(engineEntry);
1378
+ if (!loaded.POI) throw new Error("The Railgun engine did not load its proof-of-innocence checker.");
1379
+ return loaded.POI;
1380
+ }
1381
+ async function poiMerklerootsConfirmed(chain, pois) {
1382
+ const poi = await poiRootValidator();
1383
+ for (const [listKey, leaves] of Object.entries(pois)) {
1384
+ for (const entry of Object.values(leaves)) {
1385
+ const roots = poiRoots(entry);
1386
+ if (roots.length === 0) return false;
1387
+ const confirmed = await poi.validatePOIMerkleroots(TXIDVersion.V2_PoseidonMerkle, chain, listKey, roots);
1388
+ if (!confirmed) return false;
1389
+ }
1390
+ }
1391
+ return true;
1392
+ }
1393
+ async function waitForConfirmedPoiRoots(chain, proved, onProofStatusRefresh) {
1394
+ const pois = proved.populated.preTransactionPOIsPerTxidLeafPerList;
1395
+ if (!poiListsPresent(pois)) {
1396
+ throw new Error("The proof did not include a proof of innocence.");
1397
+ }
1398
+ const deadline = Date.now() + POI_ROOT_WAIT_MS;
857
1399
  while (Date.now() < deadline) {
858
- selected = await WakuBroadcasterClient.findBestBroadcaster(chain, tokenAddress, false);
859
- if (selected) return selected;
860
- await new Promise((resolve) => setTimeout(resolve, 1e3));
1400
+ try {
1401
+ if (await poiMerklerootsConfirmed(chain, pois)) return;
1402
+ } catch (err) {
1403
+ if (!broadcasterAskedToRefreshProofStatus(err)) throw err;
1404
+ }
1405
+ onProofStatusRefresh?.();
1406
+ await delay(POI_STATUS_RETRY_MS);
1407
+ }
1408
+ throw new Error("The proof-of-innocence list has not confirmed these notes. Wait a few minutes and try the private transfer again.");
1409
+ }
1410
+ async function submitFreshProof(args) {
1411
+ if (args.refreshProofStatus) {
1412
+ args.onProofStatusRefresh?.();
1413
+ await args.refreshProofStatus().catch(() => void 0);
1414
+ }
1415
+ await args.assertSpendable?.();
1416
+ const refused = /* @__PURE__ */ new Set();
1417
+ let feesId = args.broadcasterFeesId?.trim() ?? "";
1418
+ let lastError = new Error("No Railgun broadcaster is available for this token.");
1419
+ let builds = 0;
1420
+ while (builds < MAX_GAS_BROADCASTERS) {
1421
+ args.onBroadcasterSearch?.();
1422
+ const rows = await loadRailgunBroadcasters({
1423
+ chainId: args.chainId,
1424
+ rpcUrl: args.rpcUrl,
1425
+ chainDetail: args.chainDetail,
1426
+ tokenAddress: args.tokenAddress,
1427
+ kind: args.kind,
1428
+ waitMs: BROADCASTER_PROOF_WAIT_MS
1429
+ });
1430
+ const requested = feesId && !refused.has(feesId) ? rows.find((row2) => row2.feesId === feesId) : void 0;
1431
+ if (builds === 0 && args.broadcasterFeesId?.trim() && !requested) {
1432
+ throw new Error("That broadcaster fee expired. Refresh the list and choose again.");
1433
+ }
1434
+ const row = requested ?? nextCoveringBroadcaster(rows, refused) ?? (refused.size === 0 ? defaultBroadcasterChoice(rows) : void 0);
1435
+ if (!row || !broadcasterFeeStillValid(row.expiration)) {
1436
+ if (!row) break;
1437
+ refused.add(row.feesId);
1438
+ feesId = "";
1439
+ if (refused.size >= rows.length) break;
1440
+ continue;
1441
+ }
1442
+ builds += 1;
1443
+ feesId = row.feesId;
1444
+ args.onBroadcasterNote?.(broadcasterNote(row, refused.size > 0));
1445
+ const broadcaster = row.selected;
1446
+ const proved = await args.build(broadcaster);
1447
+ if (!broadcasterFeeStillValid(broadcaster.tokenFee.expiration)) {
1448
+ broadcasterListCache.clear();
1449
+ refused.add(feesId);
1450
+ feesId = "";
1451
+ continue;
1452
+ }
1453
+ await waitForConfirmedPoiRoots(args.chain, proved, args.onProofStatusRefresh);
1454
+ let switchBroadcaster = false;
1455
+ for (let poiTry = 1; poiTry <= BROADCASTER_PROOF_ATTEMPTS && !switchBroadcaster; poiTry += 1) {
1456
+ try {
1457
+ return {
1458
+ txHash: await submitProved({
1459
+ chain: args.chain,
1460
+ populated: proved.populated,
1461
+ broadcaster,
1462
+ overallBatchMinGasPrice: proved.overallBatchMinGasPrice
1463
+ })
1464
+ };
1465
+ } catch (err) {
1466
+ lastError = err;
1467
+ if (broadcasterRefusedForGas(err)) {
1468
+ refused.add(feesId);
1469
+ feesId = "";
1470
+ switchBroadcaster = true;
1471
+ break;
1472
+ }
1473
+ if (poiTry === BROADCASTER_PROOF_ATTEMPTS || !broadcasterAskedToRefreshProofStatus(err)) throw err;
1474
+ args.onProofStatusRefresh?.();
1475
+ await delay(POI_STATUS_RETRY_MS);
1476
+ await args.refreshProofStatus?.().catch(() => void 0);
1477
+ if (!broadcasterFeeStillValid(broadcaster.tokenFee.expiration)) {
1478
+ broadcasterListCache.clear();
1479
+ refused.add(feesId);
1480
+ feesId = "";
1481
+ switchBroadcaster = true;
1482
+ break;
1483
+ }
1484
+ await waitForConfirmedPoiRoots(args.chain, proved, args.onProofStatusRefresh);
1485
+ }
1486
+ }
861
1487
  }
862
- throw new Error("No Railgun broadcaster is available for this token.");
1488
+ if (lastError instanceof Error) throw lastError;
1489
+ throw new Error("The broadcaster fee expired while the proof was building. Try the private transfer again.");
863
1490
  }
864
1491
  async function submitProved(args) {
865
1492
  const to = args.populated.transaction.to;
866
1493
  const data = args.populated.transaction.data;
867
1494
  if (!to || !data) throw new Error("Railgun proof did not produce a transaction.");
868
1495
  const { BroadcasterTransaction } = await broadcasterClient();
869
- const tx = await BroadcasterTransaction.create(
870
- TXIDVersion.V2_PoseidonMerkle,
871
- to,
872
- data,
873
- args.broadcaster.railgunAddress,
874
- args.broadcaster.tokenFee.feesID,
875
- args.chain,
876
- args.populated.nullifiers ?? [],
877
- args.overallBatchMinGasPrice,
878
- false,
879
- args.populated.preTransactionPOIsPerTxidLeafPerList
880
- );
881
- return tx.send();
1496
+ const detail = await broadcasterDetailConfig();
1497
+ const previousDetail = detail?.IS_DEV;
1498
+ if (detail) detail.IS_DEV = true;
1499
+ try {
1500
+ const tx = await BroadcasterTransaction.create(
1501
+ TXIDVersion.V2_PoseidonMerkle,
1502
+ to,
1503
+ data,
1504
+ args.broadcaster.railgunAddress,
1505
+ args.broadcaster.tokenFee.feesID,
1506
+ args.chain,
1507
+ args.populated.nullifiers ?? [],
1508
+ args.overallBatchMinGasPrice,
1509
+ false,
1510
+ args.populated.preTransactionPOIsPerTxidLeafPerList
1511
+ );
1512
+ return tx.send();
1513
+ } finally {
1514
+ if (detail && previousDetail !== void 0) detail.IS_DEV = previousDetail;
1515
+ }
1516
+ }
1517
+ async function broadcasterDetailConfig() {
1518
+ const node = await nodeRequire();
1519
+ if (!node) return null;
1520
+ const entry = node.require.resolve("@railgun-community/waku-broadcaster-client-web");
1521
+ const loaded = node.require(node.path.join(node.path.dirname(entry), "models/broadcaster-config.js"));
1522
+ return loaded.BroadcasterConfig;
882
1523
  }
883
1524
  function feeRecipient(wallet, broadcaster, gasDetails) {
884
1525
  const fee = wallet.calculateBroadcasterFeeERC20Amount(
@@ -894,71 +1535,175 @@ function feeRecipient(wallet, broadcaster, gasDetails) {
894
1535
  recipientAddress: broadcaster.railgunAddress
895
1536
  };
896
1537
  }
897
- async function railgunPrivateTransfer(args) {
898
- assertRailgunCtmAllowed(args.chainId, args.tokenAddress);
899
- await assertDeployed(args.chainId);
1538
+ var broadcasterFeeCache = /* @__PURE__ */ new Map();
1539
+ async function quoteRailgunBroadcasterFee(args) {
900
1540
  const tokenAddress = getAddress(args.tokenAddress);
1541
+ const cacheKey = `${args.kind}:${args.chainId}:${tokenAddress.toLowerCase()}`;
1542
+ const cached2 = broadcasterFeeCache.get(cacheKey);
1543
+ if (cached2 && Date.now() - cached2.at < BROADCASTER_CACHE_MS) {
1544
+ return { feeWei: cached2.feeWei, tokenAddress: cached2.tokenAddress };
1545
+ }
901
1546
  const amount = parsedAmount(args.amountWei);
902
- const recipientAddress = args.recipientAddress.trim();
903
- if (!recipientAddress.startsWith("0zk")) throw new Error("Private transfer recipient must be a 0zk address.");
904
- const session2 = requireRailgunWalletSession();
905
- const rpcUrl = args.rpcUrl?.trim() ?? "";
906
- if (!rpcUrl || !args.chainDetail) throw new Error("rpcUrl and chain details are required.");
907
- const networkName = await ensureRailgunProvider(args.chainId, rpcUrl);
1547
+ const networkName = await ensureRailgunProvider(args.chainId, args.rpcUrl);
908
1548
  const wallet = await import('@railgun-community/wallet');
909
- const chain = chainFor(args.chainId);
910
- const broadcaster = await selectedBroadcaster(chain, tokenAddress);
911
- const recipients = [{ tokenAddress, amount, recipientAddress }];
1549
+ const priced = await chainDetailWithLiveGas(args.rpcUrl, args.chainDetail);
1550
+ const listed = await loadRailgunBroadcasters({
1551
+ chainId: args.chainId,
1552
+ rpcUrl: args.rpcUrl,
1553
+ chainDetail: args.chainDetail,
1554
+ tokenAddress,
1555
+ kind: args.kind
1556
+ });
1557
+ const chosen = defaultBroadcasterChoice(listed);
1558
+ if (!chosen) throw new Error("No Railgun broadcaster is available for this token.");
1559
+ const broadcaster = chosen.selected;
912
1560
  const feeToken = {
913
1561
  tokenAddress: broadcaster.tokenAddress,
914
1562
  feePerUnitGas: BigInt(broadcaster.tokenFee.feePerUnitGas)
915
1563
  };
916
- const original = railgunGasDetails(args.chainDetail, 0n);
917
- const { gasEstimate } = await wallet.gasEstimateForUnprovenTransfer(
1564
+ const original = railgunBroadcasterGasDetails(networkName, priced, 0n);
1565
+ const recipientAddress = args.kind === "unshield" ? getAddress(args.recipientAddress || "0x0000000000000000000000000000000000000001") : args.recipientAddress.trim();
1566
+ if (args.kind === "transfer" && !recipientAddress.startsWith("0zk")) {
1567
+ throw new Error("A 0zk address is required to estimate the private-transfer fee.");
1568
+ }
1569
+ const recipients = [{ tokenAddress, amount, recipientAddress }];
1570
+ const estimated = args.kind === "unshield" ? await wallet.gasEstimateForUnprovenUnshield(
918
1571
  TXIDVersion.V2_PoseidonMerkle,
919
1572
  networkName,
920
- session2.walletId,
921
- session2.encryptionKey,
922
- args.memoText ?? null,
1573
+ args.walletId,
1574
+ args.encryptionKey,
923
1575
  recipients,
924
1576
  [],
925
1577
  original,
926
1578
  feeToken,
927
1579
  false
928
- );
929
- const gasDetails = railgunGasDetails(args.chainDetail, gasEstimate);
930
- const broadcasterFee = feeRecipient(wallet, broadcaster, gasDetails);
931
- const overallBatchMinGasPrice = calculateGasPrice(gasDetails);
932
- await wallet.generateTransferProof(
933
- TXIDVersion.V2_PoseidonMerkle,
934
- networkName,
935
- session2.walletId,
936
- session2.encryptionKey,
937
- false,
938
- args.memoText ?? null,
939
- recipients,
940
- [],
941
- broadcasterFee,
942
- false,
943
- overallBatchMinGasPrice,
944
- (progress) => args.onProofProgress?.(progress)
945
- );
946
- const populated = await wallet.populateProvedTransfer(
1580
+ ) : await wallet.gasEstimateForUnprovenTransfer(
947
1581
  TXIDVersion.V2_PoseidonMerkle,
948
1582
  networkName,
949
- session2.walletId,
950
- false,
1583
+ args.walletId,
1584
+ args.encryptionKey,
951
1585
  args.memoText ?? null,
952
1586
  recipients,
953
1587
  [],
954
- broadcasterFee,
955
- false,
956
- overallBatchMinGasPrice,
957
- gasDetails
1588
+ original,
1589
+ feeToken,
1590
+ false
958
1591
  );
959
- return { txHash: await submitProved({ chain, populated, broadcaster, overallBatchMinGasPrice }) };
1592
+ const fee = feeRecipient(wallet, broadcaster, railgunBroadcasterGasDetails(networkName, priced, estimated.gasEstimate));
1593
+ const quoted = { feeWei: fee.amount.toString(), tokenAddress: getAddress(fee.tokenAddress) };
1594
+ broadcasterFeeCache.set(cacheKey, { at: Date.now(), ...quoted });
1595
+ return quoted;
1596
+ }
1597
+ async function quoteRailgunBroadcasterFeeRate(args) {
1598
+ const tokenAddress = getAddress(args.tokenAddress);
1599
+ const network = await railgunNetworkByChainId(args.chainId);
1600
+ if (!network) throw new Error("Railgun is not deployed on this chain.");
1601
+ const listed = await loadRailgunBroadcasters({
1602
+ chainId: args.chainId,
1603
+ rpcUrl: args.rpcUrl,
1604
+ chainDetail: args.chainDetail,
1605
+ tokenAddress,
1606
+ kind: args.kind
1607
+ });
1608
+ const chosen = defaultBroadcasterChoice(listed);
1609
+ if (!chosen) throw new Error("No Railgun broadcaster is available for this token.");
1610
+ return { feeWei: chosen.feeWei, tokenAddress };
1611
+ }
1612
+ async function railgunPrivateTransfer(args) {
1613
+ try {
1614
+ return await provePrivateTransfer(args);
1615
+ } catch (err) {
1616
+ throw new Error(railgunDetailedError(err));
1617
+ }
1618
+ }
1619
+ async function provePrivateTransfer(args) {
1620
+ assertRailgunCtmAllowed(args.chainId, args.tokenAddress);
1621
+ await assertDeployed(args.chainId);
1622
+ const tokenAddress = getAddress(args.tokenAddress);
1623
+ const amount = parsedAmount(args.amountWei);
1624
+ const recipientAddress = args.recipientAddress.trim();
1625
+ if (!recipientAddress.startsWith("0zk")) throw new Error("Private transfer recipient must be a 0zk address.");
1626
+ const session2 = requireRailgunWalletSession();
1627
+ const rpcUrl = args.rpcUrl?.trim() ?? "";
1628
+ if (!rpcUrl || !args.chainDetail) throw new Error("rpcUrl and chain details are required.");
1629
+ const networkName = await ensureRailgunProvider(args.chainId, rpcUrl);
1630
+ const wallet = await import('@railgun-community/wallet');
1631
+ const chain = chainFor(args.chainId);
1632
+ const priced = await chainDetailWithLiveGas(rpcUrl, args.chainDetail);
1633
+ const recipients = [{ tokenAddress, amount, recipientAddress }];
1634
+ return submitFreshProof({
1635
+ chainId: args.chainId,
1636
+ rpcUrl,
1637
+ chainDetail: args.chainDetail,
1638
+ kind: "transfer",
1639
+ chain,
1640
+ tokenAddress,
1641
+ broadcasterFeesId: args.broadcasterFeesId,
1642
+ onBroadcasterSearch: args.onBroadcasterSearch,
1643
+ onBroadcasterNote: args.onBroadcasterNote,
1644
+ onProofStatusRefresh: args.onProofStatusRefresh,
1645
+ refreshProofStatus: () => refreshSpendingProofStatus(networkName, session2.walletId),
1646
+ assertSpendable: () => assertTokenSpendable(networkName, session2.walletId, tokenAddress, amount),
1647
+ build: async (broadcaster) => {
1648
+ const feeToken = {
1649
+ tokenAddress: broadcaster.tokenAddress,
1650
+ feePerUnitGas: BigInt(broadcaster.tokenFee.feePerUnitGas)
1651
+ };
1652
+ const original = railgunBroadcasterGasDetails(networkName, priced, 0n);
1653
+ const { gasEstimate } = await wallet.gasEstimateForUnprovenTransfer(
1654
+ TXIDVersion.V2_PoseidonMerkle,
1655
+ networkName,
1656
+ session2.walletId,
1657
+ session2.encryptionKey,
1658
+ args.memoText ?? null,
1659
+ recipients,
1660
+ [],
1661
+ original,
1662
+ feeToken,
1663
+ false
1664
+ );
1665
+ const gasDetails = railgunBroadcasterGasDetails(networkName, priced, gasEstimate);
1666
+ const broadcasterFee = feeRecipient(wallet, broadcaster, gasDetails);
1667
+ const overallBatchMinGasPrice = calculateGasPrice(gasDetails);
1668
+ await wallet.generateTransferProof(
1669
+ TXIDVersion.V2_PoseidonMerkle,
1670
+ networkName,
1671
+ session2.walletId,
1672
+ session2.encryptionKey,
1673
+ false,
1674
+ args.memoText ?? null,
1675
+ recipients,
1676
+ [],
1677
+ broadcasterFee,
1678
+ false,
1679
+ overallBatchMinGasPrice,
1680
+ (progress) => args.onProofProgress?.(progress)
1681
+ );
1682
+ const populated = await wallet.populateProvedTransfer(
1683
+ TXIDVersion.V2_PoseidonMerkle,
1684
+ networkName,
1685
+ session2.walletId,
1686
+ false,
1687
+ args.memoText ?? null,
1688
+ recipients,
1689
+ [],
1690
+ broadcasterFee,
1691
+ false,
1692
+ overallBatchMinGasPrice,
1693
+ gasDetails
1694
+ );
1695
+ return { populated, overallBatchMinGasPrice };
1696
+ }
1697
+ });
960
1698
  }
961
1699
  async function railgunUnshield(args) {
1700
+ try {
1701
+ return await proveUnshield(args);
1702
+ } catch (err) {
1703
+ throw new Error(railgunDetailedError(err));
1704
+ }
1705
+ }
1706
+ async function proveUnshield(args) {
962
1707
  assertRailgunCtmAllowed(args.chainId, args.tokenAddress);
963
1708
  await assertDeployed(args.chainId);
964
1709
  const tokenAddress = getAddress(args.tokenAddress);
@@ -970,51 +1715,148 @@ async function railgunUnshield(args) {
970
1715
  const networkName = await ensureRailgunProvider(args.chainId, rpcUrl);
971
1716
  const wallet = await import('@railgun-community/wallet');
972
1717
  const chain = chainFor(args.chainId);
973
- const broadcaster = await selectedBroadcaster(chain, tokenAddress);
1718
+ const priced = await chainDetailWithLiveGas(rpcUrl, args.chainDetail);
974
1719
  const recipients = [{ tokenAddress, amount, recipientAddress }];
975
- const feeToken = {
976
- tokenAddress: broadcaster.tokenAddress,
977
- feePerUnitGas: BigInt(broadcaster.tokenFee.feePerUnitGas)
1720
+ return submitFreshProof({
1721
+ chainId: args.chainId,
1722
+ rpcUrl,
1723
+ chainDetail: args.chainDetail,
1724
+ kind: "unshield",
1725
+ chain,
1726
+ tokenAddress,
1727
+ broadcasterFeesId: args.broadcasterFeesId,
1728
+ onBroadcasterSearch: args.onBroadcasterSearch,
1729
+ onBroadcasterNote: args.onBroadcasterNote,
1730
+ onProofStatusRefresh: args.onProofStatusRefresh,
1731
+ refreshProofStatus: () => refreshSpendingProofStatus(networkName, session2.walletId),
1732
+ assertSpendable: () => assertTokenSpendable(networkName, session2.walletId, tokenAddress, amount),
1733
+ build: async (broadcaster) => {
1734
+ const feeToken = {
1735
+ tokenAddress: broadcaster.tokenAddress,
1736
+ feePerUnitGas: BigInt(broadcaster.tokenFee.feePerUnitGas)
1737
+ };
1738
+ const original = railgunBroadcasterGasDetails(networkName, priced, 0n);
1739
+ const { gasEstimate } = await wallet.gasEstimateForUnprovenUnshield(
1740
+ TXIDVersion.V2_PoseidonMerkle,
1741
+ networkName,
1742
+ session2.walletId,
1743
+ session2.encryptionKey,
1744
+ recipients,
1745
+ [],
1746
+ original,
1747
+ feeToken,
1748
+ false
1749
+ );
1750
+ const gasDetails = railgunBroadcasterGasDetails(networkName, priced, gasEstimate);
1751
+ const broadcasterFee = feeRecipient(wallet, broadcaster, gasDetails);
1752
+ const overallBatchMinGasPrice = calculateGasPrice(gasDetails);
1753
+ await wallet.generateUnshieldProof(
1754
+ TXIDVersion.V2_PoseidonMerkle,
1755
+ networkName,
1756
+ session2.walletId,
1757
+ session2.encryptionKey,
1758
+ recipients,
1759
+ [],
1760
+ broadcasterFee,
1761
+ false,
1762
+ overallBatchMinGasPrice,
1763
+ (progress) => args.onProofProgress?.(progress)
1764
+ );
1765
+ const populated = await wallet.populateProvedUnshield(
1766
+ TXIDVersion.V2_PoseidonMerkle,
1767
+ networkName,
1768
+ session2.walletId,
1769
+ recipients,
1770
+ [],
1771
+ broadcasterFee,
1772
+ false,
1773
+ overallBatchMinGasPrice,
1774
+ gasDetails
1775
+ );
1776
+ return { populated, overallBatchMinGasPrice };
1777
+ }
1778
+ });
1779
+ }
1780
+ var feeAbi = parseAbi([
1781
+ "function shieldFee() view returns (uint256)",
1782
+ "function unshieldFee() view returns (uint256)"
1783
+ ]);
1784
+ var BASIS_POINTS = 10000n;
1785
+ var FEE_CACHE_MS = 6e4;
1786
+ var basisCache = /* @__PURE__ */ new Map();
1787
+ function railgunProtocolFee(amount, basisPoints) {
1788
+ const value = amount > 0n ? amount : 0n;
1789
+ const bps = basisPoints > 0n ? basisPoints : 0n;
1790
+ const fee = value * bps / BASIS_POINTS;
1791
+ return { fee, received: value - fee };
1792
+ }
1793
+ async function protocolBasisPoints(chainId, rpcUrl) {
1794
+ const cached2 = basisCache.get(chainId);
1795
+ if (cached2 && Date.now() - cached2.at < FEE_CACHE_MS) return cached2;
1796
+ const network = await railgunNetworkByChainId(chainId);
1797
+ if (!network) throw new Error("Railgun is not deployed on this chain.");
1798
+ const client = createPublicClient({ transport: defiEvmTransport(rpcUrl) });
1799
+ const address = getAddress(network.proxyContract);
1800
+ const [shield, unshield] = await Promise.all([
1801
+ client.readContract({ address, abi: feeAbi, functionName: "shieldFee" }),
1802
+ client.readContract({ address, abi: feeAbi, functionName: "unshieldFee" })
1803
+ ]);
1804
+ const next = { shield, unshield, at: Date.now() };
1805
+ basisCache.set(chainId, next);
1806
+ return next;
1807
+ }
1808
+ function clearRailgunFeeCache() {
1809
+ basisCache.clear();
1810
+ }
1811
+ async function quoteRailgunFee(args) {
1812
+ const rpcUrl = args.rpcUrl.trim();
1813
+ if (!rpcUrl) throw new Error("rpcUrl is required.");
1814
+ const amount = (() => {
1815
+ try {
1816
+ return BigInt(args.amountWei.trim() || "0");
1817
+ } catch {
1818
+ throw new Error("Amount must be an integer in wei.");
1819
+ }
1820
+ })();
1821
+ if (amount < 0n) throw new Error("Amount must be greater than zero.");
1822
+ const rates = await protocolBasisPoints(args.chainId, rpcUrl);
1823
+ const basisPoints = args.kind === "shield" ? rates.shield : args.kind === "unshield" ? rates.unshield : 0n;
1824
+ const protocol = railgunProtocolFee(amount, basisPoints);
1825
+ let broadcasterFee = 0n;
1826
+ let broadcasterTokenAddress = "";
1827
+ let broadcasterError;
1828
+ if (args.kind !== "shield") {
1829
+ if (!args.chainDetail) {
1830
+ broadcasterError = "Chain gas details are required to estimate the broadcaster fee.";
1831
+ } else {
1832
+ try {
1833
+ const quoted = await quoteRailgunBroadcasterFeeRate({
1834
+ kind: args.kind,
1835
+ chainId: args.chainId,
1836
+ rpcUrl,
1837
+ chainDetail: args.chainDetail,
1838
+ tokenAddress: args.tokenAddress
1839
+ });
1840
+ broadcasterFee = BigInt(quoted.feeWei);
1841
+ broadcasterTokenAddress = quoted.tokenAddress;
1842
+ } catch (err) {
1843
+ broadcasterError = err instanceof Error ? err.message : "Broadcaster fee is not available.";
1844
+ }
1845
+ }
1846
+ }
1847
+ const totalDebit = args.kind === "shield" ? amount : amount + broadcasterFee;
1848
+ const quote = {
1849
+ kind: args.kind,
1850
+ amountWei: amount.toString(),
1851
+ protocolFeeBasisPoints: basisPoints.toString(),
1852
+ protocolFeeWei: protocol.fee.toString(),
1853
+ receivedWei: (args.kind === "transfer" ? amount : protocol.received).toString(),
1854
+ broadcasterFeeWei: broadcasterFee.toString(),
1855
+ broadcasterTokenAddress,
1856
+ totalDebitWei: totalDebit.toString()
978
1857
  };
979
- const original = railgunGasDetails(args.chainDetail, 0n);
980
- const { gasEstimate } = await wallet.gasEstimateForUnprovenUnshield(
981
- TXIDVersion.V2_PoseidonMerkle,
982
- networkName,
983
- session2.walletId,
984
- session2.encryptionKey,
985
- recipients,
986
- [],
987
- original,
988
- feeToken,
989
- false
990
- );
991
- const gasDetails = railgunGasDetails(args.chainDetail, gasEstimate);
992
- const broadcasterFee = feeRecipient(wallet, broadcaster, gasDetails);
993
- const overallBatchMinGasPrice = calculateGasPrice(gasDetails);
994
- await wallet.generateUnshieldProof(
995
- TXIDVersion.V2_PoseidonMerkle,
996
- networkName,
997
- session2.walletId,
998
- session2.encryptionKey,
999
- recipients,
1000
- [],
1001
- broadcasterFee,
1002
- false,
1003
- overallBatchMinGasPrice,
1004
- (progress) => args.onProofProgress?.(progress)
1005
- );
1006
- const populated = await wallet.populateProvedUnshield(
1007
- TXIDVersion.V2_PoseidonMerkle,
1008
- networkName,
1009
- session2.walletId,
1010
- recipients,
1011
- [],
1012
- broadcasterFee,
1013
- false,
1014
- overallBatchMinGasPrice,
1015
- gasDetails
1016
- );
1017
- return { txHash: await submitProved({ chain, populated, broadcaster, overallBatchMinGasPrice }) };
1858
+ if (broadcasterError) quote.broadcasterError = broadcasterError;
1859
+ return quote;
1018
1860
  }
1019
1861
  async function walletModule2() {
1020
1862
  return await import('@railgun-community/wallet');
@@ -1053,6 +1895,16 @@ async function railgunShareableViewingKey(walletId) {
1053
1895
  if (!viewingKey) throw new Error("Railgun viewing key is not available.");
1054
1896
  return viewingKey;
1055
1897
  }
1898
+ async function rememberKeyGenWallet(keyGenId, walletId, railgunAddress) {
1899
+ const root = await resolveRailgunDataRoot();
1900
+ if (!root) return;
1901
+ const load = new Function("id", "return import(id)");
1902
+ const fs = await load("node:fs/promises");
1903
+ const file = railgunKeyGenWalletPath(root, keyGenId);
1904
+ await fs.mkdir(file.slice(0, file.lastIndexOf("/")), { recursive: true });
1905
+ await fs.writeFile(file, `${JSON.stringify({ walletId, railgunAddress })}
1906
+ `, { mode: 384 });
1907
+ }
1056
1908
  async function openRailgunViewOnlyWallet(args) {
1057
1909
  await ensureRailgunEngine();
1058
1910
  const network = await railgunNetworkByChainId(args.chainId);
@@ -1062,6 +1914,7 @@ async function openRailgunViewOnlyWallet(args) {
1062
1914
  args.shareableViewingKey,
1063
1915
  network ? { [network.networkName]: network.deploymentBlock } : void 0
1064
1916
  );
1917
+ if (args.keyGenId) await rememberKeyGenWallet(args.keyGenId, created.id, created.railgunAddress);
1065
1918
  return { walletId: created.id, railgunAddress: created.railgunAddress };
1066
1919
  }
1067
1920
  async function discardRailgunWallet(walletId) {
@@ -1074,11 +1927,15 @@ function railgunAddressIsValid(address) {
1074
1927
  return walletModule2().then((wallet) => wallet.validateRailgunAddress(address));
1075
1928
  }
1076
1929
  var buckets = /* @__PURE__ */ new Map();
1077
- function rowMap(chainId) {
1078
- let rows = buckets.get(chainId);
1930
+ function bucketKey(walletId, chainId) {
1931
+ return `${walletId}:${chainId}`;
1932
+ }
1933
+ function rowMap(walletId, chainId) {
1934
+ const key = bucketKey(walletId, chainId);
1935
+ let rows = buckets.get(key);
1079
1936
  if (!rows) {
1080
1937
  rows = /* @__PURE__ */ new Map();
1081
- buckets.set(chainId, rows);
1938
+ buckets.set(key, rows);
1082
1939
  }
1083
1940
  return rows;
1084
1941
  }
@@ -1091,9 +1948,18 @@ function cellFor(rows, tokenAddress) {
1091
1948
  }
1092
1949
  return cell;
1093
1950
  }
1951
+ function recordRailgunWalletBalance(event) {
1952
+ applyBalanceEvent(event);
1953
+ }
1954
+ function railgunWalletBalanceSnapshot(walletId, chainId) {
1955
+ return snapshotRows(walletId, chainId);
1956
+ }
1957
+ function clearRailgunWalletBalances() {
1958
+ buckets.clear();
1959
+ }
1094
1960
  function applyBalanceEvent(event) {
1095
- if (event.txidVersion !== TXIDVersion.V2_PoseidonMerkle) return;
1096
- const rows = rowMap(event.chain.id);
1961
+ if (!event.railgunWalletID || event.txidVersion !== TXIDVersion.V2_PoseidonMerkle) return;
1962
+ const rows = rowMap(event.railgunWalletID, event.chain.id);
1097
1963
  const spendable = event.balanceBucket === RailgunWalletBalanceBucket.Spendable;
1098
1964
  const pending = event.balanceBucket === RailgunWalletBalanceBucket.ShieldPending || event.balanceBucket === RailgunWalletBalanceBucket.ProofSubmitted || event.balanceBucket === RailgunWalletBalanceBucket.MissingExternalPOI || event.balanceBucket === RailgunWalletBalanceBucket.MissingInternalPOI;
1099
1965
  if (!spendable && !pending) return;
@@ -1109,8 +1975,8 @@ function applyBalanceEvent(event) {
1109
1975
  if (pending) cell.pending.set(event.balanceBucket, amount.amount);
1110
1976
  }
1111
1977
  }
1112
- function snapshotRows(chainId) {
1113
- const rows = buckets.get(chainId);
1978
+ function snapshotRows(walletId, chainId) {
1979
+ const rows = buckets.get(bucketKey(walletId, chainId));
1114
1980
  if (!rows) return [];
1115
1981
  return [...rows.entries()].filter(([, cell]) => cell.spendable > 0n || [...cell.pending.values()].some((amount) => amount > 0n)).map(([tokenAddress, cell]) => ({
1116
1982
  tokenAddress,
@@ -1123,49 +1989,315 @@ function utxoScanTerminalStatus(scanStatus) {
1123
1989
  if (scanStatus === "Incomplete") return "failed";
1124
1990
  return "pending";
1125
1991
  }
1126
- async function scanRailgunPrivateBalances(args) {
1127
- const rpcUrl = args.rpcUrl.trim();
1128
- if (!rpcUrl) throw new Error("rpcUrl is required.");
1129
- buckets.set(args.chainId, /* @__PURE__ */ new Map());
1992
+ var RAILGUN_RESCAN_QUIET_MS = 4e3;
1993
+ var RAILGUN_TXID_WAIT_AFTER_NOTES_MS = 45e3;
1994
+ var RAILGUN_CATCH_UP_MS = 12e4;
1995
+ var RAILGUN_MERKLETREE_STALL_MS = 5 * 60 * 1e3;
1996
+ var RAILGUN_SCAN_BEHIND_NOTE = "The shielded balance is saved. The newest blocks did not finish syncing, so a shield that just landed may be missing until the next scan.";
1997
+ var RAILGUN_SCAN_STOPPED_NOTE = "The note scan stopped before it reached the latest block. The shielded balance is unchanged until a scan finishes.";
1998
+ function merkletreeScanStillRunning(args) {
1999
+ return args.sawProgress && !args.notesFinished && args.terminal === "pending";
2000
+ }
2001
+ function railgunScanRecord(merkletree, balanceCount, caughtUp) {
2002
+ if (merkletree !== "done") {
2003
+ return { scanStatus: "error", notes: RAILGUN_SCAN_STOPPED_NOTE };
2004
+ }
2005
+ if (!caughtUp) {
2006
+ return {
2007
+ scanStatus: balanceCount === 0 ? "empty" : "ready",
2008
+ notes: RAILGUN_SCAN_BEHIND_NOTE
2009
+ };
2010
+ }
2011
+ return {
2012
+ scanStatus: balanceCount === 0 ? "empty" : "ready",
2013
+ notes: balanceCount === 0 ? "No private balance on this chain yet." : ""
2014
+ };
2015
+ }
2016
+ function balancesToKeepAfterNoteScan(decrypted, afterRefresh, previous) {
2017
+ if (afterRefresh.length > 0) return afterRefresh;
2018
+ if (decrypted.length > 0) return decrypted;
2019
+ return previous;
2020
+ }
2021
+ var utxoListeners = /* @__PURE__ */ new Set();
2022
+ var utxoHooked = false;
2023
+ var balanceListeners = /* @__PURE__ */ new Set();
2024
+ var balanceHooked = false;
2025
+ var scansInFlight = /* @__PURE__ */ new Map();
2026
+ function delay2(ms) {
2027
+ return new Promise((resolve) => setTimeout(resolve, ms));
2028
+ }
2029
+ async function hookUtxoScans(wallet) {
2030
+ if (utxoHooked) return;
2031
+ utxoHooked = true;
2032
+ wallet.setOnUTXOMerkletreeScanCallback((event) => {
2033
+ for (const listener of utxoListeners) listener(event);
2034
+ });
2035
+ }
2036
+ async function hookBalanceUpdates(wallet) {
2037
+ if (balanceHooked) return;
2038
+ balanceHooked = true;
2039
+ wallet.setOnBalanceUpdateCallback((event) => {
2040
+ for (const listener of balanceListeners) listener(event);
2041
+ });
2042
+ }
2043
+ function publishScan(chainId, scanStatus, balances, settled, notes) {
2044
+ const text = notes ?? (balances.length === 0 ? "No private balance on this chain yet." : void 0);
2045
+ publishRailgunPrivateBalanceSnapshot({
2046
+ chainId,
2047
+ scanStatus,
2048
+ balances,
2049
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2050
+ notes: text
2051
+ });
2052
+ return { scanStatus, balances, settled, notes: text ?? "" };
2053
+ }
2054
+ async function scanRailgunPrivateBalancesOnce(args) {
2055
+ const previous = snapshotRows(args.walletId, args.chainId);
1130
2056
  publishRailgunPrivateBalanceSnapshot({
1131
2057
  chainId: args.chainId,
1132
2058
  scanStatus: "scanning",
1133
- balances: [],
2059
+ balances: previous,
1134
2060
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1135
2061
  });
1136
2062
  const wallet = await walletModule2();
1137
2063
  let resolveScan = () => {
1138
2064
  };
1139
- const scanSettled = new Promise((resolve) => {
2065
+ let scanSettled = new Promise((resolve) => {
1140
2066
  resolveScan = resolve;
1141
2067
  });
1142
- wallet.setOnBalanceUpdateCallback((event) => {
1143
- if (event.railgunWalletID !== args.walletId) return;
1144
- applyBalanceEvent(event);
2068
+ const armScanWait = () => {
2069
+ scanSettled = new Promise((resolve) => {
2070
+ resolveScan = resolve;
2071
+ });
2072
+ };
2073
+ let sawUtxoEvent = false;
2074
+ let lastUtxoAt = 0;
2075
+ let replacedBuckets = false;
2076
+ let resolveNotesEnd = () => {
2077
+ };
2078
+ const notesEnded = new Promise((resolve) => {
2079
+ resolveNotesEnd = resolve;
1145
2080
  });
1146
- wallet.setOnUTXOMerkletreeScanCallback((event) => {
2081
+ const publishLiveBalances = () => {
2082
+ const rows = snapshotRows(args.walletId, args.chainId);
2083
+ if (rows.length > 0) args.onBalances?.(rows);
2084
+ };
2085
+ let notesFinished = false;
2086
+ const listener = (event) => {
1147
2087
  if (event.chain.id !== args.chainId) return;
2088
+ if (!replacedBuckets) {
2089
+ buckets.set(bucketKey(args.walletId, args.chainId), /* @__PURE__ */ new Map());
2090
+ replacedBuckets = true;
2091
+ }
2092
+ sawUtxoEvent = true;
2093
+ lastUtxoAt = Date.now();
1148
2094
  args.onProgress?.(event.progress);
1149
2095
  const terminal = utxoScanTerminalStatus(event.scanStatus);
2096
+ if (event.progress >= 1 || terminal === "done") {
2097
+ notesFinished = true;
2098
+ publishLiveBalances();
2099
+ resolveNotesEnd();
2100
+ }
1150
2101
  if (terminal !== "pending") resolveScan(terminal);
1151
- });
2102
+ };
2103
+ await hookUtxoScans(wallet);
2104
+ await hookBalanceUpdates(wallet);
2105
+ utxoListeners.add(listener);
2106
+ const onBalance = (event) => {
2107
+ if (event.railgunWalletID !== args.walletId || event.chain.id !== args.chainId) return;
2108
+ recordRailgunWalletBalance(event);
2109
+ publishLiveBalances();
2110
+ };
2111
+ balanceListeners.add(onBalance);
2112
+ try {
2113
+ await ensureRailgunProvider(args.chainId, args.rpcUrl);
2114
+ const chain = { type: ChainType.EVM, id: args.chainId };
2115
+ const refresh = wallet.refreshBalances(chain, [args.walletId]).then(
2116
+ () => "returned",
2117
+ () => "returned"
2118
+ );
2119
+ const quiet = delay2(RAILGUN_RESCAN_QUIET_MS).then(() => "quiet");
2120
+ const notesTimeout = notesEnded.then(() => delay2(RAILGUN_TXID_WAIT_AFTER_NOTES_MS)).then(() => "notes-timeout");
2121
+ let status = await Promise.race([
2122
+ scanSettled,
2123
+ refresh,
2124
+ quiet,
2125
+ notesTimeout
2126
+ ]);
2127
+ if (status === "quiet") {
2128
+ status = await Promise.race([
2129
+ scanSettled,
2130
+ refresh,
2131
+ notesTimeout,
2132
+ delay2(RAILGUN_CATCH_UP_MS).then(() => "catch-up-timeout")
2133
+ ]);
2134
+ }
2135
+ let historyFinished = status === "done" || (status === "notes-timeout" || status === "returned") && notesFinished;
2136
+ const terminal = status === "done" || status === "failed" ? status : "pending";
2137
+ if (!historyFinished && merkletreeScanStillRunning({ sawProgress: sawUtxoEvent, notesFinished, terminal })) {
2138
+ let walked = "stalled";
2139
+ while (walked === "stalled") {
2140
+ const idle = Date.now() - lastUtxoAt;
2141
+ const slice = Math.max(250, RAILGUN_MERKLETREE_STALL_MS - idle);
2142
+ const step = await Promise.race([scanSettled, delay2(slice).then(() => "tick")]);
2143
+ if (step === "done" || step === "failed") {
2144
+ walked = step;
2145
+ break;
2146
+ }
2147
+ if (Date.now() - lastUtxoAt >= RAILGUN_MERKLETREE_STALL_MS) break;
2148
+ }
2149
+ if (walked !== "done") {
2150
+ const rows = snapshotRows(args.walletId, args.chainId);
2151
+ const balances2 = rows.length > 0 ? rows : previous;
2152
+ const record2 = railgunScanRecord("failed", balances2.length, false);
2153
+ return publishScan(args.chainId, record2.scanStatus, balances2, false, record2.notes);
2154
+ }
2155
+ notesFinished = true;
2156
+ historyFinished = true;
2157
+ }
2158
+ if (!historyFinished) {
2159
+ armScanWait();
2160
+ const retry = wallet.refreshBalances(chain, [args.walletId]).then(
2161
+ () => "returned",
2162
+ () => "returned"
2163
+ );
2164
+ const retried = await Promise.race([
2165
+ scanSettled,
2166
+ retry,
2167
+ delay2(RAILGUN_CATCH_UP_MS).then(() => "catch-up-timeout")
2168
+ ]);
2169
+ if (retried !== "done" && !notesFinished) {
2170
+ const rows = snapshotRows(args.walletId, args.chainId);
2171
+ const balances2 = rows.length > 0 ? rows : previous;
2172
+ const record2 = railgunScanRecord("failed", balances2.length, false);
2173
+ return publishScan(args.chainId, record2.scanStatus, balances2, false, record2.notes);
2174
+ }
2175
+ }
2176
+ const decrypted = snapshotRows(args.walletId, args.chainId);
2177
+ publishLiveBalances();
2178
+ const refreshed = await Promise.race([
2179
+ refresh.then(() => true),
2180
+ delay2(RAILGUN_TXID_WAIT_AFTER_NOTES_MS).then(() => false)
2181
+ ]);
2182
+ if (!refreshed) {
2183
+ await Promise.race([refresh.then(() => void 0), delay2(RAILGUN_CATCH_UP_MS).then(() => void 0)]);
2184
+ }
2185
+ sawUtxoEvent = false;
2186
+ armScanWait();
2187
+ const followUpStarted = Date.now();
2188
+ const followUp = wallet.refreshBalances(chain, [args.walletId]).then(
2189
+ () => "returned",
2190
+ () => "returned"
2191
+ );
2192
+ let followStatus = await Promise.race([
2193
+ scanSettled,
2194
+ followUp,
2195
+ delay2(RAILGUN_RESCAN_QUIET_MS).then(() => "quiet"),
2196
+ delay2(RAILGUN_CATCH_UP_MS).then(() => "catch-up-timeout")
2197
+ ]);
2198
+ if ((followStatus === "quiet" || followStatus === "returned") && sawUtxoEvent) {
2199
+ const remaining = Math.max(0, RAILGUN_CATCH_UP_MS - (Date.now() - followUpStarted));
2200
+ followStatus = await Promise.race([
2201
+ scanSettled,
2202
+ delay2(remaining).then(() => "catch-up-timeout")
2203
+ ]);
2204
+ }
2205
+ const caughtUp = followStatus === "done" || followStatus === "quiet" && !sawUtxoEvent;
2206
+ const balances = balancesToKeepAfterNoteScan(decrypted, snapshotRows(args.walletId, args.chainId), previous);
2207
+ const record = railgunScanRecord("done", balances.length, caughtUp);
2208
+ return publishScan(args.chainId, record.scanStatus, balances, refreshed && caughtUp, record.notes || void 0);
2209
+ } finally {
2210
+ utxoListeners.delete(listener);
2211
+ balanceListeners.delete(onBalance);
2212
+ }
2213
+ }
2214
+ var SPEND_DECRYPT_WAIT_MS = 3 * 60 * 1e3;
2215
+ var RAILGUN_SPEND_SCAN_STOPPED = "The spending wallet did not finish scanning private notes.";
2216
+ function spendingScanReadyAfterHistory(progress, scanStatus) {
2217
+ return progress >= 1 || utxoScanTerminalStatus(scanStatus) === "done";
2218
+ }
2219
+ function spendingNoteScanGaveUp(args) {
2220
+ if (args.walletReady || args.notesFinished) return false;
2221
+ if (args.sawProgress) return args.msSinceProgress >= RAILGUN_MERKLETREE_STALL_MS;
2222
+ if (args.msWaitingForDecrypt == null) return false;
2223
+ return args.msWaitingForDecrypt >= SPEND_DECRYPT_WAIT_MS;
2224
+ }
2225
+ async function scanSpendingWalletNotes(args) {
2226
+ const rpcUrl = args.rpcUrl.trim();
2227
+ if (!rpcUrl) throw new Error("rpcUrl is required.");
1152
2228
  await ensureRailgunProvider(args.chainId, rpcUrl);
2229
+ const wallet = await walletModule2();
1153
2230
  const chain = { type: ChainType.EVM, id: args.chainId };
1154
- await wallet.refreshBalances(chain, [args.walletId]);
1155
- if (await scanSettled === "failed") {
1156
- const cause = latestRailgunEngineError();
1157
- throw new Error(cause ? `Railgun note scan stopped before it finished. ${cause}` : "Railgun note scan stopped before it finished.");
2231
+ await hookUtxoScans(wallet);
2232
+ let sawUtxoEvent = false;
2233
+ let lastUtxoAt = Date.now();
2234
+ let notesFinished = false;
2235
+ let walletReady = false;
2236
+ const listener = (event) => {
2237
+ if (event.chain.id !== args.chainId) return;
2238
+ sawUtxoEvent = true;
2239
+ lastUtxoAt = Date.now();
2240
+ if (spendingScanReadyAfterHistory(event.progress, event.scanStatus)) {
2241
+ notesFinished = true;
2242
+ walletReady = true;
2243
+ }
2244
+ args.onProgress?.(event.progress);
2245
+ };
2246
+ utxoListeners.add(listener);
2247
+ const listenForDecrypt = () => {
2248
+ wallet.awaitWalletScan(args.walletId, chain).then(
2249
+ () => {
2250
+ walletReady = true;
2251
+ },
2252
+ () => {
2253
+ listenForDecrypt();
2254
+ }
2255
+ );
2256
+ };
2257
+ listenForDecrypt();
2258
+ let refreshSettled = false;
2259
+ let refreshError = null;
2260
+ void wallet.refreshBalances(chain, [args.walletId]).then(
2261
+ () => {
2262
+ refreshSettled = true;
2263
+ },
2264
+ (err) => {
2265
+ refreshError = err;
2266
+ refreshSettled = true;
2267
+ }
2268
+ );
2269
+ let decryptWaitStarted = null;
2270
+ try {
2271
+ while (!walletReady) {
2272
+ if (refreshError) throw refreshError;
2273
+ const waitingForDecrypt = notesFinished || !sawUtxoEvent && refreshSettled ? decryptWaitStarted ??= Date.now() : null;
2274
+ if (waitingForDecrypt == null) decryptWaitStarted = null;
2275
+ if (spendingNoteScanGaveUp({
2276
+ walletReady,
2277
+ sawProgress: sawUtxoEvent,
2278
+ notesFinished,
2279
+ msSinceProgress: Date.now() - lastUtxoAt,
2280
+ msWaitingForDecrypt: waitingForDecrypt == null ? null : Date.now() - waitingForDecrypt
2281
+ })) {
2282
+ throw new Error(RAILGUN_SPEND_SCAN_STOPPED);
2283
+ }
2284
+ await delay2(250);
2285
+ }
2286
+ } finally {
2287
+ utxoListeners.delete(listener);
1158
2288
  }
1159
- const balances = snapshotRows(args.chainId);
1160
- const scanStatus = balances.length === 0 ? "empty" : "ready";
1161
- publishRailgunPrivateBalanceSnapshot({
1162
- chainId: args.chainId,
1163
- scanStatus,
1164
- balances,
1165
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1166
- notes: balances.length === 0 ? "No private balance on this chain yet." : void 0
2289
+ }
2290
+ async function scanRailgunPrivateBalances(args) {
2291
+ const rpcUrl = args.rpcUrl.trim();
2292
+ if (!rpcUrl) throw new Error("rpcUrl is required.");
2293
+ const key = `${args.chainId}:${args.walletId}`;
2294
+ const existing = scansInFlight.get(key);
2295
+ if (existing) return existing;
2296
+ const run = scanRailgunPrivateBalancesOnce({ ...args, rpcUrl }).finally(() => {
2297
+ if (scansInFlight.get(key) === run) scansInFlight.delete(key);
1167
2298
  });
1168
- return { scanStatus, balances };
2299
+ scansInFlight.set(key, run);
2300
+ return run;
1169
2301
  }
1170
2302
 
1171
2303
  // src/protocols/evm/railgun/index.ts
@@ -1202,7 +2334,16 @@ var railgunProtocolModule = {
1202
2334
  params: {
1203
2335
  tokenAddress: { type: "string", required: true, description: "Shielded ERC-20" },
1204
2336
  amountWei: { type: "string", required: true, description: "Amount in wei" },
1205
- recipientAddress: { type: "string", required: true, description: "Recipient 0zk address" }
2337
+ recipientAddress: {
2338
+ type: "string",
2339
+ required: false,
2340
+ description: "Recipient 0zk address. Omit when toContactName is set."
2341
+ },
2342
+ toContactName: {
2343
+ type: "string",
2344
+ required: false,
2345
+ description: "Address-book contact name. Uses that contact\u2019s saved 0zk address, not their Ethereum address."
2346
+ }
1206
2347
  }
1207
2348
  },
1208
2349
  {
@@ -1221,6 +2362,6 @@ var railgunProtocolModule = {
1221
2362
  };
1222
2363
  registerProtocolModule(railgunProtocolModule);
1223
2364
 
1224
- export { RAILGUN_CTM_DISABLED_MESSAGE, RAILGUN_CTM_ENABLED, RAILGUN_POPULAR_ASSET_LIMIT, RAILGUN_PROTOCOL_ID, RAILGUN_WALLET_LOCKED_MESSAGE, assertRailgunCtmAllowed, buildEvmMultisignBodyRailgunShield, buildEvmMultisignBodyWethUnwrap, clearRailgunNetworkCache, clearRailgunPopularAssetCache, clearRailgunPrivateBalanceSnapshots, commitmentsFromShieldIndex, createRailgunBrowserWallet, discardRailgunWallet, getRailgunWalletSession, loadRailgunNetworks, loadRailgunPopularAssets, loadRailgunSupportedChainIds, openRailgunViewOnlyWallet, publishRailgunPrivateBalanceSnapshot, railgunAddressIsValid, railgunFetchPopularAssets, railgunFetchPrivateBalances, railgunFetchPrivateBalancesSummary, railgunNetworkByChainId, railgunPrivateTransfer, railgunProtocolModule, railgunShareableViewingKey, railgunUnshield, rankPopularShieldTokens, readRailgunPrivateBalanceSnapshot, requireRailgunWalletSession, scanRailgunPrivateBalances, selectRailgunNetworks, setRailgunAllowanceReaderForTests, setRailgunPopulateShieldForTests, setRailgunShieldLogReaderForTests, setRailgunWalletSession, shieldIndexEndpoint, unlockRailgunBrowserWallet, utxoScanTerminalStatus };
2365
+ export { BROADCASTER_FEE_MIN_REMAINING_MS, BROADCASTER_PROOF_WAIT_MS, RAILGUN_CATCH_UP_MS, RAILGUN_CTM_DISABLED_MESSAGE, RAILGUN_CTM_ENABLED, RAILGUN_MERKLETREE_STALL_MS, RAILGUN_POPULAR_ASSET_LIMIT, RAILGUN_PROTOCOL_ID, RAILGUN_SCAN_BEHIND_NOTE, RAILGUN_SCAN_STOPPED_NOTE, RAILGUN_SPEND_SCAN_STOPPED, RAILGUN_TXID_WAIT_AFTER_NOTES_MS, RAILGUN_WALLET_LOCKED_MESSAGE, assertRailgunCtmAllowed, balancesToKeepAfterNoteScan, broadcasterAskedToRefreshProofStatus, broadcasterFeeStillValid, broadcasterRefusedForGas, buildEvmMultisignBodyRailgunShield, buildEvmMultisignBodyWethUnwrap, clearRailgunFeeCache, clearRailgunNetworkCache, clearRailgunPopularAssetCache, clearRailgunPrivateBalanceSnapshots, clearRailgunWalletBalances, commitmentsFromShieldIndex, createRailgunBrowserWallet, discardRailgunWallet, getRailgunWalletSession, listRailgunBroadcasters, loadRailgunNetworks, loadRailgunPopularAssets, loadRailgunSupportedChainIds, merkletreeScanStillRunning, openRailgunViewOnlyWallet, publishRailgunPrivateBalanceSnapshot, quoteRailgunBroadcasterFee, quoteRailgunBroadcasterFeeRate, quoteRailgunFee, railgunAddressIsValid, railgunDetailedError, railgunFetchPopularAssets, railgunFetchPrivateBalances, railgunFetchPrivateBalancesSummary, railgunNetworkByChainId, railgunPrivateTransfer, railgunProtocolFee, railgunProtocolModule, railgunScanRecord, railgunShareableViewingKey, railgunUnshield, railgunWalletBalanceSnapshot, rankPopularShieldTokens, readRailgunPrivateBalanceSnapshot, recordRailgunWalletBalance, requireRailgunWalletSession, scanRailgunPrivateBalances, scanSpendingWalletNotes, selectRailgunNetworks, setRailgunAllowanceReaderForTests, setRailgunPopulateShieldForTests, setRailgunShieldLogReaderForTests, setRailgunWalletSession, shieldIndexEndpoint, shieldPrivateKeyFromSignature, spendingNoteScanGaveUp, spendingScanReadyAfterHistory, unlockRailgunBrowserWallet, utxoScanTerminalStatus };
1225
2366
  //# sourceMappingURL=index.js.map
1226
2367
  //# sourceMappingURL=index.js.map