Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a0d69ae
sidebar initial implementation
yasha-meursault Mar 30, 2026
e08f1cc
implement shadcn sidebar instead of custom one
yasha-meursault Mar 30, 2026
289e714
bring wallets button back to the picker
yasha-meursault Mar 30, 2026
d30c07f
small fixes
yasha-meursault Mar 30, 2026
efc7f22
redesign the sidebar, implement tabs in sidebar, implement settings m…
yasha-meursault Apr 1, 2026
ceae4c5
minor fixes
yasha-meursault Apr 1, 2026
fadd278
Merge remote-tracking branch 'origin/dev' into dev-sidebar
yasha-meursault Apr 1, 2026
34da76f
minor fixes
yasha-meursault Apr 1, 2026
18ce383
Merge remote-tracking branch 'origin/dev' into dev-sidebar
yasha-meursault Apr 8, 2026
252c6ef
small fixes
yasha-meursault Apr 8, 2026
93d9412
minor fixes
yasha-meursault Apr 8, 2026
46826fd
fix build
yasha-meursault Apr 8, 2026
70588ba
Merge branch 'dev' into dev-sidebar
arentant Apr 13, 2026
a409aaf
fix build
arentant Apr 13, 2026
648b805
sidebar initial implementation
yasha-meursault Apr 18, 2026
3e6d139
App Router migration follow-ups and cleanup
yasha-meursault Apr 22, 2026
8cd2f58
App Router: fix Tailwind content globs + migrate to Metadata API
yasha-meursault Apr 22, 2026
bb690ad
redesign sidebars login/wallets modal changing them to dialogue
yasha-meursault Apr 23, 2026
7b8ce44
Merge remote-tracking branch 'origin/dev' into dev-sidebar
yasha-meursault Apr 23, 2026
68c9cfd
Merge branch 'dev' into dev-sidebar
arentant Apr 23, 2026
f6e5e93
fix build
arentant Apr 23, 2026
8857c29
Replace settings-scoped overlay with a global AppDialogue; add named …
yasha-meursault Apr 23, 2026
7d89b60
small design fix
yasha-meursault Apr 23, 2026
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
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Tests use **vitest** (`pnpm test` in each package). Node.js >=20.9.0 required. P
## Architecture

**Monorepo** (pnpm workspaces):
- `apps/app` — Next.js 15 frontend (Pages Router, not App Router)
- `apps/app` — Next.js 15 frontend (App Router). Entry: `app/layout.tsx` (server, `export const dynamic = "force-dynamic"`) → `app/providers.tsx` (single consolidated client providers function). Route segments: `app/{page,swap,settings,transactions,nocookies}/page.tsx`. Root `error.tsx` + `not-found.tsx`. `middleware.ts` sets `Cache-Control: public, s-maxage=60, stale-while-revalidate`. Every page renders per request on the server (direct analog of Pages Router `getServerSideProps`); CDN caches responses by full URL via the middleware header.
- `packages/sdk` — `@train-protocol/sdk`: core HTLC protocol logic, API client, lock verification
- `packages/blockchains/` — chain-specific HTLC client implementations (`evm`, `solana`, `starknet`, `tron`, `aztec`)

Expand All @@ -31,6 +31,12 @@ Tests use **vitest** (`pnpm test` in each package). Node.js >=20.9.0 required. P
- **Zustand stores** (`apps/app/stores/`): `swapStore` (main swap state), `secretDerivationStore`, `balanceStore`, `walletStore`, `rpcConfigStore`, etc.
- **React Context** (`apps/app/context/`): `atomicContext` (HTLC contract interactions), `secretDerivationContext`, `swapAccounts` (wallet/account handling), `formWizardProvider` (multi-step forms), `evmConnectorsContext`

