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
1 change: 0 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ Key files:
```
NEXT_PUBLIC_TRAIN_API # Station API base URL (required)
NEXT_PUBLIC_API_VERSION # "sandbox" or "mainnet"
NEXT_PUBLIC_ALCHEMY_KEY # For light client RPC calls
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID # WalletConnect
NEXT_PUBLIC_POSTHOG_KEY # PostHog analytics
NEXT_PUBLIC_POSTHOG_HOST # PostHog host
Expand Down
15 changes: 12 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ The app has no `lint` script and no in-app test setup — tests live in packages
### HTLC / Atomic Swap Flow
1. `userLock()` — user locks funds with hashlock on source chain (single-step, no separate commit)
2. Poll `getSolverLock(hashlock, solverAddress)` — wait for the quoted solver to lock on the destination chain
3. **Auto-reveal** — once solver-lock verification (against the original quote) and multi-RPC consensus pass, the secret is revealed automatically via the API (`RevealSecret`). No user click; manual `RevealSecretAction` button and the `swapPreferencesStore.autoRevealSecret` toggle have been removed. When verification is `skipped` (no on-chain `dstAmount` to compare against), reveal still proceeds, but `SolverLockDetectedAction` shows a "Verification skipped — proceeding with caution" banner. On reveal failure, a "Try again" button is rendered.
3. **Auto-reveal** — once solver-lock verification (against the original quote) and lock-existence verification (Helios light client on supported networks, multi-RPC consensus otherwise) pass, the secret is revealed automatically via the API (`RevealSecret`). No user click; manual `RevealSecretAction` button and the `swapPreferencesStore.autoRevealSecret` toggle have been removed. When verification is `skipped` (no on-chain `dstAmount` to compare against), reveal still proceeds, but `SolverLockDetectedAction` shows a "Verification skipped — proceeding with caution" banner. On reveal failure, a "Try again" button is rendered.
4. Swap complete when solver redeems

Key files:
Expand All @@ -67,7 +67,17 @@ Key files:
- `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. Returns `ConsensusResult { details, agreedCount } | null` (the `agreedCount` is the number of nodes that agreed on the lock data).
- `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. The hook also tracks `verifiedNodeCount` (=1 on the single-node fast path, =`agreedCount` after multi-node consensus) and writes it to swap flags so the `VerificationStatus` UI can render "Verified by N RPCs" accurately.
- Consensus runs once on first solver lock detection (tracked by the `verified` ref in `useSolverLockPolling`), then falls back to single-node polling. Swap flags carry `verificationSource: 'rpc' | 'lightClient' | 'manual'` plus `verifiedNodeCount` (meaningful only for `'rpc'`: =1 on the single-node fast path, =`agreedCount` after multi-node consensus) so the `VerificationStatus` UI can render "Verified by N RPCs" / "Verified by light client" / "Verified manually" accurately.

### Helios Light Client (trustless solver-lock verification)
- **LC-first, RPC fallback**: on supported networks (Ethereum mainnet + Sepolia — see `apps/app/lib/lightClient/networks.ts`), `useSolverLockPolling` hands the verification verdict to a Helios light client before RPC consensus. Success → `consensusPhase='verified'` with `verificationSource='lightClient'`; init/sync failure or a 60s timeout demotes permanently (per hashlock) to the multi-RPC consensus path. Unsupported networks behave exactly as before.
- **The LC reading is authoritative, not just a presence check.** On success it is written straight to the solver-lock query cache (which is what `useRevealSecret` reads to gate the irreversible reveal) and pinned in `lcVerifiedRef`. The primary RPC stays untrusted: it may only move `status` forward, and any disagreement about the lock's *terms* — checked at verdict time and on every subsequent poll via `solverLockTermsMatch` — fails verification with `lightClientMismatch`, which is terminal and not user-overridable. Without this the light client would prove only that *a* lock exists while the reveal still ran on one node's account of what that lock says.
- `solverLockTermsMatch` / `solverLockDetailsMatch` (exported from `@train-protocol/sdk`) are the single definition of lock agreement, shared by the LC cross-check and `getSolverLockDetailsWithConsensus`. `…TermsMatch` compares the immutable deal (amount, sender, recipient, token, refundTo, timelock, payout curve); `…DetailsMatch` adds `status` and is for readings taken simultaneously across nodes.
- **Large swaps only**: the light client runs only when the swap's source amount is worth ≥ `TrainConfig.lightClientMinAmountUsd` (app constant `LIGHT_CLIENT_MIN_AMOUNT_USD` in `lib/lightClient/networks.ts`, currently $1000; USD value = `requestedAmount × prices["caip2Id:contract"]` from `NetworksProvider`). Smaller swaps use multi-RPC consensus only and never spawn the worker. A swap that cannot be valued (missing price) is treated as large. Threshold 0 disables the gate.
- Seam: `TrainConfig.resolveLightClient?: (networkId) => LightClientVerifier | null` (packages/react), implemented by `apps/app/lib/lightClient/index.ts` (`getLightClientVerifier` — per-network singletons, SSR-guarded) and injected in `app/providers.tsx`.
- `apps/app/lib/lightClient/heliosVerifier.ts` wraps the worker (`/workers/helios/heliosWorker.js`, vendored `@a16z/helios` **0.11.1** ESM with inlined WASM). The worker is a thin EIP-1193 bridge (`init`/`waitSynced`/`ethCall`); ABI encode/decode happens main-thread via `encodeGetSolverLockData`/`decodeGetSolverLockResult` from `@train-protocol/evm`, so the worker can never drift from the contract ABI. **The worker is short-lived**: helios re-syncs every slot while alive, so it is terminated as soon as the last in-flight verification settles (refcounted), and a warmed-up worker whose verification never starts is reaped after 5 min; the next verification re-inits from a fresh checkpoint (~3-6s). Any worker error/timeout also destroys the worker; the next attempt re-inits fresh.
- Beacon access goes through `app/api/beacon/[network]/[...path]/route.ts`, which **sanitizes** publicnode's broken `light_client/updates` endpoint (it ignores `start_period`/`count`) — a plain rewrite is not enough. Execution RPC is a public getProof-capable endpoint (publicnode); helios verifies all responses cryptographically, so no trusted/keyed RPC (e.g. Alchemy) is needed.
- Re-vendor procedure: `npm pack @a16z/helios@<version>`, copy `dist/lib.mjs` + `dist/lib.d.ts` into `apps/app/workers/helios/`, run `pnpm --filter train-app build:workers` (compiles the worker and copies `lib.mjs` to `public/workers/helios/`), then verify sync AND that `eth_blockNumber` matches an explorer — an outdated helios can "sync" while silently misdecoding post-fork data.

### Secret & Nonce
- Secret derived from: `deriveInitialKey()` + `deriveSecretFromTimelock(key, nonce)`
Expand Down Expand Up @@ -100,7 +110,6 @@ Key files:
```
NEXT_PUBLIC_TRAIN_API # Station API base URL (required)
NEXT_PUBLIC_API_VERSION # "sandbox" or "mainnet"
NEXT_PUBLIC_ALCHEMY_KEY # For light client RPC calls
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID # WalletConnect
NEXT_PUBLIC_POSTHOG_KEY # PostHog analytics
NEXT_PUBLIC_POSTHOG_HOST # PostHog host
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ Set the following in `apps/app/.env.local`:
```
NEXT_PUBLIC_TRAIN_API # Station API base URL
NEXT_PUBLIC_API_VERSION # "sandbox" or "mainnet"
NEXT_PUBLIC_ALCHEMY_KEY # for light-client RPC calls
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID # WalletConnect project id
```

