Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions apps/app/context/atomicContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ type CommitStatesDict = Record<string, HTLCState>;
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)
Expand All @@ -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
Expand Down Expand Up @@ -168,7 +169,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)

Expand Down Expand Up @@ -248,6 +249,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,
Expand All @@ -257,7 +265,7 @@ export function AtomicProvider({ children }) {
client: destinationClient,
solverAddress: destinationSolverAddress,
onSuccess: handleSolverLockSuccess,
nodeUrls: destination_network ? getEffectiveRpcUrls(destination_network) : [],
nodeUrls: destNodeUrls,
})

// useEffect(() => {
Expand Down Expand Up @@ -340,7 +348,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])
Expand Down
11 changes: 7 additions & 4 deletions apps/app/helpers/getSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NetworkContract } from "@/Models/Network";
// import TrainApiClient from "../lib/trainApiClient";
import { getThemeData } from "./settingsHelper";
import KnownInternalNames from "@/lib/knownIds";
import { resolveNodes } from "@/lib/rpc/nodeResolver";

// const apiClient = new TrainApiClient()

Expand All @@ -27,20 +28,22 @@ export async function getServerSideProps(context) {
"solana:devnet:11111111111111111111111111111111": 150,
}

// Use mock data while backend ngrok is off
const resolvedNetworks = MOCK_API_NETWORKS.map(network => {
//const resolvedNetworks = (await Promise.all(networks.map(async network => {
const resolvedNetworks = (await Promise.all(MOCK_API_NETWORKS.map(async network => {
const _network = mockData.data.find(n => n.caip2Id === network.caip2Id)
const seedNodes = _network?.nodes ?? []
const resolvedNodes = await 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}`] || 0,
})),
}
}).filter(n => n.nodes.length > 0 && n.contracts.length > 0)
}))).filter(n => n?.nodes?.length > 0 && n?.contracts?.length > 0)

const settings = {
networks: resolvedNetworks,
Expand Down
25 changes: 24 additions & 1 deletion apps/app/hooks/htlc/useSolverLockPolling.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect, useRef } from "react"
import useSWR from "swr"
import { Network, Token } from "@/Models/Network"
import { LockDetails } from "@/Models/phtlc/PHTLC"
Expand Down Expand Up @@ -28,6 +29,11 @@ const useSolverLockPolling = ({
onSuccess,
}: UseSolverLockPollingParams) => {
const type: 'erc20' | 'native' = destinationAsset?.contractAddress && destinationAsset.contractAddress !== '0x0000000000000000000000000000000000000000' ? 'erc20' : 'native'
const consensusVerified = useRef(false)

useEffect(() => {
consensusVerified.current = false
}, [hashlock, nodeUrls])

const shouldPoll = !!(network && hashlock && contractAddress && enabled)

Expand All @@ -49,8 +55,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
Expand Down
3 changes: 2 additions & 1 deletion apps/app/lib/balances/providers/aztecBalanceProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
15 changes: 8 additions & 7 deletions apps/app/lib/balances/providers/evmBalanceProvider.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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) => {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 })
}
})

Expand Down Expand Up @@ -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 })
}
})

Expand Down
3 changes: 2 additions & 1 deletion apps/app/lib/balances/providers/solanaBalanceProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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"
);

Expand Down
3 changes: 2 additions & 1 deletion apps/app/lib/balances/providers/starknetBalanceProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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),
});


Expand Down
5 changes: 3 additions & 2 deletions apps/app/lib/gases/providers/evmGasProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion apps/app/lib/gases/providers/solanaGasProvider.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion apps/app/lib/gases/providers/starknetGasProvider.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)

Expand Down
5 changes: 3 additions & 2 deletions apps/app/lib/resolveChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Expand All @@ -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),
Expand Down
45 changes: 45 additions & 0 deletions apps/app/lib/rpc/evmNodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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<ResolvedNode[]> {
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<string>()
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) })
}
}
}

return results
}
Loading