### Layout & navigation
- `ThemeWrapper` (inside providers) renders the persistent shell: left-side `AppSidebar` + top app-header strip (desktop) that holds `<PendingSwap />` + content area + `GlobalFooter`.
- `HeaderWithMenu` lives inside `Widget` (not the app shell) and contains back button, mobile-only wallet/menu cluster. On mobile `PendingSwap` also appears here (no top app-header on mobile).
- **Progress bar**: `progress` (`@badrap/bar-of-progress`) lives at module scope in `providers.tsx`. A single `useEffect` monkey-patches `history.pushState` + listens to `popstate` to `progress.start()` on navigation; the `pathname`-dep effect calls `progress.finish()` on settle. `history.replaceState` is intentionally NOT patched — internal URL-sync calls (e.g. `Swap/Atomic/index.tsx` syncing `/` ↔ `/swap` via `replaceState`) would otherwise start a bar that never finishes.
- **Maintenance fallback**: when `getSettings()` returns `null`, `app/providers.tsx` renders `<MaintananceContent />` in place of the full provider tree (inside `IntercomProvider` so `useIntercom` works). Root layout does NOT call `notFound()`.

### API Layer — Station API
`apps/app/lib/trainApiClient.ts` is a thin wrapper delegating to `@train-protocol/sdk`'s `TrainApiClient`. Uses SSE for streaming:
- `GET /api/v1/quote/stream` — quote streaming (events: `quote`, `done`)
Expand Down Expand Up @@ -77,6 +83,9 @@ Key files:
- `solver` in swapStore is a solverId string (e.g. `"plorex"`), not a wallet address
- For destination chain polling, use `getSolverLock` (not `getUserLock`)
- Contract functions: `userLock`/`redeemUser`/`refundUser`/`getUserLock` + `solverLock`/`redeemSolver`/`getSolverLock`
- **Active swap modal**: the `VaulDrawer` that reads `useSwapStore.swapModalOpen` is mounted only inside `Swap/Atomic/index.tsx`, which renders on `/` only. Setting `swapModalOpen=true` from `/transactions` / `/settings` flips the flag but nothing opens. To open the active swap from any path, navigate to `/swap?sourceNetwork=X&txHash=Y` (see `handleViewSwap` in `SwapDetailsPanel` and `handleClick` in `PendingSwap`); `/swap` routes through `useRecoverSwap` to rehydrate the active hashlock.
- **`useSearchParams` placement**: callback-only reads (e.g. `useGoHome`, `useMenuNavigation.handleRecoverSwap`, `Widget.goBack` via `window.location.search`) are fine under `force-dynamic`. Render-time reads in providers / pages / form components also work because `force-dynamic` grants SSR access to the URL — no Suspense boundaries needed.
- **Persistent query params**: `buildHrefWithPersistantParams(pathname, searchParams, extraParams?)` (in `helpers/querryHelper.ts`) preserves keys defined in `Models/QueryParams` (widget embed params like `appName`, `hideLogo`, `lockNetwork`, etc.) across navigations. Use it for any programmatic `router.push` that should keep the embed state intact.

## Environment Variables

Expand Down
5 changes: 4 additions & 1 deletion apps/app/Models/Wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,14 @@ export enum AuthStep {

export enum MenuStep {
Menu = "Menu",
Login = "Login",
LoginStatus = "Login Status",
Transactions = "Transactions",
TransactionDetails = "Transaction Details",
RPCConfiguration = "RPC Configuration",
NetworkRPCEdit = "Network RPC Edit",
RecoverSwap = "Recover Swap"
RecoverSwap = "Recover Swap",
SuggestFeature = "Suggest a Feature"
}

export type Steps = AuthStep | SwapWithdrawalStep | SwapCreateStep | MenuStep
Expand Down
18 changes: 18 additions & 0 deletions apps/app/app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use client";

import { useEffect } from "react";
import ErrorFallback from "@/components/ErrorFallback";

export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);