Expand Down
2 changes: 0 additions & 2 deletions apps/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ This repository contains implementation of TRAIN UI
NEXT_PUBLIC_TRAIN_API = https://atomic-dev.layerswap.cloud/
NEXT_PUBLIC_API_VERSION = sandbox #mainnet for mainnets


NEXT_PUBLIC_ALCHEMY_KEY = <YOUR_ALCHEMY_KEY> #required for light client calls
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID = <YOUR_WALLETCONNECT_PROJECT_ID>
```

209 changes: 209 additions & 0 deletions apps/app/app/api/beacon/[network]/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { NextRequest, NextResponse } from 'next/server'

/**
* Beacon-API proxy for the Helios light client.
*
* A plain rewrite is not enough: publicnode's `/eth/v1/beacon/light_client/updates`
* ignores `start_period`/`count` and returns its whole update cache — ~213 entries
* spanning mixed periods, ~12 MB — even when asked for a single period, which
* helios rejects with "invalid sync committee period". This handler passes every
* route through untouched except `light_client/updates`, which it filters down to
* the requested period window.
*
* Because that upstream response is ~210x larger than the 1-3 entries any request
* actually needs, it is not re-fetched per request. Entries are cached per period
* (they are finalized history once their period completes) and the filtered
* response carries `Cache-Control` so the edge absorbs repeats without invoking
* this function at all.
*/

const UPSTREAMS: Record<string, string> = {
sepolia: 'https://ethereum-sepolia-beacon-api.publicnode.com',
mainnet: 'https://ethereum-beacon-api.publicnode.com',
}

const SLOTS_PER_SYNC_COMMITTEE_PERIOD = 8192
/** Beacon spec `MAX_REQUEST_LIGHT_CLIENT_UPDATES` — the window when the caller names none. */
const DEFAULT_UPDATE_COUNT = 128
const UPSTREAM_TIMEOUT_MS = 20_000
/** Guard against an upstream that streams without end; publicnode's cache is ~12 MB. */
const MAX_UPDATES_BYTES = 32 * 1024 * 1024
/** Helios only ever walks the last few periods; this is ~36 days of them. */
const MAX_CACHED_PERIODS = 32
/** How long a populated cache may answer before we re-check upstream for a new period. */
const CACHE_TTL_MS = 60_000

/** A window entirely below the newest known period is finalized history. */
const COMPLETED_WINDOW_CACHE = 'public, s-maxage=86400, stale-while-revalidate=86400'
/** A window touching the newest period can still gain a better-participation update. */
const CURRENT_WINDOW_CACHE = 'public, s-maxage=60, stale-while-revalidate=600'

interface LightClientUpdate {
data?: {
signature_slot?: string
attested_header?: { beacon?: { slot?: string } }
}
}

/** `${network}:${period}` → update. Insertion-ordered, oldest period evicted first. */
const updateCache = new Map<string, LightClientUpdate>()
const lastFetchedAt = new Map<string, number>()
const newestPeriod = new Map<string, number>()

/**
* The spec addresses updates by the period of `attested_header.beacon.slot`.
* `signature_slot` is at least one slot later and can land in the next period at
* a boundary, which would file the update under the wrong key — and, once cached,
* serve it for a window it does not belong to.
*/
function periodOf(update: LightClientUpdate): number | null {
const slot = update?.data?.attested_header?.beacon?.slot ?? update?.data?.signature_slot
const parsed = Number(slot)
return Number.isFinite(parsed) ? Math.floor(parsed / SLOTS_PER_SYNC_COMMITTEE_PERIOD) : null
}

function rememberUpdates(network: string, updates: LightClientUpdate[]): void {
const byPeriod = new Map<number, LightClientUpdate>()
for (const update of updates) {
const period = periodOf(update)
if (period !== null) byPeriod.set(period, update)
}
if (!byPeriod.size) return

// Insert ascending so LRU eviction keeps the newest periods — the only ones
// helios ever asks for.
for (const period of [...byPeriod.keys()].sort((a, b) => a - b)) {
const key = `${network}:${period}`
updateCache.delete(key)
updateCache.set(key, byPeriod.get(period)!)
}
while (updateCache.size > MAX_CACHED_PERIODS) {
const oldest = updateCache.keys().next().value
if (oldest === undefined) break
updateCache.delete(oldest)
}

newestPeriod.set(network, Math.max(...byPeriod.keys()))
lastFetchedAt.set(network, Date.now())
}

/** Ascending by period, one entry per period — the order helios verifies in. */
function selectWindow(updates: LightClientUpdate[], start: number, count: number): LightClientUpdate[] {
const byPeriod = new Map<number, LightClientUpdate>()
for (const update of updates) {
const period = periodOf(update)
if (period !== null && period >= start && period < start + count) byPeriod.set(period, update)
}
return [...byPeriod.keys()].sort((a, b) => a - b).map(period => byPeriod.get(period)!)
}

function readWindow(network: string, start: number, count: number): LightClientUpdate[] {
const window: LightClientUpdate[] = []
for (let period = start; period < start + count; period++) {
const hit = updateCache.get(`${network}:${period}`)
if (hit) window.push(hit)
}
return window
}

/** Buffer a JSON body with a hard byte ceiling, so a broken upstream can't exhaust the function. */
async function readJsonCapped(response: Response, maxBytes: number): Promise<unknown> {
const reader = response.body?.getReader()
if (!reader) throw new Error('Upstream returned no body')

const chunks: Uint8Array[] = []
let total = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.length
if (total > maxBytes) {
await reader.cancel()
throw new Error(`Upstream response exceeded ${maxBytes} bytes`)
}
chunks.push(value)
}

const body = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.length
}
return JSON.parse(new TextDecoder().decode(body))
}

function windowResponse(network: string, window: LightClientUpdate[], start: number, count: number) {
const known = newestPeriod.get(network)
const historical = known !== undefined && start + count - 1 < known
return NextResponse.json(window, {
headers: { 'cache-control': historical ? COMPLETED_WINDOW_CACHE : CURRENT_WINDOW_CACHE },
})
}

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ network: string; path: string[] }> },
) {
const { network, path } = await params
if (!Object.hasOwn(UPSTREAMS, network)) {
return NextResponse.json({ error: 'Unknown network' }, { status: 404 })
}
const upstream = UPSTREAMS[network]

const pathname = path.join('/')
const isUpdates = pathname.endsWith('light_client/updates')

// Non-finite params would make every period comparison false and silently
// return an empty window, which helios cannot distinguish from a real miss.
const rawStart = request.nextUrl.searchParams.get('start_period')
const rawCount = request.nextUrl.searchParams.get('count')
const start = rawStart === null ? 0 : Number(rawStart)
const count = rawCount === null ? DEFAULT_UPDATE_COUNT : Number(rawCount)
if (isUpdates && (!Number.isInteger(start) || !Number.isInteger(count) || start < 0 || count <= 0)) {
return NextResponse.json({ error: 'Invalid start_period or count' }, { status: 400 })
}

// Serve the window from cache while it is fresh: the upstream payload is ~210x
// the size of the answer, so re-fetching it per request is the whole problem.
if (isUpdates) {
const fetchedAt = lastFetchedAt.get(network) ?? 0
const cached = readWindow(network, start, count)
if (Date.now() - fetchedAt < CACHE_TTL_MS && cached.length) {
return windowResponse(network, cached, start, count)
}
}

const url = `${upstream}/${pathname}${request.nextUrl.search}`
let response: Response
try {
response = await fetch(url, {
headers: { accept: request.headers.get('accept') ?? 'application/json' },
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
cache: 'no-store',
})
} catch {
return NextResponse.json({ error: 'Beacon upstream unreachable' }, { status: 502 })
}

if (!isUpdates || !response.ok) {
return new NextResponse(response.body, {
status: response.status,
headers: { 'content-type': response.headers.get('content-type') ?? 'application/json' },
})
}

let updates: unknown
try {
updates = await readJsonCapped(response, MAX_UPDATES_BYTES)
if (!Array.isArray(updates)) throw new Error('not an array')
} catch {
return NextResponse.json({ error: 'Malformed updates response from upstream' }, { status: 502 })
}

// Answer from what we just fetched, not from the cache: `rememberUpdates`
// evicts down to the newest periods, so a window below that cut would come
// back empty even though upstream just handed us the entries.
rememberUpdates(network, updates as LightClientUpdate[])
return windowResponse(network, selectWindow(updates as LightClientUpdate[], start, count), start, count)
}
4 changes: 4 additions & 0 deletions apps/app/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import { SendErrorMessage } from "@/lib/telegram"
import { IsExtensionError } from "@/helpers/errorHelper"
import AppSettings from "@/lib/AppSettings"
import { useRpcConfigStore } from "@/stores/rpcConfigStore"
import { getLightClientVerifier } from "@/lib/lightClient"
import { LIGHT_CLIENT_MIN_AMOUNT_USD } from "@/lib/lightClient/networks"
import Loading from "@/components/Loading"

if (typeof window !== "undefined") {
Expand Down Expand Up @@ -120,6 +122,8 @@ function AppShell({ children, settings }: { children: React.ReactNode; settings:
<TrainProvider
baseUrl={AppSettings.TrainApiUri ?? ''}
resolveNodeUrls={resolveNodeUrls}
resolveLightClient={getLightClientVerifier}
lightClientMinAmountUsd={LIGHT_CLIENT_MIN_AMOUNT_USD}
initialNetworks={settings.networks}
secretDerivation={{ persist: true }}
>
Expand Down
2 changes: 1 addition & 1 deletion apps/app/components/Swap/Atomic/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const SwapForm: FC<SwapFormProps> = ({ polling = true, onQuoteChange }) => {
onQuoteChange?.(quote, solverId)
}, [quote, solverId, onQuoteChange])

const actionDisplayName = query?.buttonTextColor || "Swap now"
const actionDisplayName = query?.buttonTextColor || "Next"
const shouldConnectWallet = values.from && !wallets.length;

return <Form className={`h-full space-y-2 ${(isSubmitting) ? 'pointer-events-none' : 'pointer-events-auto'}`} >
Expand Down
Loading