This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
pnpm dev # Start Next.js dev server (apps/app)
pnpm build # Build all packages first, then the app
pnpm build:sdk # Build @train-protocol/sdk only
pnpm build:packages # Build all workspace packages
pnpm --filter train-app build:workers # Compile worker bundles (tsconfig.worker.json)
# SDK development
pnpm --filter @train-protocol/sdk dev # Watch mode for SDK
pnpm --filter @train-protocol/sdk check:types # Type-check SDKThe app has no lint script and no in-app test setup — tests live in packages (vitest), notably packages/sdk (pnpm --filter @train-protocol/sdk test). Node.js >=20.9.0 required. Package manager: pnpm 10.20.0.
Monorepo (pnpm workspaces — apps/* and packages/**):
apps/app— Next.js 15.5 frontend (App Router). Entry:app/layout.tsx(server, awaitsgetSettings()) →app/providers.tsx(consolidated client providers). The provider tree is split: outer (PostHog, SWR, Theme, Intercom) and innerAppShell(Settings, Tooltip, Train, Wallets, ThemeWrapper, ErrorBoundary, SwapAccounts, AsyncModal). The persistent shell (ThemeWrapper— sidebar/navbar/footer) is rendered above the Suspense boundary so it never blanks. Only<QueryProvider>(which readsuseSearchParamsinternally) plusAuthDialog,SwapModalRoot, and the pagechildrensit inside<Suspense fallback={null}>. Route segments:app/{page,swap,settings,transactions,nocookies}/page.tsx. Rooterror.tsx+not-found.tsx.middleware.tssetsCache-Control: public, s-maxage=60, stale-while-revalidate. The layout is no longerforce-dynamic—getSettingsis cached viaunstable_cache(60s revalidate) and all routes build as○ Static. Sidebar<Link>s use Next 15 default prefetch (eager prefetch on viewport entry for static routes); the logo opts out viaprefetch={false}since it points at/. All four sidebar nav targets (logo, Home, History, Settings) preserve persistent embed query params viabuildHrefWithPersistantParams. Web Workers live inapps/app/workers/(e.g.helioslight client) and are built viabuild:workers.apps/packagesdemo— small Next.js Pages-Router demo app for exercising published SDK packages.packages/sdk—@train-protocol/sdk: core HTLC protocol logic,TrainApiClient, lock verification, consensus helpers (vitest tests in__tests__/).packages/blockchains/— chain-specific HTLC client implementations:evm,solana,starknet,tron,aztec, plus sharedutils.packages/auth—@train-protocol/authshared auth helpers.packages/react—@train-protocol/reactshared React utilities.
What the app does: Cross-chain atomic swaps using HTLC (Hash Time-Locked Contracts). Users lock funds on a source chain, a solver locks on the destination chain, then secrets are revealed to complete the swap. EVM is the primary chain; Solana, Starknet, TON, Aztec, Tron, and Fuel support is in progress (see FUEL_PHTLC.json).
- Zustand stores (
apps/app/stores/):swapStore(main swap state),walletStore,addressesStore,balanceStore,rpcConfigStore,authDialogStore,aztecWalletStore,starknetWalletStore,contractWalletsStore,recentRoutesStore,routeSortingStore,routeTokenSwitchStore,usdModeStore. There is nosecretDerivationStore— secret derivation lives in helpers/components. - React Context (
apps/app/context/):swapAccounts(wallet/account handling),formWizardProvider(multi-step forms),walletHookProviders(per-chain wallet hook composition),query(React Query),settings,snapPointsContext,timerContext,asyncModal. HTLC contract interactions and EVM connectors are no longer separate contexts — they're handled inline via wallet hooks and the SDK's HTLC clients.
ThemeWrapper(inside providers) renders the persistent shell: left-sideAppSidebar+ top app-header strip (desktop) that holds<PendingSwap />+ content area +GlobalFooter.HeaderWithMenulives insideWidget(not the app shell) and contains back button, mobile-only wallet/menu cluster. On mobilePendingSwapalso appears here (no top app-header on mobile).- Route loading skeletons:
app/{settings,transactions,swap,faucet}/loading.tsxeach inline a centeredLoader2spinner (identical body —flex items-center justify-center w-full min-h-93.5wrapper around<Loader2 className="h-10 w-10 text-primary animate-spin" />).components/Loading.tsxholds the same body for non-route Suspense boundaries. Copy the body into a newloading.tsxwhen adding a route that needs a Suspense fallback; let it diverge if that route eventually wants a different skeleton. - Maintenance fallback: when
getSettings()returnsnull,app/providers.tsxrenders<MaintananceContent />in place of the full provider tree (insideIntercomProvidersouseIntercomworks). Root layout does NOT callnotFound().
TrainApiClient is imported directly from @train-protocol/sdk (no app-side wrapper). Server-side instantiation lives in apps/app/lib/getSettings.ts (cached via unstable_cache). Uses SSE for streaming:
GET /api/v1/quote/stream— quote streaming (events:quote,done)GET /api/v1/orders/{hashlock}/stream?solverAddress=0x...— order status streamingPOST /api/v1/orders/{hashlock}/reveal-secret?solverAddress=0x...— reveal secret to solverGET /api/v1/networks— network/token metadataGET /api/v1/prices— token price datasourceNetworkparam must be a CAIP-2 ID (e.g."eip155:11155111")
userLock()— user locks funds with hashlock on source chain (single-step, no separate commit)- Poll
getSolverLock— wait for solver to lock on destination chain - 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; manualRevealSecretActionbutton and theswapPreferencesStore.autoRevealSecrettoggle have been removed. When verification isskipped(no on-chaindstAmountto compare against), reveal still proceeds, butSolverLockDetectedActionshows a "Verification skipped — proceeding with caution" banner. On reveal failure, a "Try again" button is rendered. - Swap complete when solver redeems
Key files:
apps/app/lib/abis/atomic/EVM_HTLC.json— unified EVM ABIapps/app/lib/abis/atomic/FUEL_PHTLC.json— Fuel PHTLC ABIpackages/blockchains/{evm,solana,starknet,tron,aztec}/src/client/— chain-specific HTLC implementations (PublicClient + WalletClient)apps/app/lib/wallets/{evm,solana,starknet,tron,aztec}/— per-chain wallet hooks (useEVM.ts,useStarknet.ts,useTron.ts,useAztec.ts, etc.) that bridge UI state to the SDK's HTLC clients
apps/app/lib/rpc/— RPC resolution:nodeResolver.ts(entry point),evmNodes.ts(static chainlist data fromdata/chainlistRpcs.json),nonEvmNodes.ts(static registry)resolveNodes(caip2Id)returns all available RPCs (existing nodes first, then dynamic/static). Called server-side ingetSettings.tsrpcConfigStoremanages user custom RPC overrides;getEffectiveRpcUrls(network)returns custom URLs ornetwork.nodes- Consensus verification:
getSolverLockDetailsWithConsensus()in SDK queries nodes in batches ofbatchSize(default 3), retries with next batch if quorum (minQuorum, default 2) not met. ReturnsConsensusResult { details, agreedCount } | null(theagreedCountis 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
consensusVerifiedref inuseSolverLockPolling), then falls back to single-node polling. The hook also tracksverifiedNodeCount(=1 on the single-node fast path, =agreedCountafter multi-node consensus) and writes it to swap flags so theVerificationStatusUI can render "Verified by N RPCs" accurately.
- Secret derived from:
deriveInitialKey()+deriveSecretFromTimelock(key, nonce) - Nonce =
Date.now()timestamp, stored in URL query params (for page refresh recovery) and on-chain viauserDatabytes field
- EVM: wagmi 2.14 + viem 2.44 (catalog-pinned in
pnpm-workspace.yaml) - Starknet: @starknet-react 5.0 + starknet.js 8.x
- Solana: @solana/web3.js 1.98 + wallet-adapter + @coral-xyz/anchor
- TON: @ton/ton 13.11 + @tonconnect/ui-react
- Tron: @tronweb3/tronwallet-adapter-* family
- Aztec: @aztec/aztec.js 4.1 + @aztec/wallet-sdk
- Fuel: fuels 0.101
- Code uses legacy "commit" naming (
CommitStatus,commitId) — these refer to locks, not a separate commit step hashlock === commitId— always present from lock creation, never null- Use
statusfield (notclaimed) for lock state:Pending(0),Redeemed(1),Refunded(2)— there is noCompleted(3) solverin swapStore is a solverId string (e.g."plorex"), not a wallet address- For destination chain polling, use
getSolverLock(notgetUserLock) - Contract functions:
userLock/redeemUser/refundUser/getUserLock+solverLock/redeemSolver/getSolverLock - Active swap modal: the
VaulDrawerthat readsuseSwapStore.swapModalOpenis mounted only insideSwap/Atomic/index.tsx, which renders on/only. SettingswapModalOpen=truefrom/transactions//settingsflips the flag but nothing opens. To open the active swap from any path, navigate to/swap?sourceNetwork=X&txHash=Y(seehandleViewSwapinSwapDetailsPanelandhandleClickinPendingSwap);/swaproutes throughuseRecoverSwapto rehydrate the active hashlock. useSearchParamsplacement: callback-only reads (e.g.useGoHome,useMenuNavigation.handleRecoverSwap,Widget.goBackviawindow.location.search) are fine anywhere. Render-time reads need a<Suspense>boundary, otherwise Next.js opts the segment out of static prerender. The single render-timeuseSearchParamsin the provider tree lives insideQueryProvider(context/query.tsx), which is wrapped in<Suspense fallback={null}>inproviders.tsx. Sidebar/navbar/useOptionalSecretDerivationconsumers do not read URL, so the shell renders above that boundary. New components that read URL at render time should consumeuseQueryState()(already inside the Suspense island viaQueryProvider) rather than callinguseSearchParamsdirectly.- Persistent query params:
buildHrefWithPersistantParams(pathname, searchParams, extraParams?)(inhelpers/querryHelper.ts) preserves keys defined inModels/QueryParams(widget embed params likeappName,hideLogo,lockNetwork, etc.) across navigations. Use it for any programmaticrouter.pushthat should keep the embed state intact.
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
NEXT_PUBLIC_VERCEL_ENV # Vercel env (production/preview/development)
NEXT_PUBLIC_VERCEL_URL # Vercel deployment URL