return <ErrorFallback error={error} resetErrorBoundary={reset} />;
}
68 changes: 68 additions & 0 deletions apps/app/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import "../styles/globals.css";
import "../styles/dialog-transition.css";
import "../styles/vaul.css";
import type { Metadata, Viewport } from "next";
import { getSettings } from "@/lib/getSettings";
import { Providers } from "./providers";

export const dynamic = "force-dynamic";

const title = "TRAIN I The First Scalable Cross-Chain Bridge";
const description = "The trustless and permissionless way of cross-chain asset bridging & swapping. Move assets across blockchains without third parties, secured by a battle-tested system.";

const cookieCheckScript = `if(typeof window !== "undefined" && !window.location.pathname.includes("nocookies")){try { localStorage.getItem("ls-ls-test"); }catch (e) { window.location.href = "/nocookies"; }}`;

export const metadata: Metadata = {
metadataBase: new URL("https://app.train.tech"),
title,
description,
alternates: { canonical: "/" },
icons: {
icon: [
{ url: "/favicon/favicon-96x96.png", type: "image/png", sizes: "96x96" },
{ url: "/favicon/favicon.svg", type: "image/svg+xml" },
],
shortcut: "/favicon/favicon.ico",
apple: { url: "/favicon/apple-touch-icon.png", sizes: "180x180" },
},
manifest: "/favicon/site.webmanifest",
openGraph: {
type: "website",
url: "/",
title,
description,
images: [{ url: "/opengraph.jpg?v=2" }],
},
twitter: {
card: "summary_large_image",
title,
description,
images: ["/opengraphtw.jpg"],
},
other: {
"apple-mobile-web-app-title": title,
"msapplication-TileColor": "#ffffff",
"twitter:domain": "app.train.tech",
"twitter:url": "https://app.train.tech/",
},
};

export const viewport: Viewport = {
themeColor: "rgb(var(--ls-colors-secondary-900))",
};

export default async function RootLayout({ children }: { children: React.ReactNode }) {
const settings = await getSettings();
return (
<html lang="en" suppressHydrationWarning>
<head>
<script suppressHydrationWarning dangerouslySetInnerHTML={{ __html: cookieCheckScript }} />
</head>
<body>
<Providers settings={settings}>
{children}
</Providers>
</body>
</html>
);
}
10 changes: 5 additions & 5 deletions apps/app/pages/nocookies.tsx → apps/app/app/nocookies/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react'
import NoCookies from '../components/NoCookies';
import React from "react";
import NoCookies from "@/components/NoCookies";

export default function NoCookiesPage() {
return (
<div className={`flex flex-col items-center min-h-screen overflow-hidden relative font-robo`}>
<div className="flex flex-col items-center min-h-screen overflow-hidden relative font-robo">
<div className="w-full max-w-lg z-[1]">
<div className="flex content-center items-center justify-center space-y-5 flex-col container mx-auto sm:px-6 max-w-lg">
<div className="flex flex-col w-full text-primary-text">
Expand All @@ -12,5 +12,5 @@ export default function NoCookiesPage() {
</div>
</div>
</div>
)
}
);
}
55 changes: 55 additions & 0 deletions apps/app/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client";

import { Home } from "lucide-react";
import { useEffect } from "react";
import posthog from "posthog-js";
import MessageComponent from "@/components/MessageComponent";
import GoHomeButton from "@/components/utils/GoHome";
import ContactSupport from "@/components/ContactSupport";
import { Widget } from "@/components/Widget/Index";

