From cb042228e198f0633b034e8a1ac2ecf58d24f358 Mon Sep 17 00:00:00 2001 From: Aren Date: Wed, 11 Mar 2026 19:39:46 +0400 Subject: [PATCH 1/5] Fix three critical issues with dynamic RPC resolver 1. AtomicProvider now reactively subscribes to rpcConfigStore so changing custom RPC URLs triggers a rerender and polling uses fresh URLs. 2. HTLCClient.getSolverLockDetails restored cross-node agreement check: queries all nodes in parallel and requires matching lock details before accepting, preventing false lock detection from stale/faulty nodes. 3. TON client now respects network-aware RPC configuration: createTonClient(network) is called by getTONDetails and commitTransactionBuilder instead of using a hardcoded singleton. Co-Authored-By: Claude Haiku 4.5 --- apps/app/context/atomicContext.tsx | 12 +- apps/app/helpers/getSettings.ts | 16 ++- .../providers/aztecBalanceProvider.ts | 3 +- .../balances/providers/evmBalanceProvider.ts | 15 +- .../providers/solanaBalanceProvider.ts | 3 +- .../providers/starknetBalanceProvider.ts | 3 +- .../app/lib/gases/providers/evmGasProvider.ts | 5 +- .../lib/gases/providers/solanaGasProvider.ts | 3 +- .../gases/providers/starknetGasProvider.ts | 3 +- apps/app/lib/resolveChain.ts | 5 +- apps/app/lib/rpc/evmNodes.ts | 46 ++++++ apps/app/lib/rpc/fallbackCaller.ts | 20 +++ apps/app/lib/rpc/nodeResolver.ts | 53 +++++++ apps/app/lib/rpc/nonEvmNodes.ts | 44 ++++++ apps/app/lib/rpc/resolveNetworkRpcUrl.ts | 33 +++++ apps/app/lib/rpc/types.ts | 6 + apps/app/lib/wallets/ton/client.ts | 18 ++- apps/app/lib/wallets/ton/getters.ts | 5 +- .../app/lib/wallets/ton/transactionBuilder.ts | 11 +- apps/app/lib/wallets/ton/useAtomicTON.ts | 2 + apps/app/next.config.ts | 2 +- apps/app/package.json | 1 + packages/sdk/src/types/htlc-client.ts | 26 +++- pnpm-lock.yaml | 131 +++++++++++++++--- 24 files changed, 405 insertions(+), 61 deletions(-) create mode 100644 apps/app/lib/rpc/evmNodes.ts create mode 100644 apps/app/lib/rpc/fallbackCaller.ts create mode 100644 apps/app/lib/rpc/nodeResolver.ts create mode 100644 apps/app/lib/rpc/nonEvmNodes.ts create mode 100644 apps/app/lib/rpc/resolveNetworkRpcUrl.ts create mode 100644 apps/app/lib/rpc/types.ts diff --git a/apps/app/context/atomicContext.tsx b/apps/app/context/atomicContext.tsx index 8c4e36b4..eca58e61 100644 --- a/apps/app/context/atomicContext.tsx +++ b/apps/app/context/atomicContext.tsx @@ -62,6 +62,8 @@ type CommitStatesDict = Record; export function AtomicProvider({ children }) { const router = useRouter() const { networks } = useSettingsState() + const rpcConfigs = useRpcConfigStore(s => s.rpcConfigs) + const getEffectiveRpcUrls = useRpcConfigStore(s => s.getEffectiveRpcUrls) const activeHashlock = useSwapStore(s => s.activeHashlock) const updateSwap = useSwapStore(s => s.updateSwap) @@ -84,7 +86,6 @@ export function AtomicProvider({ children }) { useShallow(s => activeHashlock ? s.swaps[activeHashlock] ?? null : null) ) const currentSwap = tempSwap ?? committedSwap - const { getEffectiveRpcUrls } = useRpcConfigStore(); const address = currentSwap?.address const amount = currentSwap?.requestedAmount @@ -252,6 +253,13 @@ export function AtomicProvider({ children }) { onSuccess: handleUserLockSuccess, }) + // Subscribe reactively to rpcConfigs so RPC URL changes trigger a rerender + const destNodeUrls = useMemo( + () => destination_network ? getEffectiveRpcUrls(destination_network) : [], + // eslint-disable-next-line react-hooks/exhaustive-deps + [destination_network, rpcConfigs] + ) + useSolverLockPolling({ network: destination_network, hashlock, @@ -261,7 +269,7 @@ export function AtomicProvider({ children }) { client: destinationClient, solverAddress: destinationSolverAddress, onSuccess: handleSolverLockSuccess, - nodeUrls: destination_network ? getEffectiveRpcUrls(destination_network) : [], + nodeUrls: destNodeUrls, }) // useEffect(() => { diff --git a/apps/app/helpers/getSettings.ts b/apps/app/helpers/getSettings.ts index df6ecff6..74756e7f 100644 --- a/apps/app/helpers/getSettings.ts +++ b/apps/app/helpers/getSettings.ts @@ -2,8 +2,10 @@ import { NetworkContract } from "@/Models/Network"; import TrainApiClient from "../lib/trainApiClient"; import { getThemeData } from "./settingsHelper"; import KnownInternalNames from "@/lib/knownIds"; +import { NodeResolver } from "@/lib/rpc/nodeResolver"; const apiClient = new TrainApiClient() +const nodeResolver = new NodeResolver() export async function getServerSideProps(context) { @@ -19,19 +21,21 @@ export async function getServerSideProps(context) { if (!networks.length) return - const resolvedNetworks = networks.map(network => { + const resolvedNetworks = await Promise.all(networks.map(async network => { const _network = mockData.data.find(n => n.caip2Id === network.caip2Id) + const seedNodes = _network?.nodes ?? [] + const resolvedNodes = await nodeResolver.resolveNodes(network.caip2Id, seedNodes) return { ...network, - nodes: _network?.nodes ?? [], + nodes: resolvedNodes.map(n => ({ providerName: n.providerName, url: n.url })), contracts: (_network?.contracts as NetworkContract[]) ?? [], tokens: network.tokens.map(token => ({ ...token, priceInUsd: prices[`${network.caip2Id}:${token.contractAddress}`], })), } - }) + })) // Inject Starknet Sepolia if the API doesn't return it const hasStarknet = resolvedNetworks.some(n => n.caip2Id === KnownInternalNames.Networks.StarkNetSepolia) @@ -50,7 +54,7 @@ export async function getServerSideProps(context) { decimals: 18, priceInUsd: prices["eip155:11155111:0x0000000000000000000000000000000000000000"], }], - nodes: starknetMock?.nodes ?? [], + nodes: (await nodeResolver.resolveNodes(KnownInternalNames.Networks.StarkNetSepolia, starknetMock?.nodes ?? [])).map(n => ({ providerName: n.providerName, url: n.url })), contracts: (starknetMock?.contracts as NetworkContract[]) ?? [], metadata: [], } as any) @@ -73,7 +77,7 @@ export async function getServerSideProps(context) { decimals: 18, priceInUsd: prices["eip155:11155111:0x0000000000000000000000000000000000000000"], }], - nodes: aztecMock?.nodes ?? [], + nodes: (await nodeResolver.resolveNodes(KnownInternalNames.Networks.AztecDevnet, aztecMock?.nodes ?? [])).map(n => ({ providerName: n.providerName, url: n.url })), contracts: (aztecMock?.contracts as NetworkContract[]) ?? [], metadata: [], } as any) @@ -98,7 +102,7 @@ export async function getServerSideProps(context) { ?? prices["solana:devnet:11111111111111111111111111111111"] ?? 150, }], - nodes: solanaMock?.nodes ?? [], + nodes: (await nodeResolver.resolveNodes(KnownInternalNames.Networks.SolanaDevnet, solanaMock?.nodes ?? [])).map(n => ({ providerName: n.providerName, url: n.url })), contracts: (solanaMock?.contracts as NetworkContract[]) ?? [], metadata: [], } as any) diff --git a/apps/app/lib/balances/providers/aztecBalanceProvider.ts b/apps/app/lib/balances/providers/aztecBalanceProvider.ts index 7411346b..a4b34e53 100644 --- a/apps/app/lib/balances/providers/aztecBalanceProvider.ts +++ b/apps/app/lib/balances/providers/aztecBalanceProvider.ts @@ -6,6 +6,7 @@ import { Fr } from "@aztec/aztec.js/fields"; import { createAztecNodeClient } from "@aztec/aztec.js/node"; import { deriveStorageSlotInMap } from "@aztec/stdlib/hash"; import { formatUnits } from "viem"; +import { getNetworkRpcUrl } from "@/lib/rpc/resolveNetworkRpcUrl"; // Storage slot for public_balances map in the Token contract (slot 9 for standard Aztec token) const TOKEN_PUBLIC_BALANCES_SLOT = new Fr(9n) @@ -18,7 +19,7 @@ export class AztecBalanceProvider extends BalanceProvider { fetchBalance: BalanceProvider['fetchBalance'] = async (address, network) => { if (!address || !network?.tokens) return [] - const nodeUrl = network.nodes?.[0]?.url + const nodeUrl = getNetworkRpcUrl(network) if (!nodeUrl) return [] const client = createAztecNodeClient(nodeUrl) diff --git a/apps/app/lib/balances/providers/evmBalanceProvider.ts b/apps/app/lib/balances/providers/evmBalanceProvider.ts index 18b44b5e..50ce0af6 100644 --- a/apps/app/lib/balances/providers/evmBalanceProvider.ts +++ b/apps/app/lib/balances/providers/evmBalanceProvider.ts @@ -1,5 +1,5 @@ -import { Chain, formatUnits, PublicClient, http } from "viem" +import { Chain, formatUnits, PublicClient, http, fallback } from "viem" import { TokenBalance } from "@/Models/Balance" import { Network, Token, getNativeToken } from "@/Models/Network" import { createConfig } from '@wagmi/core' @@ -10,6 +10,7 @@ import resolveChain from "@/lib/resolveChain" import BalanceGetterAbi from "@/lib/abis/BALANCEGETTERABI.json" import KnownInternalNames from "@/lib/knownIds" import { BalanceProvider } from "@/Models/BalanceProvider" +import { getNetworkRpcUrls, buildNetworkTransport } from "@/lib/rpc/resolveNetworkRpcUrl" export class EVMBalanceProvider extends BalanceProvider { supportsNetwork: BalanceProvider['supportsNetwork'] = (network) => { @@ -38,10 +39,10 @@ export class EVMBalanceProvider extends BalanceProvider { const { createPublicClient } = await import("viem") const publicClient: PublicClient = createPublicClient({ chain, - transport: http(network.nodes?.[0]?.url, { + transport: buildNetworkTransport(network, { timeout: options?.timeoutMs, retryCount: options?.retryCount, - }) + }), }) const erc20Promise = getErc20Balances({ @@ -84,10 +85,10 @@ export class EVMBalanceProvider extends BalanceProvider { const { createPublicClient } = await import("viem") const publicClient = createPublicClient({ chain, - transport: http(network.nodes?.[0]?.url, { + transport: buildNetworkTransport(network, { timeout: options?.timeoutMs, retryCount: options?.retryCount, - }) + }), }) const contract = balanceGetterContracts.find(c => c.networks.includes(network.caip2Id)) @@ -230,7 +231,7 @@ export const getErc20Balances = async ({ const config = createConfig({ chains: [chain], transports: { - [chain.id]: http(network.nodes?.[0]?.url, { timeout: timeoutMs, retryCount }) + [chain.id]: buildNetworkTransport(network, { timeout: timeoutMs, retryCount }) } }) @@ -325,7 +326,7 @@ export const getTokenBalance = async (address: `0x${string}`, network: Network, const config = createConfig({ chains: [chain], transports: { - [chain.id]: http(network.nodes?.[0]?.url, { timeout: timeoutMs, retryCount }) + [chain.id]: buildNetworkTransport(network, { timeout: timeoutMs, retryCount }) } }) diff --git a/apps/app/lib/balances/providers/solanaBalanceProvider.ts b/apps/app/lib/balances/providers/solanaBalanceProvider.ts index aa54b230..fb065611 100644 --- a/apps/app/lib/balances/providers/solanaBalanceProvider.ts +++ b/apps/app/lib/balances/providers/solanaBalanceProvider.ts @@ -2,6 +2,7 @@ import { BalanceProvider } from "@/Models/BalanceProvider"; import { TokenBalance } from "@/Models/Balance"; import { formatUnits } from "viem"; import KnownInternalNames from "@/lib/knownIds"; +import { getNetworkRpcUrl } from "@/lib/rpc/resolveNetworkRpcUrl"; export class SolanaBalanceProvider extends BalanceProvider { supportsNetwork: BalanceProvider['supportsNetwork'] = (network) => { @@ -22,7 +23,7 @@ export class SolanaBalanceProvider extends BalanceProvider { if (!network?.tokens || !walletPublicKey) return const connection = new SolanaConnection( - `${network.nodes?.[0]?.url}`, + getNetworkRpcUrl(network), "confirmed" ); diff --git a/apps/app/lib/balances/providers/starknetBalanceProvider.ts b/apps/app/lib/balances/providers/starknetBalanceProvider.ts index ace1abd7..261c7a6e 100644 --- a/apps/app/lib/balances/providers/starknetBalanceProvider.ts +++ b/apps/app/lib/balances/providers/starknetBalanceProvider.ts @@ -3,6 +3,7 @@ import { formatUnits } from "viem"; import Erc20Abi from '@/lib/abis/ERC20.json' import KnownInternalNames from "@/lib/knownIds"; import { BalanceProvider } from "@/Models/BalanceProvider"; +import { getNetworkRpcUrl } from "@/lib/rpc/resolveNetworkRpcUrl"; export class StarknetBalanceProvider extends BalanceProvider { supportsNetwork: BalanceProvider['supportsNetwork'] = (network) => { @@ -22,7 +23,7 @@ export class StarknetBalanceProvider extends BalanceProvider { if (!network?.tokens) return const provider = new RpcProvider({ - nodeUrl: network.nodes?.[0]?.url, + nodeUrl: getNetworkRpcUrl(network), }); diff --git a/apps/app/lib/gases/providers/evmGasProvider.ts b/apps/app/lib/gases/providers/evmGasProvider.ts index cf9d9159..f89bfb1e 100644 --- a/apps/app/lib/gases/providers/evmGasProvider.ts +++ b/apps/app/lib/gases/providers/evmGasProvider.ts @@ -8,6 +8,7 @@ import { gasPriceOracleABI, gasPriceOracleAddress } from '@eth-optimism/contracts-ts' +import { buildNetworkTransport } from "../../rpc/resolveNetworkRpcUrl" const ERC20_TRANSFER_FROM_GAS_BUFFER = 65_000n @@ -27,13 +28,13 @@ export class EVMGasProvider implements GasProvider { if (!atomicContract) return try { - const { createPublicClient, http } = await import("viem") + const { createPublicClient } = await import("viem") const chain = resolveChain(network) if (!chain) return const publicClient = createPublicClient({ chain, - transport: http(network.nodes?.[0]?.url), + transport: buildNetworkTransport(network), }) const nativeToken = getNativeToken(network) diff --git a/apps/app/lib/gases/providers/solanaGasProvider.ts b/apps/app/lib/gases/providers/solanaGasProvider.ts index 66b56730..0ed869f5 100644 --- a/apps/app/lib/gases/providers/solanaGasProvider.ts +++ b/apps/app/lib/gases/providers/solanaGasProvider.ts @@ -1,6 +1,7 @@ import { GasProps } from "../../../Models/Balance"; import { Network, getNativeToken, NetworkContractType } from "../../../Models/Network"; import { formatUnits } from "viem"; +import { getNetworkRpcUrl } from "../../rpc/resolveNetworkRpcUrl"; export class SolanaGasProvider { supportsNetwork(network: Network): boolean { @@ -18,7 +19,7 @@ export class SolanaGasProvider { try { const lamports = await estimateSolanaGas({ - rpcUrl: network.nodes?.[0]?.url ?? '', + rpcUrl: getNetworkRpcUrl(network), contractAddress: atomicContract, address, tokenSymbol: token.symbol, diff --git a/apps/app/lib/gases/providers/starknetGasProvider.ts b/apps/app/lib/gases/providers/starknetGasProvider.ts index 31c81a4f..4a304f01 100644 --- a/apps/app/lib/gases/providers/starknetGasProvider.ts +++ b/apps/app/lib/gases/providers/starknetGasProvider.ts @@ -1,6 +1,7 @@ import { GasProps } from "../../../Models/Balance" import { Network, getNativeToken, NetworkContractType } from "../../../Models/Network" import { GasProvider } from "./types" +import { getNetworkRpcUrl } from "../../rpc/resolveNetworkRpcUrl" export class StarknetGasProvider implements GasProvider { supportsNetwork(network: Network): boolean { @@ -12,7 +13,7 @@ export class StarknetGasProvider implements GasProvider { if (!account || !network) return - const rpcUrl = network.nodes?.[0]?.url + const rpcUrl = getNetworkRpcUrl(network) const contractAddress = network.contracts?.find(c => c.type === NetworkContractType.Train)?.address const nativeToken = getNativeToken(network) diff --git a/apps/app/lib/resolveChain.ts b/apps/app/lib/resolveChain.ts index c2710c84..4ce46a12 100644 --- a/apps/app/lib/resolveChain.ts +++ b/apps/app/lib/resolveChain.ts @@ -3,6 +3,7 @@ import { Network, getNativeToken } from "../Models/Network"; import NetworkSettings from "./NetworkSettings"; import { SendErrorMessage } from "./telegram"; import { chainConfig } from 'viem/op-stack' +import { getNetworkRpcUrl } from "./rpc/resolveNetworkRpcUrl"; export default function resolveChain(network: Network, customRpcUrl?: string) { @@ -18,8 +19,8 @@ export default function resolveChain(network: Network, customRpcUrl?: string) { const opStackChainConfig = Number(network.chainId) == 10 ? chainConfig : {} - // Use custom RPC URL if provided, otherwise use the network's default RPC - const rpcUrl = customRpcUrl || network.nodes?.[0]?.url; + // Use custom RPC URL if provided, otherwise use the network's effective RPC + const rpcUrl = customRpcUrl || getNetworkRpcUrl(network); const res = defineChain({ id: Number(network.chainId), diff --git a/apps/app/lib/rpc/evmNodes.ts b/apps/app/lib/rpc/evmNodes.ts new file mode 100644 index 00000000..8eff8026 --- /dev/null +++ b/apps/app/lib/rpc/evmNodes.ts @@ -0,0 +1,46 @@ +import type { ResolvedNode } from './types' + +function extractProviderName(url: string): string { + try { + const parts = new URL(url).hostname.split('.') + return parts.length >= 2 ? parts[parts.length - 2] : parts[0] + } catch { + return 'unknown' + } +} + +/** + * Resolve EVM RPC nodes for a given chainId from chainlist-rpcs. + * Returns up to 3 HTTPS endpoints with no tracking or limited tracking. + */ +export async function resolveEvmNodes(chainId: string): Promise { + const { get_rpcs_for_chain } = await import('chainlist-rpcs') + const rpcs = get_rpcs_for_chain({ + chain_id: Number(chainId), + allowed_tracking: ['none', 'limited'], + }) + + if (!Array.isArray(rpcs)) return [] + + const seen = new Set() + const results: ResolvedNode[] = [] + + for (const entry of rpcs) { + const url = typeof entry === 'string' ? entry : entry.url + if ( + typeof url === 'string' && + url.startsWith('https://') && + !url.includes('${') + ) { + const normalized = url.replace(/\/+$/, '') + const key = normalized.toLowerCase() + if (!seen.has(key)) { + seen.add(key) + results.push({ url: normalized, providerName: extractProviderName(url) }) + } + } + if (results.length >= 3) break + } + + return results +} diff --git a/apps/app/lib/rpc/fallbackCaller.ts b/apps/app/lib/rpc/fallbackCaller.ts new file mode 100644 index 00000000..6b562ace --- /dev/null +++ b/apps/app/lib/rpc/fallbackCaller.ts @@ -0,0 +1,20 @@ +/** + * Execute an async function against multiple RPC URLs, + * falling back to the next on failure. + */ +export async function withFallback( + urls: string[], + fn: (url: string) => Promise, +): Promise { + if (!urls.length) throw new Error('No RPC URLs provided') + + let lastError: Error | undefined + for (const url of urls) { + try { + return await fn(url) + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)) + } + } + throw lastError! +} diff --git a/apps/app/lib/rpc/nodeResolver.ts b/apps/app/lib/rpc/nodeResolver.ts new file mode 100644 index 00000000..2252f9fb --- /dev/null +++ b/apps/app/lib/rpc/nodeResolver.ts @@ -0,0 +1,53 @@ +import type { ResolvedNode, ChainNamespace } from './types' +import { NON_EVM_NODES } from './nonEvmNodes' +import { resolveEvmNodes } from './evmNodes' + +export class NodeResolver { + /** + * Resolves RPC node URLs for a given CAIP-2 network ID. + * Existing nodes (from API/mock) get priority, then public RPCs are appended. + */ + async resolveNodes( + caip2Id: string, + existingNodes?: Array<{ url: string; providerName: string }>, + ): Promise { + const [namespace, chainId] = caip2Id.split(':') as [ChainNamespace, string] + const seen = new Set() + const results: ResolvedNode[] = [] + + const add = (node: ResolvedNode) => { + const key = node.url.replace(/\/+$/, '').toLowerCase() + if (!seen.has(key)) { + seen.add(key) + results.push(node) + } + } + + // 1. Existing nodes first (known to work with this app) + if (existingNodes?.length) { + for (const n of existingNodes) { + add({ url: n.url, providerName: n.providerName }) + } + } + + // 2. Dynamic resolution by chain type + if (namespace === 'eip155') { + for (const n of await resolveEvmNodes(chainId)) { + add(n) + } + } else { + for (const n of NON_EVM_NODES[caip2Id] ?? []) { + add(n) + } + } + + return results.slice(0, 3) + } + + async resolveUrls( + caip2Id: string, + existingNodes?: Array<{ url: string; providerName: string }>, + ): Promise { + return (await this.resolveNodes(caip2Id, existingNodes)).map(n => n.url) + } +} diff --git a/apps/app/lib/rpc/nonEvmNodes.ts b/apps/app/lib/rpc/nonEvmNodes.ts new file mode 100644 index 00000000..37beec87 --- /dev/null +++ b/apps/app/lib/rpc/nonEvmNodes.ts @@ -0,0 +1,44 @@ +import type { ResolvedNode } from './types' + +/** + * Curated public RPC endpoints for non-EVM chains. + * Keyed by CAIP-2 ID. + */ +export const NON_EVM_NODES: Record = { + // ── Solana ── + 'solana:mainnet': [ + { url: 'https://api.mainnet-beta.solana.com', providerName: 'solana-official' }, + { url: 'https://solana-rpc.publicnode.com', providerName: 'publicnode' }, + { url: 'https://rpc.ankr.com/solana', providerName: 'ankr' }, + ], + 'solana:devnet': [ + { url: 'https://api.devnet.solana.com', providerName: 'solana-official' }, + ], + 'solana:testnet': [ + { url: 'https://api.testnet.solana.com', providerName: 'solana-official' }, + ], + + // ── Starknet ── + 'starknet:SN_MAIN': [ + { url: 'https://starknet-mainnet-rpc.publicnode.com', providerName: 'publicnode' }, + { url: 'https://free-rpc.nethermind.io/mainnet-juno/', providerName: 'nethermind' }, + { url: 'https://rpc.starknet.lava.build', providerName: 'lava' }, + ], + 'starknet:SN_SEPOLIA': [ + { url: 'https://starknet-sepolia-rpc.publicnode.com', providerName: 'publicnode' }, + { url: 'https://free-rpc.nethermind.io/sepolia-juno/', providerName: 'nethermind' }, + ], + + // ── Aztec ── + 'aztec:aztec-devnet': [ + { url: 'https://v4-devnet-2.aztec-labs.com', providerName: 'aztec-labs' }, + ], + + // ── TON ── + 'ton:mainnet': [ + { url: 'https://toncenter.com/api/v2/jsonRPC', providerName: 'toncenter' }, + ], + 'ton:testnet': [ + { url: 'https://testnet.toncenter.com/api/v2/jsonRPC', providerName: 'toncenter-testnet' }, + ], +} diff --git a/apps/app/lib/rpc/resolveNetworkRpcUrl.ts b/apps/app/lib/rpc/resolveNetworkRpcUrl.ts new file mode 100644 index 00000000..2ad8a194 --- /dev/null +++ b/apps/app/lib/rpc/resolveNetworkRpcUrl.ts @@ -0,0 +1,33 @@ +import { useRpcConfigStore } from '@/stores/rpcConfigStore' +import { Network } from '@/Models/Network' +import { http, fallback, type Transport } from 'viem' + +/** + * Get the effective RPC URLs for a network, respecting user overrides. + * Works outside React (balance providers, gas providers) via Zustand getState(). + */ +export function getNetworkRpcUrls(network: Network): string[] { + const store = useRpcConfigStore.getState() + return store.getEffectiveRpcUrls(network) +} + +/** + * Get the primary effective RPC URL (first in priority order). + */ +export function getNetworkRpcUrl(network: Network): string { + return getNetworkRpcUrls(network)[0] ?? '' +} + +/** + * Build a viem Transport with fallback across all effective RPC URLs. + */ +export function buildNetworkTransport( + network: Network, + options?: { timeout?: number; retryCount?: number }, +): Transport { + const urls = getNetworkRpcUrls(network) + if (urls.length > 1) { + return fallback(urls.map(u => http(u, options))) + } + return http(urls[0], options) +} diff --git a/apps/app/lib/rpc/types.ts b/apps/app/lib/rpc/types.ts new file mode 100644 index 00000000..07509597 --- /dev/null +++ b/apps/app/lib/rpc/types.ts @@ -0,0 +1,6 @@ +export interface ResolvedNode { + url: string + providerName: string +} + +export type ChainNamespace = 'eip155' | 'solana' | 'starknet' | 'aztec' | 'ton' diff --git a/apps/app/lib/wallets/ton/client.ts b/apps/app/lib/wallets/ton/client.ts index 11323aa7..8d7cfb41 100644 --- a/apps/app/lib/wallets/ton/client.ts +++ b/apps/app/lib/wallets/ton/client.ts @@ -1,8 +1,16 @@ import { TonClient } from "@ton/ton"; +import { Network } from "@/Models/Network"; +import { getNetworkRpcUrl } from "@/lib/rpc/resolveNetworkRpcUrl"; -const tonClient = new TonClient({ - endpoint: process.env.NEXT_PUBLIC_API_VERSION == 'sandbox' ? 'https://testnet.toncenter.com/api/v2/jsonRPC' : 'https://toncenter.com/api/v2/jsonRPC', - apiKey: process.env.NEXT_PUBLIC_TON_API_KEY, -}); +const DEFAULT_ENDPOINT = process.env.NEXT_PUBLIC_API_VERSION == 'sandbox' + ? 'https://testnet.toncenter.com/api/v2/jsonRPC' + : 'https://toncenter.com/api/v2/jsonRPC'; + +export function createTonClient(network?: Network): TonClient { + const endpoint = network ? (getNetworkRpcUrl(network) || DEFAULT_ENDPOINT) : DEFAULT_ENDPOINT; + return new TonClient({ + endpoint, + apiKey: process.env.NEXT_PUBLIC_TON_API_KEY, + }); +} -export default tonClient; \ No newline at end of file diff --git a/apps/app/lib/wallets/ton/getters.ts b/apps/app/lib/wallets/ton/getters.ts index 20a9b463..2cb96212 100644 --- a/apps/app/lib/wallets/ton/getters.ts +++ b/apps/app/lib/wallets/ton/getters.ts @@ -1,6 +1,6 @@ import { LockParams } from "../../../Models/phtlc"; import { Address } from "@ton/ton" -import tonClient from "./client"; +import { createTonClient } from "./client"; import { hexToBigInt, toHex } from "viem"; import { TupleBuilder } from "@ton/core" import { Network } from "../../../Models/Network"; @@ -17,12 +17,13 @@ export const getTONDetails = async (params: LockParams & { network: Network | un if (!network) throw Error("No network found") + const client = createTonClient(network); const bigIntValue = hexToBigInt(id as `0x${string}`); let args = new TupleBuilder(); args.writeNumber(bigIntValue); - const commitResult = await tonClient.runMethod( + const commitResult = await client.runMethod( Address.parse(contractAddress), "getDetails", args.build() diff --git a/apps/app/lib/wallets/ton/transactionBuilder.ts b/apps/app/lib/wallets/ton/transactionBuilder.ts index 409eb124..5975a8c0 100644 --- a/apps/app/lib/wallets/ton/transactionBuilder.ts +++ b/apps/app/lib/wallets/ton/transactionBuilder.ts @@ -1,10 +1,11 @@ import { retryWithExponentialBackoff } from "../../retry"; import { UserLockParams } from "../../../Models/phtlc"; -import tonClient from "./client"; +import { createTonClient } from "./client"; import { JettonMaster, Address, Builder, Dictionary, DictionaryValue, beginCell, Slice, Cell, toNano } from "@ton/ton" import { fromHex } from "viem"; +import { Network } from "../../../Models/Network"; -export const commitTransactionBuilder = async (params: UserLockParams & { wallet: { address: string, publicKey: string } }) => { +export const commitTransactionBuilder = async (params: UserLockParams & { wallet: { address: string, publicKey: string }, network?: Network }) => { const { wallet, @@ -15,11 +16,13 @@ export const commitTransactionBuilder = async (params: UserLockParams & { wallet destinationAsset, destinationAddress, decimals, - amount + amount, + network, } = params if (!sourceAsset.contractAddress) return + const client = createTonClient(network); const response_destination = Address.parse(wallet.address); const queryId = BigInt(Date.now()); @@ -45,7 +48,7 @@ export const commitTransactionBuilder = async (params: UserLockParams & { wallet const userAddress = Address.parse(wallet.address) const jettonMasterAddress = Address.parse(sourceAsset.contractAddress) - const jettonMaster = tonClient.open(JettonMaster.create(jettonMasterAddress)) + const jettonMaster = client.open(JettonMaster.create(jettonMasterAddress)) const getJettonAddress = async (address: Address) => { return await jettonMaster.getWalletAddress(address) } diff --git a/apps/app/lib/wallets/ton/useAtomicTON.ts b/apps/app/lib/wallets/ton/useAtomicTON.ts index 67be9ba6..c89b23c8 100644 --- a/apps/app/lib/wallets/ton/useAtomicTON.ts +++ b/apps/app/lib/wallets/ton/useAtomicTON.ts @@ -30,11 +30,13 @@ export default function useAtomicTON(params: UseAtomicTONParams) { // const hashlock = secretToHashlock(secret); // Note: Add hashlock to transaction params when contract supports it + const network = networks.find(n => n.chainId === params.chainId) const tx = await commitTransactionBuilder({ wallet: { address: tonWallet.account.address, publicKey: tonWallet.account.publicKey }, + network, ...params }) diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index f97b8cf4..f2e8a4ee 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -2,7 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactStrictMode: true, - transpilePackages: ["@aztec/wallet-sdk", "@train-protocol/sdk", "@train-protocol/aztec"], + transpilePackages: ["@aztec/wallet-sdk", "@train-protocol/sdk", "@train-protocol/aztec", "chainlist-rpcs"], productionBrowserSourceMaps: true, images: { remotePatterns: [ diff --git a/apps/app/package.json b/apps/app/package.json index 57f2cd40..6b4497db 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -26,6 +26,7 @@ "@defi-wonderland/aztec-standards": "4.0.0-devnet.2-patch.1", "@badrap/bar-of-progress": "^0.2.2", "@cartridge/controller": "^0.9.2", + "chainlist-rpcs": "^0.5.0", "@coral-xyz/anchor": "catalog:", "@eth-optimism/contracts-ts": "^0.15.0", "@imtbl/sdk": "1.45.10", diff --git a/packages/sdk/src/types/htlc-client.ts b/packages/sdk/src/types/htlc-client.ts index f1112243..c30f394c 100644 --- a/packages/sdk/src/types/htlc-client.ts +++ b/packages/sdk/src/types/htlc-client.ts @@ -30,15 +30,33 @@ export abstract class HTLCClient implements IHTLCClient { } async getSolverLockDetails(params: LockParams, nodeUrls: string[]): Promise { - const results = await Promise.all( + if (!nodeUrls.length) return null + + const results = await Promise.allSettled( nodeUrls.map(url => this._getSolverLockDetails(params, url)) ) - const validResults = results.filter((r): r is LockDetails => r !== null) - if (!validResults.length) return null + const fulfilled = results.filter( + (r): r is PromiseFulfilledResult => r.status === 'fulfilled' + ) + const validResults = fulfilled.map(r => r.value).filter((r): r is LockDetails => r !== null) + + if (!validResults.length) { + const firstError = results.find( + (r): r is PromiseRejectedResult => r.status === 'rejected' + ) + if (firstError && fulfilled.length === 0) throw firstError.reason + return null + } const [first, ...rest] = validResults - if (!rest.every(r => r.amount === first.amount && r.sender === first.sender && r.recipient === first.recipient && r.token === first.token && r.timelock === first.timelock)) { + if (rest.length > 0 && !rest.every(r => + r.amount === first.amount && + r.sender === first.sender && + r.recipient === first.recipient && + r.token === first.token && + r.timelock === first.timelock + )) { throw new Error('Lock details do not match across the provided nodes') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77496237..1ba2fb0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,9 @@ importers: bn.js: specifier: ^5.2.0 version: 5.2.3 + chainlist-rpcs: + specifier: ^0.5.0 + version: 0.5.270 clsx: specifier: ^1.2.1 version: 1.2.1 @@ -310,7 +313,7 @@ importers: version: 9.0.1 viem: specifier: 'catalog:' - version: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + version: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4) wagmi: specifier: 2.14.11 version: 2.14.11(@react-native-async-storage/async-storage@1.24.0)(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@18.3.1))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) @@ -6583,6 +6586,9 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chainlist-rpcs@0.5.270: + resolution: {integrity: sha512-7rgQr7TVMFNlOQBSvy7Z92Urawvvha7jFwGsx4Qhgv7MyXDz1riTYbKRCQ6exjd1MlhIWu1wocxIIth6QnK15Q==} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -11505,7 +11511,7 @@ snapshots: '@aztec/noir-types': 4.0.0-devnet.2-patch.2 '@aztec/simulator': 4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@aztec/stdlib': 4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@aztec/telemetry-client': 4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@aztec/telemetry-client': 4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@aztec/world-state': 4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) commander: 12.1.0 pako: 2.1.0 @@ -12063,7 +12069,7 @@ snapshots: lodash.omit: 4.5.0 sha3: 2.1.4 tslib: 2.8.1 - viem: '@aztec/viem@2.38.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)' + viem: '@aztec/viem@2.38.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)' transitivePeerDependencies: - '@types/pg' - aws-crt @@ -12087,7 +12093,7 @@ snapshots: '@aztec/noir-types': 4.0.0-devnet.2-patch.2 '@aztec/protocol-contracts': 4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@aztec/stdlib': 4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@aztec/telemetry-client': 4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@aztec/telemetry-client': 4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@aztec/world-state': 4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) lodash.clonedeep: 4.5.0 lodash.merge: 4.6.2 @@ -12206,6 +12212,38 @@ snapshots: - typescript - utf-8-validate + '@aztec/telemetry-client@4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@aztec/foundation': 4.0.0-devnet.2-patch.2 + '@aztec/stdlib': 4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.55.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/host-metrics': 0.36.2(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-gcp': 0.32.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + prom-client: 15.1.3 + viem: '@aztec/viem@2.38.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)' + transitivePeerDependencies: + - '@types/pg' + - aws-crt + - bufferutil + - debug + - encoding + - pg-native + - supports-color + - typescript + - utf-8-validate + - zod + '@aztec/telemetry-client@4.0.0-devnet.2-patch.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@aztec/foundation': 4.0.0-devnet.2-patch.2 @@ -12289,6 +12327,23 @@ snapshots: - typescript - utf-8-validate + '@aztec/viem@2.38.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.1.0(typescript@5.9.3)(zod@3.25.76) + isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.9.6(typescript@5.9.3) + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + '@aztec/viem@2.38.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@noble/curves': 1.9.1 @@ -15507,7 +15562,7 @@ snapshots: dependencies: big.js: 6.2.2 dayjs: 1.11.13 - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - typescript @@ -15529,7 +15584,7 @@ snapshots: dependencies: big.js: 6.2.2 dayjs: 1.11.13 - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - typescript @@ -15553,7 +15608,7 @@ snapshots: '@reown/appkit-wallet': 1.7.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@walletconnect/universal-provider': 2.21.8(@react-native-async-storage/async-storage@1.24.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) valtio: 1.13.2(@types/react@18.3.28)(react@18.3.1) - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15588,7 +15643,7 @@ snapshots: '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) '@walletconnect/universal-provider': 2.21.8(@react-native-async-storage/async-storage@1.24.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) valtio: 1.13.2(@types/react@18.3.28)(react@18.3.1) - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15812,7 +15867,7 @@ snapshots: '@walletconnect/logger': 2.1.2 '@walletconnect/universal-provider': 2.21.8(@react-native-async-storage/async-storage@1.24.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) valtio: 1.13.2(@types/react@18.3.28)(react@18.3.1) - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15850,7 +15905,7 @@ snapshots: '@walletconnect/logger': 2.1.2 '@walletconnect/universal-provider': 2.21.8(@react-native-async-storage/async-storage@1.24.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) valtio: 1.13.2(@types/react@18.3.28)(react@18.3.1) - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15914,7 +15969,7 @@ snapshots: '@walletconnect/universal-provider': 2.21.8(@react-native-async-storage/async-storage@1.24.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) bs58: 6.0.0 valtio: 1.13.2(@types/react@18.3.28)(react@18.3.1) - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15957,7 +16012,7 @@ snapshots: '@walletconnect/universal-provider': 2.21.8(@react-native-async-storage/async-storage@1.24.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) bs58: 6.0.0 valtio: 1.13.2(@types/react@18.3.28)(react@18.3.1) - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16092,7 +16147,7 @@ snapshots: '@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': dependencies: '@safe-global/safe-gateway-typescript-sdk': 3.23.1 - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - typescript @@ -18785,7 +18840,7 @@ snapshots: '@turnkey/crypto': 2.3.1 '@turnkey/encoding': 0.4.0 optionalDependencies: - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - typescript @@ -19236,7 +19291,7 @@ snapshots: '@wagmi/core': 2.16.4(@tanstack/query-core@5.90.20)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@18.3.1))(viem@2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) '@walletconnect/ethereum-provider': 2.21.8(@react-native-async-storage/async-storage@1.24.0)(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -19271,7 +19326,7 @@ snapshots: dependencies: eventemitter3: 5.0.1 mipd: 0.0.7(typescript@5.9.3) - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) zustand: 5.0.0(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) optionalDependencies: '@tanstack/query-core': 5.90.20 @@ -19286,7 +19341,7 @@ snapshots: dependencies: eventemitter3: 5.0.1 mipd: 0.0.7(typescript@5.9.3) - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) zustand: 5.0.0(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) optionalDependencies: '@tanstack/query-core': 5.90.20 @@ -20624,6 +20679,8 @@ snapshots: chai@6.2.2: {} + chainlist-rpcs@0.5.270: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -23462,7 +23519,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + abitype: 1.2.3(typescript@5.9.3)(zod@3.22.4) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -23477,7 +23534,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + abitype: 1.2.3(typescript@5.9.3)(zod@3.22.4) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -23492,7 +23549,22 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) + abitype: 1.2.3(typescript@5.9.3)(zod@3.22.4) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + + ox@0.9.6(typescript@5.9.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.22.4) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -25247,6 +25319,23 @@ snapshots: - utf-8-validate - zod + viem@2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@3.22.4) + isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.11.3(typescript@5.9.3)(zod@3.22.4) + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + viem@2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.22.4): dependencies: '@noble/curves': 1.9.1 @@ -25342,7 +25431,7 @@ snapshots: '@wagmi/core': 2.16.4(@tanstack/query-core@5.90.20)(@types/react@18.3.28)(react@18.3.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)) react: 18.3.1 use-sync-external-store: 1.4.0(react@18.3.1) - viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.44.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: From 591a5b067c0db3940a2601d0522f666e0cbb0a1c Mon Sep 17 00:00:00 2001 From: Aren Date: Fri, 13 Mar 2026 15:07:58 +0400 Subject: [PATCH 2/5] Refactor RPC node resolution and enhance solver lock polling - Updated AtomicProvider to include destinationRedeemTx in the htlcStatus calculation for improved accuracy. - Replaced NodeResolver class with a standalone resolveNodes function for better clarity and performance in resolving RPC nodes. - Enhanced useSolverLockPolling to implement consensus verification across multiple nodes, ensuring consistent lock details are returned. - Added validation for custom RPC URLs in the rpcConfigStore to prevent invalid URLs from being used. These changes improve the reliability and maintainability of the RPC handling and polling mechanisms. --- apps/app/context/atomicContext.tsx | 2 +- apps/app/helpers/getSettings.ts | 11 ++-- apps/app/hooks/htlc/useSolverLockPolling.tsx | 21 +++++- apps/app/lib/rpc/fallbackCaller.ts | 20 ------ apps/app/lib/rpc/nodeResolver.ts | 69 +++++++++----------- apps/app/lib/rpc/resolveNetworkRpcUrl.ts | 4 ++ apps/app/lib/rpc/types.ts | 11 ++-- apps/app/lib/wallets/ton/useAtomicTON.ts | 5 +- apps/app/stores/rpcConfigStore.ts | 13 ++++ packages/blockchains/IntegrationRules.md | 37 +++++++++-- packages/blockchains/aztec/src/client.ts | 3 +- packages/blockchains/evm/src/client.ts | 2 +- packages/blockchains/solana/src/client.ts | 2 +- packages/blockchains/starknet/src/client.ts | 2 +- packages/sdk/src/types/htlc-client.ts | 30 +++++++-- 15 files changed, 146 insertions(+), 86 deletions(-) delete mode 100644 apps/app/lib/rpc/fallbackCaller.ts diff --git a/apps/app/context/atomicContext.tsx b/apps/app/context/atomicContext.tsx index eca58e61..dffe5419 100644 --- a/apps/app/context/atomicContext.tsx +++ b/apps/app/context/atomicContext.tsx @@ -171,7 +171,7 @@ export function AtomicProvider({ children }) { const htlcStatus = useMemo(() => resolveHTLCStatus({ sourceDetails, solverLockDetails, timelockExpired: isTimelockExpired, secretRevealed, manualClaimRequired, destRedeemTxId: destinationRedeemTx }), - [sourceDetails, solverLockDetails, isTimelockExpired, secretRevealed, manualClaimRequired]) + [sourceDetails, solverLockDetails, isTimelockExpired, secretRevealed, manualClaimRequired, destinationRedeemTx]) const isTerminal = isTerminalStatus(htlcStatus) diff --git a/apps/app/helpers/getSettings.ts b/apps/app/helpers/getSettings.ts index 74756e7f..3768a544 100644 --- a/apps/app/helpers/getSettings.ts +++ b/apps/app/helpers/getSettings.ts @@ -2,10 +2,9 @@ import { NetworkContract } from "@/Models/Network"; import TrainApiClient from "../lib/trainApiClient"; import { getThemeData } from "./settingsHelper"; import KnownInternalNames from "@/lib/knownIds"; -import { NodeResolver } from "@/lib/rpc/nodeResolver"; +import { resolveNodes } from "@/lib/rpc/nodeResolver"; const apiClient = new TrainApiClient() -const nodeResolver = new NodeResolver() export async function getServerSideProps(context) { @@ -24,7 +23,7 @@ export async function getServerSideProps(context) { const resolvedNetworks = await Promise.all(networks.map(async network => { const _network = mockData.data.find(n => n.caip2Id === network.caip2Id) const seedNodes = _network?.nodes ?? [] - const resolvedNodes = await nodeResolver.resolveNodes(network.caip2Id, seedNodes) + const resolvedNodes = await resolveNodes(network.caip2Id, seedNodes) return { ...network, @@ -54,7 +53,7 @@ export async function getServerSideProps(context) { decimals: 18, priceInUsd: prices["eip155:11155111:0x0000000000000000000000000000000000000000"], }], - nodes: (await nodeResolver.resolveNodes(KnownInternalNames.Networks.StarkNetSepolia, starknetMock?.nodes ?? [])).map(n => ({ providerName: n.providerName, url: n.url })), + nodes: (await resolveNodes(KnownInternalNames.Networks.StarkNetSepolia, starknetMock?.nodes ?? [])).map(n => ({ providerName: n.providerName, url: n.url })), contracts: (starknetMock?.contracts as NetworkContract[]) ?? [], metadata: [], } as any) @@ -77,7 +76,7 @@ export async function getServerSideProps(context) { decimals: 18, priceInUsd: prices["eip155:11155111:0x0000000000000000000000000000000000000000"], }], - nodes: (await nodeResolver.resolveNodes(KnownInternalNames.Networks.AztecDevnet, aztecMock?.nodes ?? [])).map(n => ({ providerName: n.providerName, url: n.url })), + nodes: (await resolveNodes(KnownInternalNames.Networks.AztecDevnet, aztecMock?.nodes ?? [])).map(n => ({ providerName: n.providerName, url: n.url })), contracts: (aztecMock?.contracts as NetworkContract[]) ?? [], metadata: [], } as any) @@ -102,7 +101,7 @@ export async function getServerSideProps(context) { ?? prices["solana:devnet:11111111111111111111111111111111"] ?? 150, }], - nodes: (await nodeResolver.resolveNodes(KnownInternalNames.Networks.SolanaDevnet, solanaMock?.nodes ?? [])).map(n => ({ providerName: n.providerName, url: n.url })), + nodes: (await resolveNodes(KnownInternalNames.Networks.SolanaDevnet, solanaMock?.nodes ?? [])).map(n => ({ providerName: n.providerName, url: n.url })), contracts: (solanaMock?.contracts as NetworkContract[]) ?? [], metadata: [], } as any) diff --git a/apps/app/hooks/htlc/useSolverLockPolling.tsx b/apps/app/hooks/htlc/useSolverLockPolling.tsx index b8fcb5e0..440bfe4d 100644 --- a/apps/app/hooks/htlc/useSolverLockPolling.tsx +++ b/apps/app/hooks/htlc/useSolverLockPolling.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react" import useSWR from "swr" import { Network, Token } from "@/Models/Network" import { LockDetails } from "@/Models/phtlc/PHTLC" @@ -28,6 +29,7 @@ const useSolverLockPolling = ({ onSuccess, }: UseSolverLockPollingParams) => { const type: 'erc20' | 'native' = destinationAsset?.contractAddress && destinationAsset.contractAddress !== '0x0000000000000000000000000000000000000000' ? 'erc20' : 'native' + const consensusVerified = useRef(false) const shouldPoll = !!(network && hashlock && contractAddress && enabled) @@ -49,8 +51,25 @@ const useSolverLockPolling = ({ solverAddress, } + const primaryUrl = nodeUrls[0] + if (!primaryUrl) return null + try { - return await client.getSolverLockDetails(params, nodeUrls) + // Regular polling: single node only + const result = await client.getSolverLockDetails(params, primaryUrl) + + if (!result) return null + + // First detection: verify with multi-node consensus + if (!consensusVerified.current && nodeUrls.length > 1) { + const verified = await client.getSolverLockDetailsWithConsensus(params, nodeUrls) + if (verified) { + consensusVerified.current = true + } + return verified + } + + return result } catch (err) { console.error('Error fetching solver lock details:', err) throw err diff --git a/apps/app/lib/rpc/fallbackCaller.ts b/apps/app/lib/rpc/fallbackCaller.ts deleted file mode 100644 index 6b562ace..00000000 --- a/apps/app/lib/rpc/fallbackCaller.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Execute an async function against multiple RPC URLs, - * falling back to the next on failure. - */ -export async function withFallback( - urls: string[], - fn: (url: string) => Promise, -): Promise { - if (!urls.length) throw new Error('No RPC URLs provided') - - let lastError: Error | undefined - for (const url of urls) { - try { - return await fn(url) - } catch (e) { - lastError = e instanceof Error ? e : new Error(String(e)) - } - } - throw lastError! -} diff --git a/apps/app/lib/rpc/nodeResolver.ts b/apps/app/lib/rpc/nodeResolver.ts index 2252f9fb..3c29f8bd 100644 --- a/apps/app/lib/rpc/nodeResolver.ts +++ b/apps/app/lib/rpc/nodeResolver.ts @@ -2,52 +2,47 @@ import type { ResolvedNode, ChainNamespace } from './types' import { NON_EVM_NODES } from './nonEvmNodes' import { resolveEvmNodes } from './evmNodes' -export class NodeResolver { - /** - * Resolves RPC node URLs for a given CAIP-2 network ID. - * Existing nodes (from API/mock) get priority, then public RPCs are appended. - */ - async resolveNodes( - caip2Id: string, - existingNodes?: Array<{ url: string; providerName: string }>, - ): Promise { - const [namespace, chainId] = caip2Id.split(':') as [ChainNamespace, string] - const seen = new Set() - const results: ResolvedNode[] = [] +/** + * Resolves RPC node URLs for a given CAIP-2 network ID. + * Existing nodes (from API/mock) get priority, then public RPCs are appended. + */ +export async function resolveNodes( + caip2Id: string, + existingNodes?: Array<{ url: string; providerName: string }>, +): Promise { + const [namespace, chainId] = caip2Id.split(':') as [ChainNamespace, string] + const seen = new Set() + const results: ResolvedNode[] = [] - const add = (node: ResolvedNode) => { - const key = node.url.replace(/\/+$/, '').toLowerCase() - if (!seen.has(key)) { - seen.add(key) - results.push(node) - } + const add = (node: ResolvedNode) => { + const key = node.url.replace(/\/+$/, '').toLowerCase() + if (!seen.has(key)) { + seen.add(key) + results.push(node) } + } - // 1. Existing nodes first (known to work with this app) - if (existingNodes?.length) { - for (const n of existingNodes) { - add({ url: n.url, providerName: n.providerName }) - } + // 1. Existing nodes first (known to work with this app) + if (existingNodes?.length) { + for (const n of existingNodes) { + add({ url: n.url, providerName: n.providerName }) } + } - // 2. Dynamic resolution by chain type - if (namespace === 'eip155') { + // 2. Dynamic resolution by chain type + if (namespace === 'eip155') { + try { for (const n of await resolveEvmNodes(chainId)) { add(n) } - } else { - for (const n of NON_EVM_NODES[caip2Id] ?? []) { - add(n) - } + } catch (e) { + console.warn(`[resolveNodes] Failed to resolve dynamic EVM nodes for ${caip2Id}:`, e) + } + } else { + for (const n of NON_EVM_NODES[caip2Id] ?? []) { + add(n) } - - return results.slice(0, 3) } - async resolveUrls( - caip2Id: string, - existingNodes?: Array<{ url: string; providerName: string }>, - ): Promise { - return (await this.resolveNodes(caip2Id, existingNodes)).map(n => n.url) - } + return results.slice(0, 3) } diff --git a/apps/app/lib/rpc/resolveNetworkRpcUrl.ts b/apps/app/lib/rpc/resolveNetworkRpcUrl.ts index 2ad8a194..66ba8eb4 100644 --- a/apps/app/lib/rpc/resolveNetworkRpcUrl.ts +++ b/apps/app/lib/rpc/resolveNetworkRpcUrl.ts @@ -20,12 +20,16 @@ export function getNetworkRpcUrl(network: Network): string { /** * Build a viem Transport with fallback across all effective RPC URLs. + * Throws if no RPC URLs are available for the network. */ export function buildNetworkTransport( network: Network, options?: { timeout?: number; retryCount?: number }, ): Transport { const urls = getNetworkRpcUrls(network) + if (urls.length === 0) { + throw new Error(`No RPC URLs available for network ${network.caip2Id}`) + } if (urls.length > 1) { return fallback(urls.map(u => http(u, options))) } diff --git a/apps/app/lib/rpc/types.ts b/apps/app/lib/rpc/types.ts index 07509597..432d5ec2 100644 --- a/apps/app/lib/rpc/types.ts +++ b/apps/app/lib/rpc/types.ts @@ -1,6 +1,9 @@ -export interface ResolvedNode { - url: string - providerName: string -} +import type { NetworkNode } from '@train-protocol/sdk' + +/** + * A resolved RPC node. Identical to NetworkNode so the result can be + * assigned directly to Network.nodes without mapping. + */ +export type ResolvedNode = NetworkNode export type ChainNamespace = 'eip155' | 'solana' | 'starknet' | 'aztec' | 'ton' diff --git a/apps/app/lib/wallets/ton/useAtomicTON.ts b/apps/app/lib/wallets/ton/useAtomicTON.ts index c89b23c8..87e28776 100644 --- a/apps/app/lib/wallets/ton/useAtomicTON.ts +++ b/apps/app/lib/wallets/ton/useAtomicTON.ts @@ -188,7 +188,10 @@ export default function useAtomicTON(params: UseAtomicTONParams) { getUserLockDetails: getDetails, refund, redeemSolver, - getSolverLockDetails: function (params: LockParams): Promise { + getSolverLockDetails: function (params: LockParams, nodeUrl: string): Promise { + throw new Error("Function not implemented.") + }, + getSolverLockDetailsWithConsensus: function (params: LockParams, nodeUrls: string[]): Promise { throw new Error("Function not implemented.") } } diff --git a/apps/app/stores/rpcConfigStore.ts b/apps/app/stores/rpcConfigStore.ts index f31825c1..b0ab627b 100644 --- a/apps/app/stores/rpcConfigStore.ts +++ b/apps/app/stores/rpcConfigStore.ts @@ -2,6 +2,15 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' import { Network } from '../Models/Network' +function isValidRpcUrl(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'https:' || parsed.protocol === 'wss:' + } catch { + return false + } +} + export interface RpcConfig { customRpcUrls: string[] // Changed to array to support multiple URLs useCustomRpc: boolean @@ -40,6 +49,8 @@ export const useRpcConfigStore = create()( customRpcUrls = [config.customRpcUrl] } + customRpcUrls = customRpcUrls.filter(isValidRpcUrl) + return { rpcConfigs: { ...state.rpcConfigs, @@ -110,6 +121,7 @@ export const useRpcConfigStore = create()( }, addRpcUrl: (networkId: string, url: string) => { + if (!isValidRpcUrl(url)) return set((state) => { const config = state.rpcConfigs[networkId] || { customRpcUrls: [], useCustomRpc: true } return { @@ -149,6 +161,7 @@ export const useRpcConfigStore = create()( }, updateRpcUrl: (networkId: string, index: number, url: string) => { + if (!isValidRpcUrl(url)) return set((state) => { const config = state.rpcConfigs[networkId] if (!config || !config.customRpcUrls) return state diff --git a/packages/blockchains/IntegrationRules.md b/packages/blockchains/IntegrationRules.md index 6abb008e..7f46108d 100644 --- a/packages/blockchains/IntegrationRules.md +++ b/packages/blockchains/IntegrationRules.md @@ -131,6 +131,8 @@ export class {Chain}HTLCClient extends HTLCClient { super(config.apiClient) // Always pass apiClient to base this.rpc = ... this.signer = config.signer + // Override default consensus options if needed (e.g., Aztec: minQuorum 1) + // this.consensusOptions = { minQuorum: 1 } } // ── Write Operations ─────────────────────────────────────────────── @@ -141,7 +143,7 @@ export class {Chain}HTLCClient extends HTLCClient { // ── Read Operations ──────────────────────────────────────────────── - async _getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { ... } + async getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { ... } async getUserLockDetails(params: LockParams): Promise { ... } async recoverSwap(txHash: string): Promise { ... } @@ -155,7 +157,7 @@ export class {Chain}HTLCClient extends HTLCClient { ### Ordering rules 1. **Write operations first** — `userLock` → `refund` → `redeemSolver` -2. **Read operations second** — `getUserLockDetails` → `_getSolverLockDetails` → `recoverSwap` +2. **Read operations second** — `getUserLockDetails` → `getSolverLockDetails` → `recoverSwap` 3. **Private helpers last** — `requireSigner()` first, then chain-specific utilities 4. **Use section comments** — `// ── Write Operations ───...` separator style between groups @@ -163,8 +165,26 @@ export class {Chain}HTLCClient extends HTLCClient { The base `HTLCClient` class provides these methods — subclasses should **not** override them: -- `getSolverLockDetails(params, nodeUrls)` — queries multiple nodes via `_getSolverLockDetails`, validates results match across nodes - `revealSecret(solverId, hashlock, secret)` — delegates to `apiClient.revealSecret()` +- `getSolverLockDetailsWithConsensus(params, nodeUrls, options?)` — queries multiple nodes via `getSolverLockDetails`, validates results match across nodes (see below) + +### Cross-node consensus + +The base class provides `getSolverLockDetailsWithConsensus()` which fans out `getSolverLockDetails()` to multiple RPC nodes and validates that all successful responses agree on critical fields (`amount`, `sender`, `recipient`, `token`, `timelock`). + +**Consensus options:** +- The base class sets `protected consensusOptions: ConsensusOptions = { minQuorum: 2 }` by default +- Subclasses can override this in their constructor (e.g., Aztec sets `minQuorum: 1` since it typically has fewer public nodes) +- Per-call `options` passed to `getSolverLockDetailsWithConsensus()` take priority over the instance default + +**How it works:** +1. Queries all `nodeUrls` in parallel via `Promise.allSettled` +2. Filters for non-null results +3. Requires at least `minQuorum` agreeing results (capped to `nodeUrls.length`) +4. Compares critical fields across all valid results — throws if they disagree +5. Returns the first valid result if consensus passes + +Chain implementations only need to implement the single-node abstract method `getSolverLockDetails(params, nodeUrl)`. --- @@ -215,12 +235,12 @@ try { 3. If `txId` is provided, fetch transaction logs to extract `userData` (nonce) 4. Return `LockDetails` object with all fields mapped -### _getSolverLockDetails — Count-Then-Loop Pattern +### getSolverLockDetails — Count-Then-Loop Pattern -**This is a critical shared pattern.** The base class calls `_getSolverLockDetails` for each node URL and verifies results match. Your subclass implements the single-node version. The contract stores multiple solver locks per hashlock. Always: +**This is a critical shared pattern.** The base class calls `getSolverLockDetails` for each node URL and verifies results match via `getSolverLockDetailsWithConsensus()`. Your subclass implements the single-node version. The contract stores multiple solver locks per hashlock. Always: ```ts -async _getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { +async getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { // 1. Get the count of solver locks for this hashlock const count = /* call getSolverLockCount(hashlock) */ @@ -358,6 +378,7 @@ import { AtomicResult, RecoveredSwapData, BaseHTLCClientConfig, + ConsensusOptions, } from '@train-protocol/sdk' // Key derivation @@ -401,6 +422,7 @@ describe('register{Chain}Sdk', () => { }) expect(typeof client.getUserLockDetails).toBe('function') expect(typeof client.getSolverLockDetails).toBe('function') + expect(typeof client.getSolverLockDetailsWithConsensus).toBe('function') expect(typeof client.userLock).toBe('function') expect(typeof client.refund).toBe('function') expect(typeof client.redeemSolver).toBe('function') @@ -430,7 +452,8 @@ const ZERO_ADDRESS = '0x000...' // Chain's empty/zero address representation - [ ] Add `declare module '@train-protocol/sdk'` augmentation for `HTLCClientConfigMap` and `WalletSignConfigMap` - [ ] Implement `{Chain}HTLCClient extends HTLCClient` in `client.ts` - [ ] Follow function ordering: writes → reads → private helpers -- [ ] Implement count-then-loop pattern in `_getSolverLockDetails` (1-indexed, single-node version) +- [ ] Implement count-then-loop pattern in `getSolverLockDetails` (1-indexed, single-node version) +- [ ] Set `this.consensusOptions` in constructor if chain needs non-default quorum (default: `minQuorum: 2`) - [ ] Define `{Chain}WalletLike` minimal interface in `login/wallet-sign.ts` - [ ] Implement key derivation in `login/wallet-sign.ts` using `deriveKeyMaterial` + `IDENTITY_SALT` - [ ] Create idempotent `register{Chain}Sdk()` in `index.ts` — pass config directly (no `as` casts) diff --git a/packages/blockchains/aztec/src/client.ts b/packages/blockchains/aztec/src/client.ts index a403bfe4..d9110e42 100644 --- a/packages/blockchains/aztec/src/client.ts +++ b/packages/blockchains/aztec/src/client.ts @@ -36,6 +36,7 @@ export class AztecHTLCClient extends HTLCClient { super(config.apiClient) this.rpcUrl = config.rpcUrl this.signer = config.signer + this.consensusOptions = { minQuorum: 1 } } async userLock(params: UserLockParams): Promise { @@ -245,7 +246,7 @@ export class AztecHTLCClient extends HTLCClient { } } - async _getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { + async getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { const signer = this.requireSigner() const { id, contractAddress } = params const { contract, userAztecAddress } = await this.getContractInstance(contractAddress, signer, nodeUrl) diff --git a/packages/blockchains/evm/src/client.ts b/packages/blockchains/evm/src/client.ts index b1c23f7f..ba1a27d1 100644 --- a/packages/blockchains/evm/src/client.ts +++ b/packages/blockchains/evm/src/client.ts @@ -181,7 +181,7 @@ export class EvmHTLCClient extends HTLCClient { } } - async _getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { + async getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { const { id, contractAddress } = params const rpc = new JsonRpcClient(nodeUrl) diff --git a/packages/blockchains/solana/src/client.ts b/packages/blockchains/solana/src/client.ts index 9b341a54..7ef93d2f 100644 --- a/packages/blockchains/solana/src/client.ts +++ b/packages/blockchains/solana/src/client.ts @@ -214,7 +214,7 @@ export class SolanaHTLCClient extends HTLCClient { } } - async _getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { + async getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { const { contractAddress, id } = params if (!contractAddress) throw new Error('No contract address') diff --git a/packages/blockchains/starknet/src/client.ts b/packages/blockchains/starknet/src/client.ts index 339941af..d83fdd8e 100644 --- a/packages/blockchains/starknet/src/client.ts +++ b/packages/blockchains/starknet/src/client.ts @@ -145,7 +145,7 @@ export class StarknetHTLCClient extends HTLCClient { } } - async _getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { + async getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { const { id, contractAddress } = params const provider = new RpcProvider({ nodeUrl }) const contract = this.createContract(contractAddress, provider) diff --git a/packages/sdk/src/types/htlc-client.ts b/packages/sdk/src/types/htlc-client.ts index c30f394c..96c4820a 100644 --- a/packages/sdk/src/types/htlc-client.ts +++ b/packages/sdk/src/types/htlc-client.ts @@ -9,7 +9,8 @@ export type BaseHTLCClientConfig = { export interface IHTLCClient { getUserLockDetails(params: LockParams): Promise - getSolverLockDetails(params: LockParams, nodeUrls: string[]): Promise + getSolverLockDetails(params: LockParams, nodeUrl: string): Promise + getSolverLockDetailsWithConsensus(params: LockParams, nodeUrls: string[], options?: ConsensusOptions): Promise recoverSwap(txHash: string): Promise userLock(params: UserLockParams): Promise @@ -20,6 +21,7 @@ export interface IHTLCClient { export abstract class HTLCClient implements IHTLCClient { protected apiClient: TrainApiClient + protected consensusOptions: ConsensusOptions = { minQuorum: 2 } constructor(apiClient: TrainApiClient) { this.apiClient = apiClient @@ -29,11 +31,17 @@ export abstract class HTLCClient implements IHTLCClient { return this.apiClient.revealSecret(solverId, hashlock, secret) } - async getSolverLockDetails(params: LockParams, nodeUrls: string[]): Promise { + async getSolverLockDetailsWithConsensus( + params: LockParams, + nodeUrls: string[], + options?: ConsensusOptions + ): Promise { + const { minQuorum = 2 } = { ...this.consensusOptions, ...options } + if (!nodeUrls.length) return null const results = await Promise.allSettled( - nodeUrls.map(url => this._getSolverLockDetails(params, url)) + nodeUrls.map(url => this.getSolverLockDetails(params, url)) ) const fulfilled = results.filter( @@ -49,9 +57,17 @@ export abstract class HTLCClient implements IHTLCClient { return null } + const effectiveQuorum = Math.min(minQuorum, nodeUrls.length) + + if (validResults.length < effectiveQuorum) { + throw new Error( + `Insufficient node agreement: ${validResults.length} of ${nodeUrls.length} nodes returned results, need at least ${effectiveQuorum}` + ) + } + const [first, ...rest] = validResults if (rest.length > 0 && !rest.every(r => - r.amount === first.amount && + String(r.amount) === String(first.amount) && r.sender === first.sender && r.recipient === first.recipient && r.token === first.token && @@ -64,9 +80,13 @@ export abstract class HTLCClient implements IHTLCClient { } abstract getUserLockDetails(params: LockParams): Promise - abstract _getSolverLockDetails(params: LockParams, nodeUrl: string): Promise + abstract getSolverLockDetails(params: LockParams, nodeUrl: string): Promise abstract recoverSwap(txHash: string): Promise abstract userLock(params: UserLockParams): Promise abstract refund(params: RefundParams): Promise abstract redeemSolver(params: RedeemSolverParams): Promise } + +export interface ConsensusOptions { + minQuorum?: number +} \ No newline at end of file From 2bfc2c5d1ae1fe92a1c3936867f43d6459c4bd34 Mon Sep 17 00:00:00 2001 From: Aren Date: Fri, 13 Mar 2026 15:18:10 +0400 Subject: [PATCH 3/5] Fix timeout duration in AtomicProvider and initialize consensus verification in useSolverLockPolling - Adjusted the timeout duration in AtomicProvider from 3 minutes to 2 minutes for manual claim requirements. - Added useEffect in useSolverLockPolling to reset consensus verification state when hashlock changes, improving polling accuracy. These changes enhance the responsiveness and reliability of the HTLC handling logic. --- apps/app/context/atomicContext.tsx | 2 +- apps/app/hooks/htlc/useSolverLockPolling.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/app/context/atomicContext.tsx b/apps/app/context/atomicContext.tsx index dffe5419..09d9e739 100644 --- a/apps/app/context/atomicContext.tsx +++ b/apps/app/context/atomicContext.tsx @@ -352,7 +352,7 @@ export function AtomicProvider({ children }) { const timer = setTimeout(() => { updateHTLCState(hashlock, { manualClaimRequired: true }); - }, 3 * 60 * 1000); // 2 minutes + }, 2 * 60 * 1000); // 2 minutes return () => clearTimeout(timer); }, [sourceDetails?.status, sourceDetails?.secret, solverLockDetails?.sender, solverLockDetails?.status, hashlock, manualClaimRequired]) diff --git a/apps/app/hooks/htlc/useSolverLockPolling.tsx b/apps/app/hooks/htlc/useSolverLockPolling.tsx index 440bfe4d..f7385ff7 100644 --- a/apps/app/hooks/htlc/useSolverLockPolling.tsx +++ b/apps/app/hooks/htlc/useSolverLockPolling.tsx @@ -1,4 +1,4 @@ -import { useRef } from "react" +import { useEffect, useRef } from "react" import useSWR from "swr" import { Network, Token } from "@/Models/Network" import { LockDetails } from "@/Models/phtlc/PHTLC" @@ -31,6 +31,10 @@ const useSolverLockPolling = ({ const type: 'erc20' | 'native' = destinationAsset?.contractAddress && destinationAsset.contractAddress !== '0x0000000000000000000000000000000000000000' ? 'erc20' : 'native' const consensusVerified = useRef(false) + useEffect(() => { + consensusVerified.current = false + }, [hashlock]) + const shouldPoll = !!(network && hashlock && contractAddress && enabled) const key = shouldPoll From 452ce3a1ce62b907141e092f885fad0fff76d33a Mon Sep 17 00:00:00 2001 From: Aren Date: Fri, 13 Mar 2026 17:17:00 +0400 Subject: [PATCH 4/5] Update useSolverLockPolling to include nodeUrls in consensus verification effect - Modified the useEffect in useSolverLockPolling to reset consensus verification state when both hashlock and nodeUrls change, improving the accuracy of polling behavior. This change enhances the responsiveness of the HTLC handling logic by ensuring that the polling mechanism reacts to updates in node URLs. --- CLAUDE.md | 8 ++ apps/app/hooks/htlc/useSolverLockPolling.tsx | 2 +- apps/app/lib/rpc/evmNodes.ts | 1 - apps/app/lib/rpc/nodeResolver.ts | 2 +- packages/blockchains/aztec/src/client.ts | 2 +- packages/sdk/src/types/htlc-client.ts | 77 ++++++++++++-------- 6 files changed, 59 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b0830e37..e57e1b2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,14 @@ Key files: - `packages/blockchains/{evm,solana,starknet,aztec}/src/client.ts` — chain-specific HTLC implementations - `apps/app/lib/wallets/utils/atomicTypes.ts` — chain-specific wallet/atomic interfaces +### RPC Node Resolution & Consensus +- `apps/app/lib/rpc/` — dynamic RPC resolution: `nodeResolver.ts` (entry point), `evmNodes.ts` (chainlist-rpcs), `nonEvmNodes.ts` (static registry) +- `resolveNodes(caip2Id)` returns all available RPCs (existing nodes first, then dynamic/static). Called server-side in `getSettings.ts` +- `rpcConfigStore` manages user custom RPC overrides; `getEffectiveRpcUrls(network)` returns custom URLs or `network.nodes` +- **Consensus verification**: `getSolverLockDetailsWithConsensus()` in SDK queries nodes in batches of `batchSize` (default 3), retries with next batch if quorum (`minQuorum`, default 2) not met +- `ConsensusOptions`: `{ minQuorum?: number, batchSize?: number }` — configurable per-call or via subclass defaults +- Consensus runs once on first solver lock detection (tracked by `consensusVerified` ref in `useSolverLockPolling`), then falls back to single-node polling + ### Secret & Nonce - Secret derived from: `deriveInitialKey()` + `deriveSecretFromTimelock(key, nonce)` - Nonce = `Date.now()` timestamp, stored in URL query params (for page refresh recovery) and on-chain via `userData` bytes field diff --git a/apps/app/hooks/htlc/useSolverLockPolling.tsx b/apps/app/hooks/htlc/useSolverLockPolling.tsx index f7385ff7..88214618 100644 --- a/apps/app/hooks/htlc/useSolverLockPolling.tsx +++ b/apps/app/hooks/htlc/useSolverLockPolling.tsx @@ -33,7 +33,7 @@ const useSolverLockPolling = ({ useEffect(() => { consensusVerified.current = false - }, [hashlock]) + }, [hashlock, nodeUrls]) const shouldPoll = !!(network && hashlock && contractAddress && enabled) diff --git a/apps/app/lib/rpc/evmNodes.ts b/apps/app/lib/rpc/evmNodes.ts index 8eff8026..cbac63e5 100644 --- a/apps/app/lib/rpc/evmNodes.ts +++ b/apps/app/lib/rpc/evmNodes.ts @@ -39,7 +39,6 @@ export async function resolveEvmNodes(chainId: string): Promise results.push({ url: normalized, providerName: extractProviderName(url) }) } } - if (results.length >= 3) break } return results diff --git a/apps/app/lib/rpc/nodeResolver.ts b/apps/app/lib/rpc/nodeResolver.ts index 3c29f8bd..23b1441c 100644 --- a/apps/app/lib/rpc/nodeResolver.ts +++ b/apps/app/lib/rpc/nodeResolver.ts @@ -44,5 +44,5 @@ export async function resolveNodes( } } - return results.slice(0, 3) + return results } diff --git a/packages/blockchains/aztec/src/client.ts b/packages/blockchains/aztec/src/client.ts index d9110e42..76dd8ded 100644 --- a/packages/blockchains/aztec/src/client.ts +++ b/packages/blockchains/aztec/src/client.ts @@ -36,7 +36,7 @@ export class AztecHTLCClient extends HTLCClient { super(config.apiClient) this.rpcUrl = config.rpcUrl this.signer = config.signer - this.consensusOptions = { minQuorum: 1 } + this.consensusOptions = { minQuorum: 1, batchSize: 1 } } async userLock(params: UserLockParams): Promise { diff --git a/packages/sdk/src/types/htlc-client.ts b/packages/sdk/src/types/htlc-client.ts index 96c4820a..fed501fc 100644 --- a/packages/sdk/src/types/htlc-client.ts +++ b/packages/sdk/src/types/htlc-client.ts @@ -21,7 +21,7 @@ export interface IHTLCClient { export abstract class HTLCClient implements IHTLCClient { protected apiClient: TrainApiClient - protected consensusOptions: ConsensusOptions = { minQuorum: 2 } + protected consensusOptions: ConsensusOptions = { minQuorum: 2, batchSize: 3 } constructor(apiClient: TrainApiClient) { this.apiClient = apiClient @@ -36,47 +36,65 @@ export abstract class HTLCClient implements IHTLCClient { nodeUrls: string[], options?: ConsensusOptions ): Promise { - const { minQuorum = 2 } = { ...this.consensusOptions, ...options } + const { minQuorum, batchSize } = { ...this.consensusOptions, ...options } as Required if (!nodeUrls.length) return null - const results = await Promise.allSettled( - nodeUrls.map(url => this.getSolverLockDetails(params, url)) - ) + const effectiveQuorum = Math.min(minQuorum, nodeUrls.length) - const fulfilled = results.filter( - (r): r is PromiseFulfilledResult => r.status === 'fulfilled' - ) - const validResults = fulfilled.map(r => r.value).filter((r): r is LockDetails => r !== null) + // Partition nodeUrls into batches + const batches: string[][] = [] + for (let i = 0; i < nodeUrls.length; i += batchSize) { + batches.push(nodeUrls.slice(i, i + batchSize)) + } - if (!validResults.length) { - const firstError = results.find( - (r): r is PromiseRejectedResult => r.status === 'rejected' + let totalValid = 0 + let totalQueried = 0 + let lastError: unknown = null + + for (const batch of batches) { + const results = await Promise.allSettled( + batch.map(url => this.getSolverLockDetails(params, url)) ) - if (firstError && fulfilled.length === 0) throw firstError.reason - return null - } - const effectiveQuorum = Math.min(minQuorum, nodeUrls.length) + totalQueried += batch.length - if (validResults.length < effectiveQuorum) { - throw new Error( - `Insufficient node agreement: ${validResults.length} of ${nodeUrls.length} nodes returned results, need at least ${effectiveQuorum}` + const fulfilled = results.filter( + (r): r is PromiseFulfilledResult => r.status === 'fulfilled' ) + const validResults = fulfilled.map(r => r.value).filter((r): r is LockDetails => r !== null) + + totalValid += validResults.length + + const batchError = results.find( + (r): r is PromiseRejectedResult => r.status === 'rejected' + ) + if (batchError) lastError = batchError.reason + + if (validResults.length >= effectiveQuorum) { + const [first, ...rest] = validResults + if (rest.length > 0 && !rest.every(r => + String(r.amount) === String(first.amount) && + r.sender === first.sender && + r.recipient === first.recipient && + r.token === first.token && + r.timelock === first.timelock + )) { + throw new Error('Lock details do not match across the provided nodes') + } + return first + } } - const [first, ...rest] = validResults - if (rest.length > 0 && !rest.every(r => - String(r.amount) === String(first.amount) && - r.sender === first.sender && - r.recipient === first.recipient && - r.token === first.token && - r.timelock === first.timelock - )) { - throw new Error('Lock details do not match across the provided nodes') + // All batches exhausted + if (totalValid === 0) { + if (lastError) throw lastError + return null } - return first + throw new Error( + `Insufficient node agreement: ${totalValid} of ${totalQueried} nodes returned results, need at least ${effectiveQuorum}` + ) } abstract getUserLockDetails(params: LockParams): Promise @@ -89,4 +107,5 @@ export abstract class HTLCClient implements IHTLCClient { export interface ConsensusOptions { minQuorum?: number + batchSize?: number } \ No newline at end of file From a66708c896bed90e5339ac09e07282813d61a31c Mon Sep 17 00:00:00 2001 From: Aren Date: Fri, 13 Mar 2026 17:52:26 +0400 Subject: [PATCH 5/5] fix build --- packages/blockchains/ton/src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockchains/ton/src/client.ts b/packages/blockchains/ton/src/client.ts index 1b8571ef..350e5883 100644 --- a/packages/blockchains/ton/src/client.ts +++ b/packages/blockchains/ton/src/client.ts @@ -183,7 +183,7 @@ export class TonHTLCClient extends HTLCClient { } } - async _getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { + async getSolverLockDetails(params: LockParams, nodeUrl: string): Promise { const { id, contractAddress } = params const rpc = TonRpcClient.fromUrl(nodeUrl, this.apiKey)