export default function NotFound() {
useEffect(() => {
posthog.capture("404", {
name: "404",
path: typeof window !== "undefined" ? window.location.pathname : undefined,
});
}, []);

return (
<Widget hideMenu>
<Widget.Content center>
<MessageComponent>
<MessageComponent.Content icon="red" center>
<MessageComponent.Header>Page not found</MessageComponent.Header>
<MessageComponent.Description>
<p className="mx-auto text-center text-base font-normal leading-5 text-secondary-text px-9">
<span>We couldn&#39;t find a page with this link. If you believe there&#39;s an issue, please</span>
<ContactSupport>
<button
type="button"
className="mx-1 underline decoration-gray-400 underline-offset-2 hover:decoration-gray-200 focus:outline-none"
>
contact our support
</button>
</ContactSupport>
<span>and we&#39;ll help you fix it.</span>
</p>
</MessageComponent.Description>
</MessageComponent.Content>
<MessageComponent.Buttons>
<GoHomeButton>
<button
type="button"
className="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-secondary-300 px-5 py-4 text-base font-semibold leading-6 text-primary-text hover:bg-secondary-400 focus:outline-none transition"
>
<Home className="h-5 w-5" aria-hidden="true" />
<span>Back to app</span>
</button>
</GoHomeButton>
</MessageComponent.Buttons>
</MessageComponent>
</Widget.Content>
</Widget>
);
}
5 changes: 5 additions & 0 deletions apps/app/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Swap from "@/components/swapComponent";

export default function HomePage() {
return <Swap />;
}
176 changes: 176 additions & 0 deletions apps/app/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"use client"

import React, { useCallback, useEffect, useMemo } from "react"
import { usePathname, useSearchParams } from "next/navigation"
import { IntercomProvider } from "react-use-intercom"
import { SWRConfig } from "swr"
import { PostHogProvider } from "posthog-js/react"
import posthog from "posthog-js"
import { Analytics } from "@vercel/analytics/next"
import { ThemeProvider } from "next-themes"
import { ErrorBoundary } from "react-error-boundary"
import { registerEvmSdk } from "@train-protocol/evm"
import { TrainProvider } from "@train-protocol/react"
import ProgressBar from "@badrap/bar-of-progress"

import ThemeWrapper from "@/components/themeWrapper"
import MaintananceContent from "@/components/Maintanance"
import ErrorFallback from "@/components/ErrorFallback"
import WalletsProviders from "@/components/WalletProviders"
import { LoginModal } from "@/components/SecretDerivation"
import AppDialogue from "@/components/AppDialogue/AppDialogue"
import { TooltipProvider } from "@/components/shadcn/tooltip"
import { SettingsProvider } from "@/context/settings"
import { AsyncModalProvider } from "@/context/asyncModal"
import { SwapAccountsProvider } from "@/context/swapAccounts"
import QueryProvider from "@/context/query"
import { TrainAppSettings } from "@/Models/TrainAppSettings"
import { TrainSettings } from "@/Models/TrainSettings"
import { QueryParams } from "@/Models/QueryParams"
import { SendErrorMessage } from "@/lib/telegram"
import { IsExtensionError } from "@/helpers/errorHelper"
import AppSettings from "@/lib/AppSettings"
import { useRpcConfigStore } from "@/stores/rpcConfigStore"
import { useLoginModalStore } from "@/stores/loginModalStore"

if (typeof window !== "undefined") {
registerEvmSdk()
import("@train-protocol/aztec").then(m => m.registerAztecSdk())
import("@train-protocol/solana").then(m => m.registerSolanaSdk())
import("@train-protocol/starknet").then(m => m.registerStarknetSdk())
import("@train-protocol/tron").then(m => m.registerTronSdk())
}

const INTERCOM_APP_ID = "h5zisg78"
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com"

const progress = typeof window !== "undefined"
? new ProgressBar({ size: 2, color: "rgb(var(--ls-colors-primary))", className: "bar-of-progress", delay: 100 })
: null

if (typeof window !== "undefined" && posthogKey) {
posthog.init(posthogKey, {
api_host: posthogHost,
person_profiles: "identified_only",
loaded: (ph) => {
if (process.env.NODE_ENV === "development") ph.debug()
},
})
}

function logErrorToService(error: Error, info: { componentStack?: string | null }) {
const extension_error = IsExtensionError(error)
if (process.env.NEXT_PUBLIC_VERCEL_ENV && !extension_error) {
SendErrorMessage("UI error", `env: ${process.env.NEXT_PUBLIC_VERCEL_ENV} %0A url: ${process.env.NEXT_PUBLIC_VERCEL_URL} %0A message: ${error?.message} %0A errorInfo: ${info?.componentStack} %0A stack: ${error?.stack ?? error.stack} %0A`)
}
}

type Props = {
children: React.ReactNode
settings: TrainSettings | null
}

export function Providers({ children, settings }: Props) {
const pathname = usePathname()
const searchParams = useSearchParams()
const { getEffectiveRpcUrls } = useRpcConfigStore()
const loginOpen = useLoginModalStore(s => s.isOpen)
const closeLogin = useLoginModalStore(s => s.close)

useEffect(() => {
progress?.finish()
posthog?.capture("$pageview")
}, [pathname])

useEffect(() => {
if (!progress) return
const origPushState = history.pushState
history.pushState = function (...args) {
progress.start()
return origPushState.apply(this, args)
}
const onPopState = () => progress.start()
window.addEventListener('popstate', onPopState)
return () => {
history.pushState = origPushState
window.removeEventListener('popstate', onPopState)
}
}, [])

const resolveNodeUrls = useCallback((networkId: string) => {
const network = settings?.networks.find(n => n.caip2Id === networkId)
return network ? getEffectiveRpcUrls(network) : []
}, [settings, getEffectiveRpcUrls])

const query = useMemo<QueryParams>(() => {
const isTrue = (k: string) => searchParams?.get(k) === 'true'
return {
...Object.fromEntries(searchParams?.entries() ?? []),
lockNetwork: isTrue('lockNetwork'),
hideAddress: isTrue('hideAddress'),
hideFrom: isTrue('hideFrom'),
hideTo: isTrue('hideTo'),
lockFrom: isTrue('lockFrom'),
lockTo: isTrue('lockTo'),
lockAsset: isTrue('lockAsset'),
lockFromAsset: isTrue('lockFromAsset'),
lockToAsset: isTrue('lockToAsset'),
hideLogo: isTrue('hideLogo'),
}
}, [searchParams])

return (
<PostHogProvider client={posthog}>
<SWRConfig value={{ revalidateOnFocus: false, dedupingInterval: 5000 }}>
<ThemeProvider
attribute="data-theme"
defaultTheme="system"
themes={["default", "light"]}
storageKey="theme"
disableTransitionOnChange
enableSystem
value={{ light: "light", dark: "default" }}
>
<IntercomProvider appId={INTERCOM_APP_ID} initializeDelay={2500}>
{settings ? (
<QueryProvider query={query}>
<SettingsProvider data={new TrainAppSettings(settings)}>
<TooltipProvider delayDuration={500}>
<ErrorBoundary FallbackComponent={ErrorFallback} onError={logErrorToService}>
<TrainProvider
baseUrl={AppSettings.TrainApiUri ?? ''}
resolveNodeUrls={resolveNodeUrls}
initialNetworks={settings.networks}
secretDerivation={{ persist: true }}
>
<WalletsProviders appName={searchParams?.get('appName') ?? undefined}>
<ThemeWrapper>
<SwapAccountsProvider>
<AsyncModalProvider>
<LoginModal isOpen={loginOpen} onClose={closeLogin} />
<AppDialogue />
{process.env.NEXT_PUBLIC_IN_MAINTANANCE === 'true'
? <MaintananceContent />
: children}
</AsyncModalProvider>
</SwapAccountsProvider>
</ThemeWrapper>
</WalletsProviders>
</TrainProvider>
</ErrorBoundary>
</TooltipProvider>
</SettingsProvider>
</QueryProvider>
) : (
<div className="styled-scroll flex min-h-screen w-full items-center justify-center">
<MaintananceContent />
</div>
)}
</IntercomProvider>
</ThemeProvider>
</SWRConfig>
<Analytics />
</PostHogProvider>
)
}
Loading