From 83e5622218ddcf21fe83ba9fa9a94ef39b53141a Mon Sep 17 00:00:00 2001 From: Aren Date: Wed, 4 Feb 2026 14:16:53 +0400 Subject: [PATCH 01/10] Implement initial passkey/wallet login --- .gitignore | 1 + components/SecretDerivation/LoginModal.tsx | 238 +++++++++++++++++ components/SecretDerivation/SignFlowModal.tsx | 66 +++++ components/SecretDerivation/index.ts | 4 + components/Swap/Atomic/index.tsx | 9 +- .../Swap/AtomicChat/Actions/UserActions.tsx | 32 ++- components/Swap/AtomicChat/index.tsx | 8 + components/Swap/FormButton.tsx | 27 +- components/WalletProviders/index.tsx | 45 ++-- context/secretDerivationContext.tsx | 250 ++++++++++++++++++ lib/htlc/secretDerivation/FLOW.md | 187 +++++++++++++ lib/htlc/secretDerivation/index.ts | 6 + lib/htlc/secretDerivation/keyDerivation.ts | 45 ++++ lib/htlc/secretDerivation/passkeyService.ts | 176 ++++++++++++ lib/htlc/secretDerivation/types.ts | 19 ++ lib/htlc/secretDerivation/walletSign/evm.ts | 48 ++++ lib/htlc/secretDerivation/walletSign/index.ts | 3 + lib/wallets/aztec/useAtomicAztec.ts | 16 ++ lib/wallets/evm/useAtomicEVM.ts | 13 + lib/wallets/fuel/useAtomicFuel.ts | 16 +- lib/wallets/solana/useAtomicSVM.ts | 16 ++ lib/wallets/starknet/useAtomicStarknet.ts | 18 +- lib/wallets/ton/useAtomicTON.ts | 18 +- package.json | 3 +- yarn.lock | 5 + 25 files changed, 1233 insertions(+), 36 deletions(-) create mode 100644 components/SecretDerivation/LoginModal.tsx create mode 100644 components/SecretDerivation/SignFlowModal.tsx create mode 100644 components/SecretDerivation/index.ts create mode 100644 context/secretDerivationContext.tsx create mode 100644 lib/htlc/secretDerivation/FLOW.md create mode 100644 lib/htlc/secretDerivation/index.ts create mode 100644 lib/htlc/secretDerivation/keyDerivation.ts create mode 100644 lib/htlc/secretDerivation/passkeyService.ts create mode 100644 lib/htlc/secretDerivation/types.ts create mode 100644 lib/htlc/secretDerivation/walletSign/evm.ts create mode 100644 lib/htlc/secretDerivation/walletSign/index.ts diff --git a/.gitignore b/.gitignore index 06629127..96b70249 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ storybook-static/* .sentryclirc .env*.local +pr-review-report.md \ No newline at end of file diff --git a/components/SecretDerivation/LoginModal.tsx b/components/SecretDerivation/LoginModal.tsx new file mode 100644 index 00000000..5ec2ff7b --- /dev/null +++ b/components/SecretDerivation/LoginModal.tsx @@ -0,0 +1,238 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useConfig } from 'wagmi'; +import { Fingerprint, Loader2, Wallet as WalletIcon, Plus, ChevronLeft } from 'lucide-react'; +import toast from 'react-hot-toast'; +import VaulModal from '../Modal/vaulModal'; +import { useSecretDerivation } from '@/context/secretDerivationContext'; +import useEVM from '@/lib/wallets/evm/useEVM'; +import { Wallet } from '@/Models/WalletProvider'; +import ConnectorsList from '@/components/WalletModal/ConnectorsList'; +import { useConnectModal } from '@/components/WalletModal'; + +type Step = 'pick' | 'wallet_select' | 'connect' | 'signing'; + +interface LoginModalProps { + isOpen: boolean; + onClose: () => void; +} + +const shortAddress = (address?: string) => { + if (!address) return ''; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; + +export function LoginModal({ isOpen, onClose }: LoginModalProps) { + const config = useConfig(); + const evmProvider = useEVM(); + const { setSelectedProvider, setSelectedConnector, setSelectedMultiChainConnector } = useConnectModal(); + const { loginWithPasskey, loginWithWallet, isPasskeySupported, derivationMessage } = useSecretDerivation(); + const [step, setStep] = useState('pick'); + const [isBusy, setIsBusy] = useState(false); + + const connectedWallets = useMemo( + () => evmProvider.connectedWallets?.filter(w => w.providerName?.toLowerCase() === 'evm') || [], + [evmProvider.connectedWallets] + ); + + useEffect(() => { + if (isOpen) { + setStep('pick'); + setIsBusy(false); + } + }, [isOpen]); + + useEffect(() => { + if (step === 'connect') { + setSelectedProvider(evmProvider); + setSelectedConnector(undefined); + setSelectedMultiChainConnector(undefined); + } + }, [step, evmProvider, setSelectedProvider, setSelectedConnector, setSelectedMultiChainConnector]); + + const closeAndReset = () => { + setSelectedProvider(undefined); + setSelectedConnector(undefined); + setSelectedMultiChainConnector(undefined); + onClose(); + }; + + const startPasskeyLogin = async () => { + setStep('signing'); + setIsBusy(true); + try { + await loginWithPasskey(); + toast.success('Logged in with passkey'); + closeAndReset(); + } catch (e: any) { + toast.error(e?.message || 'Passkey login failed'); + setStep('pick'); + } finally { + setIsBusy(false); + } + }; + + const startWalletLogin = async (wallet: Wallet) => { + setStep('signing'); + setIsBusy(true); + try { + await loginWithWallet(config, wallet); + toast.success('Logged in with wallet'); + closeAndReset(); + } catch (e: any) { + toast.error(e?.message || 'Wallet login failed'); + setStep('wallet_select'); + } finally { + setIsBusy(false); + } + }; + + const onConnectFinish = async (wallet?: Wallet) => { + if (!wallet) { + setStep('wallet_select'); + return; + } + await startWalletLogin(wallet); + }; + + const canGoBack = step === 'wallet_select' || step === 'connect'; + + const handleBack = () => { + if (step === 'connect') { + setStep('wallet_select'); + return; + } + if (step === 'wallet_select') { + setStep('pick'); + } + }; + + return ( + { + if (!show) closeAndReset(); + }} + header={step === 'signing' ? 'Signing' : 'Login to continue'} + modalId="secret-derivation-login-modal" + > + +
+ {(canGoBack || step === 'signing') && ( +
+ + + {step === 'signing' ? 'Waiting for signature' : ''} + +
+ )} + {step === 'pick' && ( + <> +

Choose how to login.

+
+ isPasskeySupported && startPasskeyLogin()} icon={Fingerprint} title="Passkey" description="Face ID, Touch ID, or Windows Hello" /> + setStep('wallet_select')} icon={WalletIcon} title="Wallet (EVM)" description="Select or connect an EVM wallet" /> +
+ + )} + + {step === 'wallet_select' && ( + <> +
+

Select a connected EVM wallet

+ +
+ +
+ {connectedWallets.length === 0 && ( +
+ No EVM wallets connected. +
+ )} + {connectedWallets.map((wallet) => ( + + ))} +
+ + + + )} + + {step === 'connect' && ( + <> +

Connect an EVM wallet

+ + + )} + + {step === 'signing' && ( +
+
+ +
+

+ {derivationMessage || 'Please sign…'} +

+

+ Complete the action in your passkey or wallet. Do not close this window. +

+
+ )} +
+
+
+ ); +} + + +const OptionItem = ({ onClick, icon: Icon, title, description }: { onClick: () => void, icon: (props: { className: string }) => React.ReactNode, title: string, description: string }) => { + return ( + + ) +} \ No newline at end of file diff --git a/components/SecretDerivation/SignFlowModal.tsx b/components/SecretDerivation/SignFlowModal.tsx new file mode 100644 index 00000000..58a981dc --- /dev/null +++ b/components/SecretDerivation/SignFlowModal.tsx @@ -0,0 +1,66 @@ +// components/SecretDerivation/SignFlowModal.tsx +// Single flow: choose method (if needed) → signing state until commit completes + +import { useSecretDerivation } from '@/context/secretDerivationContext'; +import { useEffect, useState } from 'react'; +import VaulModal from '../Modal/vaulModal'; +import { Loader2 } from 'lucide-react'; + +interface SignFlowModalProps { + isOpen: boolean; + onClose: () => void; + onComplete: () => void; + performCommit: () => Promise; +} + +export function SignFlowModal({ isOpen, onClose, onComplete, performCommit }: SignFlowModalProps) { + const { derivationMessage } = useSecretDerivation(); + const [commitStarted, setCommitStarted] = useState(false); + + useEffect(() => { + if (isOpen) { + setCommitStarted(false); + } + }, [isOpen]); + + // useEffect(() => { + // if (!isOpen || commitStarted) return; + + // setCommitStarted(true); + // performCommit() + // .then(() => { + // onComplete(); + // onClose(); + // }) + // .catch(() => { + // setCommitStarted(false); + // }); + // }, [isOpen, commitStarted, performCommit, onComplete, onClose]); + + return ( + { + if (!show) onClose(); + }} + header="Signing" + modalId="secret-derivation-sign-flow" + > + +
+
+
+ +
+

+ {derivationMessage || 'Please sign…'} +

+

+ Complete the action in your passkey or wallet. Do not close this window. +

+
+
+
+
+ ); +} diff --git a/components/SecretDerivation/index.ts b/components/SecretDerivation/index.ts new file mode 100644 index 00000000..cfc88530 --- /dev/null +++ b/components/SecretDerivation/index.ts @@ -0,0 +1,4 @@ +// components/SecretDerivation/index.ts + +export { LoginModal } from './LoginModal'; +export { SignFlowModal } from './SignFlowModal'; diff --git a/components/Swap/Atomic/index.tsx b/components/Swap/Atomic/index.tsx index 62e3f4d3..583695a1 100644 --- a/components/Swap/Atomic/index.tsx +++ b/components/Swap/Atomic/index.tsx @@ -21,6 +21,7 @@ import { generateSwapInitialValues } from "../../../lib/generateSwapInitialValue import { useSettingsState } from "../../../context/settings"; import { resolvePersistantQueryParams } from "../../../helpers/querryHelper"; import toast from "react-hot-toast"; +import { useSecretDerivation } from "../../../context/secretDerivationContext"; const AtomicPage = dynamicWithRetries( () => import("../AtomicChat/index.tsx") as unknown as Promise<{ default: React.ComponentType }>, @@ -41,6 +42,7 @@ export default function Form() { const { goToStep } = useFormWizardaUpdate() const { currentStepName } = useFormWizardState() const query = useQueryState() + const { isLoggedIn } = useSecretDerivation() const { updatePolling: pollFee, fee } = useFee() const { getProvider } = useWallet() @@ -53,6 +55,11 @@ export default function Form() { const handleSubmit = useCallback(async (values: SwapFormValues) => { try { + // Check if user has logged in (chosen a derivation method) + if (!isLoggedIn) { + throw new Error("Please login first") + } + if (!values.amount) { throw new Error("No amount specified") } @@ -99,7 +106,7 @@ export default function Form() { console.log(error) toast.error(error) } - }, [query, router, getProvider]) + }, [query, router, getProvider, isLoggedIn]) const initialValues: SwapFormValues = generateSwapInitialValues(settings, query) diff --git a/components/Swap/AtomicChat/Actions/UserActions.tsx b/components/Swap/AtomicChat/Actions/UserActions.tsx index 7f77f3cd..026458a7 100644 --- a/components/Swap/AtomicChat/Actions/UserActions.tsx +++ b/components/Swap/AtomicChat/Actions/UserActions.tsx @@ -9,12 +9,14 @@ import { useFee } from "../../../../context/feeContext"; import useCommitDetailsPolling from "../../../../hooks/htlc/useCommitDetailsPolling"; import useLockDetailsPolling from "../../../../hooks/htlc/useLockDetailsPolling"; import useRefundStatusPolling from "../../../../hooks/htlc/useRefundStatusPolling"; +import { SignFlowModal } from "@/components/SecretDerivation"; export const UserCommitAction: FC = () => { const { source_network, destination_network, amount, address, source_asset, destination_asset, onCommit, commitId, updateCommit, srcAtomicContract } = useAtomicState(); const { provider } = useWallet(source_network, 'withdrawal') const wallet = provider?.activeWallet const { fee } = useFee() + const [signFlowOpen, setSignFlowOpen] = useState(false) const atomicContract = srcAtomicContract const destLpAddress = fee?.quote?.destinationSolverAddress @@ -83,6 +85,10 @@ export const UserCommitAction: FC = () => { } } + const onConfirmClick = async () => { + setSignFlowOpen(true) + } + // Poll for commit details using SWR useCommitDetailsPolling({ network: source_network, @@ -105,15 +111,23 @@ export const UserCommitAction: FC = () => { Confirm in wallet : - - Confirm in wallet - + <> + + Confirm in wallet + + setSignFlowOpen(false)} + onComplete={() => setSignFlowOpen(false)} + performCommit={handleCommit} + /> + } } diff --git a/components/Swap/AtomicChat/index.tsx b/components/Swap/AtomicChat/index.tsx index b8993b59..5583cd43 100644 --- a/components/Swap/AtomicChat/index.tsx +++ b/components/Swap/AtomicChat/index.tsx @@ -2,12 +2,20 @@ import { FC } from "react"; import { Widget } from "../../Widget/Index"; import { Actions } from "./Actions"; import AtomicContent from "./AtomicContent"; +import { useSecretDerivation } from "../../../context/secretDerivationContext"; type ContainerProps = { type: "widget" | "contained", } const Commitment: FC = ({ type }) => { + const { isLoggedIn } = useSecretDerivation(); + + // Early return for safety (login already validated by FormButton) + if (!isLoggedIn) { + return null; + } + return ( <> diff --git a/components/Swap/FormButton.tsx b/components/Swap/FormButton.tsx index 386ad9c9..d504d594 100644 --- a/components/Swap/FormButton.tsx +++ b/components/Swap/FormButton.tsx @@ -5,10 +5,12 @@ import SwapButton from "../buttons/swapButton"; import { FormikErrors } from "formik"; import { SwapFormValues } from "../DTOs/SwapFormValues"; import KnownInternalNames from "../../lib/knownIds"; -import { FC } from "react"; +import { FC, useState } from "react"; import { useFormikContext } from "formik"; import useWallet from "../../hooks/useWallet"; import { useConnectModal } from "../WalletModal"; +import { useSecretDerivation } from "../../context/secretDerivationContext"; +import { LoginModal } from "../SecretDerivation"; const Address = dynamic( () => import("../Input/Address/index.tsx").then((mod) => mod.default), @@ -26,6 +28,29 @@ const FormButton = ({ actionDisplayName, shouldConnectDestinationWallet }) => { + const { isLoggedIn } = useSecretDerivation(); + const [loginOpen, setLoginOpen] = useState(false); + + // Check derivation method first (before any other checks) + if (!isLoggedIn) { + return ( + <> + + setLoginOpen(false)} + /> + + ); + } if (values.from && values.to && values.fromCurrency && values.toCurrency && values.amount && !quote && !isQuoteLoading) { return = ({ children, basePath, themeData, appName }) => { return ( - - - - - - - - - - - {children} - - - - - - - - - - + + + + + + + + + + + + {children} + + + + + + + + + + + ) } diff --git a/context/secretDerivationContext.tsx b/context/secretDerivationContext.tsx new file mode 100644 index 00000000..e7547924 --- /dev/null +++ b/context/secretDerivationContext.tsx @@ -0,0 +1,250 @@ +// context/secretDerivationContext.tsx + +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; +import { Wallet } from '@/Models/WalletProvider'; +import { + DerivationMethod, + checkPrfSupport, + deriveKeyWithPasskey, + deriveSecretFromTimelock +} from '@/lib/htlc/secretDerivation'; +import { deriveKeyFromEvmSignature } from '@/lib/htlc/secretDerivation/walletSign/evm'; + +const LOGIN_STATE_KEY = 'train:loginState'; + +export type DerivationStatus = 'idle' | 'signing'; + +interface StoredLoginState { + method: DerivationMethod; + derivedKey: string; // Hex string of the derived key from login + loginWallet?: { + address: string; + chainId?: string | number; + providerName: string; + displayName?: string; + }; +} + +interface SecretDerivationContextValue { + method: DerivationMethod | null; + isLoggedIn: boolean; + loginWallet: Wallet | null; + loginWithPasskey: () => Promise; + loginWithWallet: (config: any, wallet: Wallet) => Promise; + logout: () => void; + isPasskeySupported: boolean; + deriveInitialKey: (params: DeriveKeyParams) => Promise; + deriveSecret: (params: DeriveSecretParams) => Promise; + isReady: boolean; + /** Set while user is completing passkey or wallet sign */ + derivationStatus: DerivationStatus; + /** Message to show during signing, e.g. "Confirm with passkey" or "Please sign in your wallet" */ + derivationMessage: string; +} + +interface DeriveKeyParams { + chainId: string | number; + wallet?: Wallet; + config?: any; // Wagmi config for EVM + tonConnectUI?: any; // TON Connect UI +} + +interface DeriveSecretParams extends DeriveKeyParams { + timelock: number; +} + +const SecretDerivationContext = createContext(undefined); + +interface SecretDerivationProviderProps { + children: ReactNode; +} + +// LocalStorage helpers for login state persistence +const saveLoginState = (method: DerivationMethod, derivedKey: Buffer, wallet?: Wallet) => { + if (typeof window === 'undefined') return; + const state: StoredLoginState = { + method, + derivedKey: derivedKey.toString('hex'), + ...(wallet && { + loginWallet: { + address: wallet.address, + chainId: wallet.chainId, + providerName: wallet.providerName, + displayName: wallet.displayName, + } + }) + }; + window.localStorage.setItem(LOGIN_STATE_KEY, JSON.stringify(state)); +}; + +const loadLoginState = (): StoredLoginState | null => { + if (typeof window === 'undefined') return null; + try { + const stored = window.localStorage.getItem(LOGIN_STATE_KEY); + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } +}; + +const clearLoginState = () => { + if (typeof window === 'undefined') return; + window.localStorage.removeItem(LOGIN_STATE_KEY); +}; + +export function SecretDerivationProvider({ children }: SecretDerivationProviderProps) { + const [method, setMethodState] = useState(null); + const [isPasskeySupported, setIsPasskeySupported] = useState(false); + const [isReady, setIsReady] = useState(false); + const [derivationStatus, setDerivationStatus] = useState('idle'); + const [derivationMessage, setDerivationMessage] = useState(''); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [loginWallet, setLoginWallet] = useState(null); + const [storedDerivedKey, setStoredDerivedKey] = useState(null); + + // Check passkey support and restore login state on mount + useEffect(() => { + checkPrfSupport().then((supported) => { + setIsPasskeySupported(supported); + setIsReady(true); + }); + + // Restore login state from localStorage + const savedState = loadLoginState(); + if (savedState) { + setMethodState(savedState.method); + setIsLoggedIn(true); + // Restore derived key from hex string + setStoredDerivedKey(Buffer.from(savedState.derivedKey, 'hex')); + if (savedState.loginWallet) { + // Reconstruct wallet object from stored data + setLoginWallet(savedState.loginWallet as any); + } + } + }, []); + + const loginWithPasskey = useCallback(async () => { + setDerivationStatus('signing'); + setDerivationMessage('Confirm with your passkey'); + try { + const derivedKey = await deriveKeyWithPasskey(); + setMethodState('passkey'); + setIsLoggedIn(true); + setLoginWallet(null); + setStoredDerivedKey(derivedKey); + saveLoginState('passkey', derivedKey); + } finally { + setDerivationStatus('idle'); + setDerivationMessage(''); + } + }, []); + + const loginWithWallet = useCallback(async (config: any, wallet: Wallet) => { + if (wallet.providerName?.toLowerCase() !== 'evm') { + throw new Error('Only EVM wallets are supported for login right now'); + } + setDerivationStatus('signing'); + setDerivationMessage('Please sign in your wallet'); + try { + const derivedKey = await deriveKeyFromEvmSignature(config, wallet.address as `0x${string}`); + setMethodState('wallet_sign'); + setIsLoggedIn(true); + setLoginWallet(wallet); + setStoredDerivedKey(derivedKey); + saveLoginState('wallet_sign', derivedKey, wallet); + } finally { + setDerivationStatus('idle'); + setDerivationMessage(''); + } + }, []); + + const logout = useCallback(() => { + setIsLoggedIn(false); + setLoginWallet(null); + setMethodState(null); + setStoredDerivedKey(null); + // Clear login state from localStorage + clearLoginState(); + }, []); + + const deriveInitialKey = useCallback(async (params: DeriveKeyParams): Promise => { + const { wallet, config } = params; + + if (!method) { + throw new Error('No derivation method selected. Please choose passkey or wallet sign.'); + } + + // If we have a stored key from login, return it directly + if (storedDerivedKey) { + return storedDerivedKey; + } + + // Fallback: Re-authenticate if no stored key + if (method === 'passkey') { + return await deriveKeyWithPasskey(); + } + + // Wallet sign method + if (!wallet) { + throw new Error('Wallet required for wallet_sign method'); + } + + const providerName = wallet.providerName?.toLowerCase(); + + if (providerName === 'evm') { + if (!config) { + throw new Error('Wagmi config required for EVM wallets'); + } + return await deriveKeyFromEvmSignature(config, wallet.address as `0x${string}`); + } + + throw new Error(`Unsupported provider: ${providerName}`); + }, [method, storedDerivedKey]); + + const deriveSecret = useCallback(async (params: DeriveSecretParams): Promise => { + setDerivationStatus('signing'); + setDerivationMessage( + method === 'passkey' + ? 'Confirm with your passkey' + : 'Please sign in your wallet' + ); + try { + const { timelock, ...keyParams } = params; + const initialKey = await deriveInitialKey(keyParams); + const derivedKey = deriveSecretFromTimelock(initialKey, timelock); + return '0x' + derivedKey.toString('hex'); + } finally { + setDerivationStatus('idle'); + setDerivationMessage(''); + } + }, [deriveInitialKey, method]); + + const value: SecretDerivationContextValue = { + method, + isLoggedIn, + loginWallet, + loginWithPasskey, + loginWithWallet, + logout, + isPasskeySupported, + deriveInitialKey, + deriveSecret, + isReady, + derivationStatus, + derivationMessage, + }; + + return ( + + {children} + + ); +} + +export function useSecretDerivation() { + const context = useContext(SecretDerivationContext); + if (!context) { + throw new Error('useSecretDerivation must be used within SecretDerivationProvider'); + } + return context; +} diff --git a/lib/htlc/secretDerivation/FLOW.md b/lib/htlc/secretDerivation/FLOW.md new file mode 100644 index 00000000..439c0c4a --- /dev/null +++ b/lib/htlc/secretDerivation/FLOW.md @@ -0,0 +1,187 @@ +# Secret derivation flow + +End-to-end path from UI to HTLC secret/hashlock. + +--- + +## 1. App bootstrap (provider tree) + +``` +components/WalletProviders/index.tsx +``` + +- **SecretDerivationProvider** wraps the whole tree (outermost). +- Under it: TonConnect → Solana → **StarknetProvider** → EvmConnectors → Wagmi → … → **WalletProvidersProvider** → app. + +So any component under this tree can call `useSecretDerivation()`. + +--- + +## 2. Unified login modal + +``` +components/SecretDerivation/LoginModal.tsx +``` + +- **LoginModal** handles the entire login flow in a single modal: + - pick method → wallet select/connect (EVM only) → signing. +- Method is **not persisted**; login is session-only. + +**Where it opens:** + +### Primary: First page (FormButton - before "Swap now") +**`components/Swap/FormButton.tsx`** - The main swap form button checks `isLoggedIn`: +- If **not logged in** → shows **"Login to continue"** button. +- Clicking opens **LoginModal**. +- After successful login → **"Swap now"** button appears. + +**This enforces that users MUST login before they can swap.** + +--- + +## 3. User triggers "Commit" (HTLC create) + +``` +components/Swap/AtomicChat/Actions/UserActions.tsx → UserCommitAction +``` + +- User clicks commit. +- `handleCommit()` runs. +- `provider` comes from `useWallet(source_network, 'withdrawal')` (the wallet provider for the source chain). +- It calls: + + ```ts + provider.createPreHTLC({ address, amount, destinationChain, sourceChain, ... }) + ``` + +- `provider` is one of the objects returned by `useStarknet` / `useEVM` / `useSVM` / `useTON` / `useFuel` / `useAztec` (see step 4). + +--- + +## 4. Wallet provider → atomic hook → createPreHTLC + +Each chain has a "useX" hook that builds the `WalletProvider` and uses an atomic hook: + +| Chain | Wallet hook | Atomic hook | File | +|--------|------------------------|------------------------|---------------------------| +| Starknet | `useStarknet` | `useAtomicStarknet` | `lib/wallets/starknet/useStarknet.ts` → `useAtomicStarknet.ts` | +| EVM | `useEVM` | `useAtomicEVM` | `lib/wallets/evm/useEVM.ts` → `useAtomicEVM.ts` | +| Solana | `useSVM` | `useAtomicSVM` | `lib/wallets/solana/useAtomicSVM.ts` | +| TON | `useTON` | `useAtomicTON` | `lib/wallets/ton/useAtomicTON.ts` | +| Fuel | `useFuel` | `useAtomicFuel` | `lib/wallets/fuel/useAtomicFuel.ts` | +| Aztec | `useAztec` | `useAtomicAztec` | `lib/wallets/aztec/useAtomicAztec.ts` | + +Example (Starknet): + +- `context/walletHookProviders.tsx` → `useStarknet()` → returns `provider` with `createPreHTLC` and other methods. +- `useStarknet` (e.g. `lib/wallets/starknet/useStarknet.ts` line 131) calls `useAtomicStarknet({ starknetWallet, nodeUrl })` and spreads `atomicFunctions` (including `createPreHTLC`) onto `provider`. + +So the flow is: **UserActions** → `provider.createPreHTLC(...)` → **atomic hook's** `createPreHTLC` (e.g. `useAtomicStarknet`). + +--- + +## 5. createPreHTLC → deriveSecret (per chain) + +In each atomic file, `createPreHTLC`: + +1. Calls **useSecretDerivation()** and gets **deriveSecret**. +2. Calls **deriveSecret({ chainId, wallet, timelock })** (timelock from `calculateEpochTimelock(40)` or similar). +3. Builds `secret` buffer and **hashlock = sha256(secret)**. +4. Builds the chain-specific transaction (Starknet call, EVM tx, Solana tx, etc.). **Hashlock is not yet passed to the contract** in many places — see "Note: Add hashlock to args…" in each file. + +Files: + +- `lib/wallets/starknet/useAtomicStarknet.ts` (around 66) +- `lib/wallets/evm/useAtomicEVM.ts` (around 76) +- `lib/wallets/solana/useAtomicSVM.ts` (around 52) +- `lib/wallets/ton/useAtomicTON.ts` (around 39) +- `lib/wallets/fuel/useAtomicFuel.ts` (around 55) +- `lib/wallets/aztec/useAtomicAztec.ts` (around 38) + +--- + +## 6. deriveSecret implementation (context) + +``` +context/secretDerivationContext.tsx +``` + +- **deriveSecret(params)**: + 1. Reads **method** from context (passkey vs wallet_sign). + 2. If no method → throws "No derivation method selected". + 3. **deriveInitialKey(params)**: + - Returns stored key from login if available. + - Fallback re-authentication: + - **passkey** → `deriveKeyWithPasskey()` in `lib/htlc/secretDerivation/passkeyService.ts` (WebAuthn PRF with fixed identity salt). + - **wallet_sign** → `deriveKeyFromEvmSignature()` (EIP-712 signature with fixed identity salt). + 4. **deriveSecretFromTimelock(initialKey, timelock)** → `lib/htlc/secretDerivation/keyDerivation.ts` (HKDF with timelock salt). + 5. Returns secret as hex string. + +**Note:** The initial key derivation uses a fixed identity salt (`train-identity-v1`) rather than chain-specific salts. Secret uniqueness comes from the timelock parameter at commit time. + +--- + +## 7. Core crypto (key derivation) + +``` +lib/htlc/secretDerivation/ +``` + +- **keyDerivation.ts** + - `deriveKeyMaterial(ikm, salt)` — HKDF(sha2). + - `deriveSecretFromTimelock(initialKey, timelock)` — HKDF with timelock salt; returns 32-byte secret. + - `normalizeHex`, etc. + +- **passkeyService.ts** + - WebAuthn PRF: `deriveKeyWithPasskey()` → 32-byte key using fixed identity salt. + - `checkPrfSupport()`, `registerPasskey()`, stored credential ID. + +- **walletSign/evm.ts** + - EIP-712 typed data signature → `deriveKeyMaterial(signature, identitySalt)` → 32-byte key. + +--- + +## Flow diagram (summary) + +``` +[App] + SecretDerivationProvider + → FormButton: if not logged in → "Login to continue" (opens LoginModal) + → User completes login (passkey or EVM wallet) → "Swap now" appears + → Login derives initialKey using fixed identity salt, stored in context + +[User clicks Swap now → goes to page 2] + +[User clicks Commit] + UserActions.handleCommit() + → provider.createPreHTLC(...) + → useAtomicEVM / … createPreHTLC() + → useSecretDerivation().deriveSecret({ timelock, ... }) + → SecretDerivationContext.deriveSecret() + → deriveInitialKey() → returns stored key from login + → deriveSecretFromTimelock(initialKey, timelock) → HKDF with timelock + → secret → sha256 → hashlock + → build tx with hashlock +``` + +--- + +## UI/UX Flow Summary + +1. **First page (swap form):** User must click "Login to continue" → LoginModal handles method selection + EVM wallet selection/connection → "Swap now" button appears. +2. **Form submission guard:** When user clicks "Swap now", `handleSubmit` checks `isLoggedIn`: + - If **not logged in** → Shows error "Please login first" and prevents navigation to page 2. + - If **logged in** → Proceeds to page 2 (commit/atomic flow). +3. **Second page guard:** `AtomicChat` component checks on mount: + - If **not logged in** → Automatically redirects back to page 1 with error toast. + - If **logged in** → Renders the commit page normally. +4. **Commit action:** User clicks "Confirm in wallet" → SignFlowModal shows signing state while the wallet/passkey signature happens. +5. **Method is not persisted**; user logs in each session when needed. + +## Security Layers + +The implementation has **three layers of protection** to ensure users cannot access page 2 without logging in: + +1. **UI Layer:** "Login to continue" button instead of "Swap now" (in `FormButton.tsx`) +2. **Submit Layer:** Form submission validation blocks navigation (in `Atomic/index.tsx` `handleSubmit`) +3. **Page Guard:** Page 2 redirects back if accessed without login (in `AtomicChat/index.tsx`) diff --git a/lib/htlc/secretDerivation/index.ts b/lib/htlc/secretDerivation/index.ts new file mode 100644 index 00000000..155fc088 --- /dev/null +++ b/lib/htlc/secretDerivation/index.ts @@ -0,0 +1,6 @@ +// lib/htlc/secretDerivation/index.ts + +export * from './types'; +export * from './keyDerivation'; +export * from './passkeyService'; +export * from './walletSign'; diff --git a/lib/htlc/secretDerivation/keyDerivation.ts b/lib/htlc/secretDerivation/keyDerivation.ts new file mode 100644 index 00000000..b5af5fbb --- /dev/null +++ b/lib/htlc/secretDerivation/keyDerivation.ts @@ -0,0 +1,45 @@ +// lib/htlc/secretDerivation/keyDerivation.ts + +import { hkdf } from "@noble/hashes/hkdf.js"; +import { sha256 } from "@noble/hashes/sha2.js"; + +const HKDF_INFO = Buffer.from('train-signature-key-derivation', 'utf8'); +const KEY_LENGTH = 32; // 256 bits + +// Normalize hex string to even length +export const normalizeHex = (value: string): string => + value.length % 2 === 0 ? value : `0${value}`; + +// Core HKDF derivation +export const deriveKeyMaterial = ( + ikm: Uint8Array, + salt: Uint8Array +): Uint8Array => hkdf(sha256, ikm, salt, HKDF_INFO, KEY_LENGTH); + +// Get chain ID as hex for salt +export const getChainIdHex = (chainId: string | number): string => { + if (typeof chainId === 'string') { + return normalizeHex(chainId.startsWith('0x') ? chainId.slice(2) : chainId); + } + return normalizeHex(BigInt(chainId).toString(16)); +}; + +// Derive secret from initial key + timelock +export const deriveSecretFromTimelock = ( + initialKey: Buffer, + timelock: number +): Buffer => { + const timelockSalt = Buffer.from(normalizeHex(timelock.toString(16)), 'hex'); + return Buffer.from(deriveKeyMaterial(initialKey, timelockSalt)); +}; + +// Convert derived key to hex secret for HTLC +export const keyToHexSecret = (derivedKey: Buffer): string => { + return '0x' + derivedKey.toString('hex'); +}; + +// Generate hashlock from hex secret string (e.g., "0x1234..." returned by deriveSecret) +export const secretToHashlock = (secret: string): string => { + const secretBuffer = Buffer.from(secret.startsWith('0x') ? secret.slice(2) : secret, 'hex'); + return '0x' + Buffer.from(sha256(secretBuffer)).toString('hex'); +}; diff --git a/lib/htlc/secretDerivation/passkeyService.ts b/lib/htlc/secretDerivation/passkeyService.ts new file mode 100644 index 00000000..e973ed06 --- /dev/null +++ b/lib/htlc/secretDerivation/passkeyService.ts @@ -0,0 +1,176 @@ +// lib/htlc/secretDerivation/passkeyService.ts + +import { sha256 } from "@noble/hashes/sha2.js"; +import { deriveKeyMaterial } from './keyDerivation'; + +// Native base64URL utilities (replacing @simplewebauthn/browser) +const base64URLStringToBuffer = (base64url: string): ArrayBuffer => { + // Convert base64url to base64 + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + // Add padding if needed + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +}; + +const bufferToBase64URLString = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + // Convert to base64 then to base64url + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +const STORAGE_KEY = 'train:passkeyCredentialId'; +const IDENTITY_SALT = 'train-identity-v1'; + +// Storage helpers +export const getStoredCredentialId = (): string | null => { + if (typeof window === 'undefined') return null; + return window.localStorage.getItem(STORAGE_KEY); +}; + +export const storeCredentialId = (credId: string): void => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(STORAGE_KEY, credId); +}; + +// Generate PRF salt for identity derivation (not chain-specific) +export const getPasskeyPrfSalt = (): Uint8Array => { + const input = Buffer.from(`train-passkey-prf-salt-v1:${IDENTITY_SALT}`, 'utf8'); + return new Uint8Array(sha256(input)); +}; + +// Check if PRF extension is supported +export const checkPrfSupport = async (): Promise => { + if (typeof window === 'undefined') return false; + if (!window.isSecureContext) return false; + if (!window.PublicKeyCredential) return false; + + // Prefer PRF capability when available, but don't hard-fail if the API is missing or returns false. + try { + const capabilities = await PublicKeyCredential.getClientCapabilities?.(); + if (capabilities?.prf === true) return true; + } catch { + // Ignore capability errors and fall back to passkey availability checks. + } + + // Fallback: if platform authenticator is available or credentials API exists, allow passkey flow. + const uvpaa = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.(); + return Boolean(uvpaa ?? ('credentials' in navigator)); +}; + +// Register a new passkey credential +export const registerPasskey = async (): Promise => { + const existing = getStoredCredentialId(); + if (existing) return existing; + + if (typeof window === 'undefined') { + throw new Error('Passkey registration must run in a browser'); + } + if (!window.isSecureContext) { + throw new Error('Passkeys require HTTPS (secure context)'); + } + + // Generate random challenge and user ID + const challengeBytes = new Uint8Array(32); + window.crypto.getRandomValues(challengeBytes); + + const userIdBytes = new Uint8Array(16); + window.crypto.getRandomValues(userIdBytes); + + const publicKey: PublicKeyCredentialCreationOptions = { + challenge: challengeBytes, + rp: { + name: 'Train', + id: window.location.hostname + }, + user: { + id: userIdBytes, + name: 'train-user', + displayName: 'Train user', + }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], // ES256 + authenticatorSelection: { + authenticatorAttachment: 'platform', + residentKey: 'required', + userVerification: 'required', + }, + attestation: 'none', + timeout: 60000, + }; + + const credential = await navigator.credentials.create({ publicKey }) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to create passkey credential'); + } + + // Convert credential ID to base64url string + const credentialId = bufferToBase64URLString(credential.rawId); + + // Save credential id + storeCredentialId(credentialId); + return credentialId; +}; + +// Derive initial key using passkey PRF +export const deriveKeyWithPasskey = async (): Promise => { + if (typeof window === 'undefined') { + throw new Error('Passkey auth must run in a browser'); + } + if (!window.isSecureContext) { + throw new Error('Passkeys require HTTPS (secure context)'); + } + + // Ensure we have a registered credential + const credentialIdB64 = await registerPasskey(); + + // Generate random challenge for this authentication + const challengeBytes = new Uint8Array(32); + window.crypto.getRandomValues(challengeBytes); + + const prfSalt = getPasskeyPrfSalt(); + + const publicKey: PublicKeyCredentialRequestOptions = { + rpId: window.location.hostname, + challenge: challengeBytes, + userVerification: 'required', + allowCredentials: [ + { + type: 'public-key', + id: base64URLStringToBuffer(credentialIdB64), + }, + ], + // PRF extension - TS types may not include this + extensions: { + prf: { + evalByCredential: { + [credentialIdB64]: { first: prfSalt }, + }, + }, + } as any, + }; + + const cred = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential; + + // Get PRF output from extension results + const ext: any = cred.getClientExtensionResults?.() ?? {}; + const prfFirst: ArrayBuffer | undefined = ext?.prf?.results?.first; + + if (!prfFirst) { + throw new Error('Passkey PRF extension not available in this browser/authenticator'); + } + + // PRF output is 32 bytes per spec + const ikm = new Uint8Array(prfFirst); + const identitySalt = Buffer.from(IDENTITY_SALT, 'utf8'); + + return Buffer.from(deriveKeyMaterial(ikm, identitySalt)); +}; diff --git a/lib/htlc/secretDerivation/types.ts b/lib/htlc/secretDerivation/types.ts new file mode 100644 index 00000000..276cb814 --- /dev/null +++ b/lib/htlc/secretDerivation/types.ts @@ -0,0 +1,19 @@ +// lib/htlc/secretDerivation/types.ts + +export type DerivationMethod = 'passkey' | 'wallet_sign'; + +export interface SecretDerivationState { + method: DerivationMethod | null; + isLoggedIn: boolean; + isPasskeySupported: boolean; + derivationStatus: 'idle' | 'signing'; + derivationMessage: string; +} + +export interface SecretDerivationActions { + loginWithPasskey: (chainId: string | number) => Promise; + loginWithWallet: (config: any, wallet: any, chainId: number) => Promise; + logout: () => void; + deriveInitialKey: (params: any) => Promise; + deriveSecret: (params: any) => Promise; +} diff --git a/lib/htlc/secretDerivation/walletSign/evm.ts b/lib/htlc/secretDerivation/walletSign/evm.ts new file mode 100644 index 00000000..c4fa2b15 --- /dev/null +++ b/lib/htlc/secretDerivation/walletSign/evm.ts @@ -0,0 +1,48 @@ +// lib/htlc/secretDerivation/walletSign/evm.ts + +import { signTypedData } from '@wagmi/core'; +import { Config } from 'wagmi'; +import { deriveKeyMaterial } from '../keyDerivation'; + +const IDENTITY_SALT = 'train-identity-v1'; + +// EIP-712 typed data for signature (chainId=1 for consistent signatures across chains) +export const getEvmTypedData = () => ({ + domain: { + name: 'Train', + version: '1', + chainId: 1, + }, + types: { + Message: [ + { name: 'content', type: 'string' }, + ], + }, + primaryType: 'Message' as const, + message: { + content: 'I am using TRAIN', + }, +}); + +// Derive key from EVM wallet signature +export const deriveKeyFromEvmSignature = async ( + config: Config, + address: `0x${string}` +): Promise => { + const { domain, types, primaryType, message } = getEvmTypedData(); + + const signature = await signTypedData(config, { + account: address, + domain, + types, + primaryType, + message, + }); + + // Use full signature as input key material + const signatureHex = signature.startsWith('0x') ? signature.slice(2) : signature; + const inputMaterial = Buffer.from(signatureHex, 'hex'); + const identitySalt = Buffer.from(IDENTITY_SALT, 'utf8'); + + return Buffer.from(deriveKeyMaterial(inputMaterial, identitySalt)); +}; diff --git a/lib/htlc/secretDerivation/walletSign/index.ts b/lib/htlc/secretDerivation/walletSign/index.ts new file mode 100644 index 00000000..6cbc5c0c --- /dev/null +++ b/lib/htlc/secretDerivation/walletSign/index.ts @@ -0,0 +1,3 @@ +// lib/htlc/secretDerivation/walletSign/index.ts + +export * from './evm'; diff --git a/lib/wallets/aztec/useAtomicAztec.ts b/lib/wallets/aztec/useAtomicAztec.ts index cc60f8bd..2d9dfbb4 100644 --- a/lib/wallets/aztec/useAtomicAztec.ts +++ b/lib/wallets/aztec/useAtomicAztec.ts @@ -7,6 +7,9 @@ import { getAztecSecret } from "./secretUtils" import { combineHighLow, highLowToHexValidated, trimTo30Bytes } from "./utils" import formatAmount from "../../formatAmount" import { TrainContract } from "./Train" +import { useSecretDerivation } from "@/context/secretDerivationContext" +import { secretToHashlock } from "@/lib/htlc/secretDerivation" +import { calculateEpochTimelock } from "../utils/calculateTimelock" export interface UseAtomicAztecParams { wallet: any @@ -24,11 +27,24 @@ export interface AtomicAztecFunctions { export default function useAtomicAztec(params: UseAtomicAztecParams): AtomicAztecFunctions { const { wallet, accountAddress, aztecNodeUrl } = params + const { deriveSecret } = useSecretDerivation() const createPreHTLC = async (params: CreatePreHTLCParams) => { if (!wallet) throw new Error("No wallet connected"); + + // Secret derivation for HTLC with hashlock + const chainId = params.chainId || 'aztec-mainnet'; + const timelock = calculateEpochTimelock(40); + const secret = await deriveSecret({ + chainId, + wallet: { metadata: { wallet }, providerName: 'aztec' } as any, + timelock + }); + const hashlock = secretToHashlock(secret); + const { commitTransactionBuilder } = await import('./transactionBuilder.ts') + // Note: Add hashlock to transaction params when contract supports it const tx = await commitTransactionBuilder({ senderWallet: wallet, aztecNodeUrl, diff --git a/lib/wallets/evm/useAtomicEVM.ts b/lib/wallets/evm/useAtomicEVM.ts index bfd56f9c..716280a6 100644 --- a/lib/wallets/evm/useAtomicEVM.ts +++ b/lib/wallets/evm/useAtomicEVM.ts @@ -12,6 +12,8 @@ import formatAmount from "../../formatAmount" import LayerSwapApiClient from "../../trainApiClient" import resolveChain from "../../resolveChain" import { calculateEpochTimelock } from "../utils/calculateTimelock" +import { useSecretDerivation } from "@/context/secretDerivationContext" +import { secretToHashlock } from "@/lib/htlc/secretDerivation" export interface UseAtomicEVMParams { config: Config @@ -32,6 +34,7 @@ export interface AtomicEVMFunctions { export default function useAtomicEVM(params: UseAtomicEVMParams): AtomicEVMFunctions { const { config, account, evmAccount, networks, getEffectiveRpcUrls } = params + const { deriveSecret } = useSecretDerivation() const createPreHTLC = async (params: CreatePreHTLCParams) => { const { destinationChain, destinationAsset, sourceAsset, srcLpAddress: lpAddress, address, amount, decimals, atomicContract, chainId } = params @@ -70,6 +73,16 @@ export default function useAtomicEVM(params: UseAtomicEVMParams): AtomicEVMFunct const id = `0x${generateBytes32Hex()}`; + // Secret derivation for HTLC with hashlock + const secret = await deriveSecret({ + chainId: Number(chainId), + wallet: account.wallet, + config, + timelock + }); + const hashlock = secretToHashlock(secret); + + // Note: Add hashlock to args array when contract supports it let simulationData: any = { account: account.address as `0x${string}`, abi: abi, diff --git a/lib/wallets/fuel/useAtomicFuel.ts b/lib/wallets/fuel/useAtomicFuel.ts index 9227eba1..8cb56a18 100644 --- a/lib/wallets/fuel/useAtomicFuel.ts +++ b/lib/wallets/fuel/useAtomicFuel.ts @@ -1,10 +1,13 @@ import { Address } from '@fuel-ts/address' import { concat, DateTime } from "@fuel-ts/utils" import { Contract } from "@fuel-ts/program" -import { Account, B256Coder, BigNumberCoder, bn, Provider, sha256 } from 'fuels' +import { Account, B256Coder, BigNumberCoder, bn, Provider } from 'fuels' +import { sha256 } from "@noble/hashes/sha2.js" import { CreatePreHTLCParams, CommitmentParams, LockParams, RefundParams, ClaimParams } from "../../../Models/phtlc" import contractAbi from "../../abis/atomic/FUEL_PHTLC.json" import LayerSwapApiClient from "../../trainApiClient" +import { useSecretDerivation } from "@/context/secretDerivationContext" +import { secretToHashlock } from "@/lib/htlc/secretDerivation" function generateUint256Hex() { const bytes = new Uint8Array(32); @@ -30,6 +33,7 @@ export interface AtomicFuelFunctions { export default function useAtomicFuel(params: UseAtomicFuelParams): AtomicFuelFunctions { const { wallet, fuelProvider } = params + const { deriveSecret } = useSecretDerivation() const createPreHTLC = async (params: CreatePreHTLCParams) => { const createEmptyArray = (length: number, char: string) => @@ -48,6 +52,16 @@ export default function useAtomicFuel(params: UseAtomicFuelParams): AtomicFuelFu if (!fuelProvider) throw new Error('Node url not found') if (!wallet) throw new Error('Wallet not connected') + // Secret derivation for HTLC with hashlock + const chainId = params.chainId || 'fuel-mainnet'; + const secret = await deriveSecret({ + chainId, + wallet: { metadata: { wallet }, providerName: 'fuel' } as any, + timelock: timeLockMS + }); + const hashlock = secretToHashlock(secret); + + // Note: Add hashlock to contract call params when contract supports it const contractAddress = new Address(atomicContract); const contractInstance = new Contract(contractAddress, contractAbi, wallet); diff --git a/lib/wallets/solana/useAtomicSVM.ts b/lib/wallets/solana/useAtomicSVM.ts index 13a953ee..2fa38196 100644 --- a/lib/wallets/solana/useAtomicSVM.ts +++ b/lib/wallets/solana/useAtomicSVM.ts @@ -8,6 +8,9 @@ import { lockTransactionBuilder, phtlcTransactionBuilder } from "./transactionBu import LayerSwapApiClient from "../../trainApiClient" import { toHex } from "viem" import { AnchorWallet } from "@solana/wallet-adapter-react" +import { useSecretDerivation } from "@/context/secretDerivationContext" +import { secretToHashlock } from "@/lib/htlc/secretDerivation" +import { calculateEpochTimelock } from "../utils/calculateTimelock" function toHexString(byteArray: any) { return Array.from(byteArray, function (byte: any) { @@ -34,6 +37,7 @@ export interface AtomicSVMFunctions { export default function useAtomicSVM(params: UseAtomicSVMParams): AtomicSVMFunctions { const { connection, signTransaction, signMessage, publicKey, network, anchorProvider } = params + const { deriveSecret } = useSecretDerivation() const createPreHTLC = async (params: CreatePreHTLCParams): Promise<{ hash: string; commitId: string; } | null | undefined> => { const { atomicContract, sourceAsset } = params @@ -41,6 +45,18 @@ export default function useAtomicSVM(params: UseAtomicSVMParams): AtomicSVMFunct if (!program || !publicKey || !network) return null + // Secret derivation for HTLC with hashlock + const chainId = network.chainId || 'solana-mainnet'; + const timelock = calculateEpochTimelock(40); + const solanaWallet = { signMessage }; + const secret = await deriveSecret({ + chainId, + wallet: { metadata: { wallet: solanaWallet }, providerName: 'solana' } as any, + timelock + }); + const hashlock = secretToHashlock(secret); + + // Note: Add hashlock to transaction params when contract supports it const transaction = await phtlcTransactionBuilder({ connection, program, walletPublicKey: publicKey, network, ...params }) const signed = transaction?.initAndCommit && signTransaction && await signTransaction(transaction.initAndCommit); diff --git a/lib/wallets/starknet/useAtomicStarknet.ts b/lib/wallets/starknet/useAtomicStarknet.ts index 4fa1f3ed..a541e51f 100644 --- a/lib/wallets/starknet/useAtomicStarknet.ts +++ b/lib/wallets/starknet/useAtomicStarknet.ts @@ -1,7 +1,6 @@ import { cairo, Call, constants, Contract, RpcProvider, shortString, TypedData, TypedDataRevision } from "starknet" import { ethers } from "ethers" import { toHex } from "viem" -import { Network } from "../../../Models/Network" import { CreatePreHTLCParams, CommitmentParams, LockParams, RefundParams, ClaimParams, GetCommitsParams } from "../../../Models/phtlc" import { Commit } from "../../../Models/phtlc/PHTLC" import PHTLCAbi from "../../abis/atomic/STARKNET_PHTLC.json" @@ -9,6 +8,8 @@ import ETHABbi from "../../abis/STARKNET_ETH.json" import formatAmount from "../../formatAmount" import LayerSwapApiClient from "../../trainApiClient" import { calculateEpochTimelock } from "../utils/calculateTimelock" +import { useSecretDerivation } from "@/context/secretDerivationContext" +import { secretToHashlock } from "@/lib/htlc/secretDerivation" export interface UseAtomicStarknetParams { starknetWallet: any @@ -27,6 +28,7 @@ export interface AtomicStarknetFunctions { export default function useAtomicStarknet(params: UseAtomicStarknetParams): AtomicStarknetFunctions { const { starknetWallet, nodeUrl } = params + const { deriveSecret } = useSecretDerivation() const createPreHTLC = async (params: CreatePreHTLCParams) => { const { destinationChain, destinationAsset, sourceAsset, srcLpAddress: lpAddress, address, tokenContractAddress, amount, decimals, atomicContract: atomicAddress } = params @@ -57,6 +59,20 @@ export default function useAtomicStarknet(params: UseAtomicStarknetParams): Atom } const id = `0x${generateBytes32Hex()}` const timelock = calculateEpochTimelock(20); + + // Secret derivation for HTLC with hashlock + const chainId = process.env.NEXT_PUBLIC_API_VERSION === 'sandbox' + ? constants.StarknetChainId.SN_SEPOLIA + : constants.StarknetChainId.SN_MAIN; + const secret = await deriveSecret({ + chainId, + wallet: starknetWallet, + timelock + }); + const hashlock = secretToHashlock(secret); + + // Note: Add hashlock to args array when contract supports it + // For hashlock-based contracts, insert hashlock in the appropriate position const args = [ BigInt(id), parsedAmount, diff --git a/lib/wallets/ton/useAtomicTON.ts b/lib/wallets/ton/useAtomicTON.ts index a60060b3..532c6e00 100644 --- a/lib/wallets/ton/useAtomicTON.ts +++ b/lib/wallets/ton/useAtomicTON.ts @@ -1,4 +1,4 @@ -import { Address, beginCell, Cell, toNano } from "@ton/ton" +import { beginCell, Cell, toNano } from "@ton/ton" import { hexToBigInt } from "viem" import { Network } from "../../../Models/Network" import { CreatePreHTLCParams, CommitmentParams, LockParams, RefundParams, ClaimParams } from "../../../Models/phtlc" @@ -7,6 +7,8 @@ import { commitTransactionBuilder } from "./transactionBuilder" import { retryUntilFecth } from "../../retry" import { getTONDetails } from "./getters" import { calculateEpochTimelock } from "../utils/calculateTimelock" +import { useSecretDerivation } from "@/context/secretDerivationContext" +import { secretToHashlock } from "@/lib/htlc/secretDerivation" export interface UseAtomicTONParams { tonWallet: any @@ -25,11 +27,25 @@ export interface AtomicTONFunctions { export default function useAtomicTON(params: UseAtomicTONParams): AtomicTONFunctions { const { tonWallet, tonConnectUI, networks, tonApiUrl } = params + const { deriveSecret } = useSecretDerivation() const createPreHTLC = async (params: CreatePreHTLCParams) => { if (!tonWallet?.account.publicKey) return + // Secret derivation for HTLC with hashlock + const network = networks.find(n => n.chainId === params.chainId); + const chainId = network?.chainId || params.chainId || 'ton-mainnet'; + const timelock = calculateEpochTimelock(40); + const secret = await deriveSecret({ + chainId, + wallet: { providerName: 'ton' } as any, + tonConnectUI, + timelock + }); + const hashlock = secretToHashlock(secret); + + // Note: Add hashlock to transaction params when contract supports it const tx = await commitTransactionBuilder({ wallet: { address: tonWallet.account.address, diff --git a/package.json b/package.json index 3de01689..3af4d932 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@imtbl/imx-sdk": "2.1.1", "@imtbl/sdk": "1.45.10", "@metamask/jazzicon": "^2.0.0", + "@noble/hashes": "^2.0.1", "@paradex/sdk": "0.5.4", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-checkbox": "^1.0.4", @@ -98,6 +99,7 @@ "devDependencies": { "@datadog/datadog-ci": "^2.41.0", "@next/bundle-analyzer": "^13.5.4", + "@tailwindcss/postcss": "^4.0.15", "@types/bn.js": "^5.1.0", "@types/crypto-js": "^4.1.1", "@types/node": "^20", @@ -110,7 +112,6 @@ "file-loader": "^6.2.0", "fuels": "^0.102.0", "tailwindcss": "^4.0.15", - "@tailwindcss/postcss": "^4.0.15", "typescript": "^5.1" }, "resolutions": { diff --git a/yarn.lock b/yarn.lock index 36274656..452c5906 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4466,6 +4466,11 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== +"@noble/hashes@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + "@noble/hashes@~1.6.0": version "1.6.1" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.6.1.tgz#df6e5943edcea504bac61395926d6fd67869a0d5" From 2d4acdacb1740d5f21cb70b5c1396a1f8a36e380 Mon Sep 17 00:00:00 2001 From: Aren Date: Wed, 4 Feb 2026 16:44:12 +0400 Subject: [PATCH 02/10] Refactor layout and integrate UserStatus component - Adjust padding in globalFooter, themeWrapper, and various components for consistent spacing. - Introduce UserStatusHeader and UserStatusMenu components for user authentication status display. - Update Navbar and HeaderWithMenu to include UserStatus for better user experience. - Enhance LayerswapMenu and Modal components with improved styling and functionality. - Implement Zustand store for managing secret derivation state, improving login handling. --- components/HeaderWithMenu/index.tsx | 62 ++-- components/LayerswapMenu/Menu.tsx | 2 +- components/LayerswapMenu/MenuList.tsx | 5 + components/LayerswapMenu/index.tsx | 5 +- components/Modal/leaflet.tsx | 14 +- components/SecretDerivation/UserStatus.tsx | 328 ++++++++++++++++++++ components/SecretDerivation/index.ts | 1 + components/Swap/Atomic/Form.tsx | 4 +- components/Swap/AtomicChat/index.tsx | 2 +- components/Widget/Content.tsx | 11 +- components/Widget/Footer.tsx | 2 +- components/Widget/Index.tsx | 2 +- components/Wizard/Wizard.tsx | 8 +- components/Wizard/WizardItem.tsx | 2 +- components/buttons/iconButton.tsx | 18 +- components/globalFooter.tsx | 2 +- components/navbar.tsx | 52 ++-- components/themeWrapper.tsx | 2 +- context/secretDerivationContext.tsx | 152 +++------ lib/htlc/secretDerivation/passkeyService.ts | 6 + lib/wallets/fuel/useAtomicFuel.ts | 3 +- stores/secretDerivationStore.ts | 106 +++++++ 22 files changed, 586 insertions(+), 203 deletions(-) create mode 100644 components/SecretDerivation/UserStatus.tsx create mode 100644 stores/secretDerivationStore.ts diff --git a/components/HeaderWithMenu/index.tsx b/components/HeaderWithMenu/index.tsx index bf88f295..6edd96f1 100644 --- a/components/HeaderWithMenu/index.tsx +++ b/components/HeaderWithMenu/index.tsx @@ -1,51 +1,49 @@ -import { useIntercom } from "react-use-intercom" import IconButton from "../buttons/iconButton" import GoHomeButton from "../utils/GoHome" import { ArrowLeft } from 'lucide-react' -import ChatIcon from "../Icons/ChatIcon" import dynamic from "next/dynamic" import LayerswapMenu from "../LayerswapMenu" import { useQueryState } from "../../context/query" +import useWindowDimensions from "@/hooks/useWindowDimensions" +import { UserStatusHeader } from "../SecretDerivation" const WalletsHeader = dynamic(() => import("../Wallet/ConnectedWallets.tsx").then((comp) => comp.WalletsHeader), { loading: () => <> }) + function HeaderWithMenu({ goBack }: { goBack: (() => void) | undefined | null }) { - const { boot, show, update } = useIntercom() const query = useQueryState() + const { isMobile } = useWindowDimensions() return ( -
- { - goBack && - - }> - - } - { - !query.hideLogo &&
- -
- } -
+
+
+ { + goBack && +
+ + } /> +
+ } + { + !query.hideLogo &&
+ +
+ } +
+
+ { + isMobile + ? + : null + } - { - boot(); - show(); - update() - }} - icon={ - - }> - -
- -
+
) diff --git a/components/LayerswapMenu/Menu.tsx b/components/LayerswapMenu/Menu.tsx index f863c6a8..2a7c8905 100644 --- a/components/LayerswapMenu/Menu.tsx +++ b/components/LayerswapMenu/Menu.tsx @@ -131,7 +131,7 @@ const Footer = ({ children, hidden, sticky = true }: FooterProps) => { bg-secondary-900 shadow-widget-footer p-4 - px-6 + px-4 w-full ${hidden ? 'animation-slide-out' : ''}`}> {children} diff --git a/components/LayerswapMenu/MenuList.tsx b/components/LayerswapMenu/MenuList.tsx index 52dd25db..b854f92d 100644 --- a/components/LayerswapMenu/MenuList.tsx +++ b/components/LayerswapMenu/MenuList.tsx @@ -18,6 +18,10 @@ const WalletsMenu = dynamic(() => import("../Wallet/ConnectedWallets.tsx").then( loading: () => <> }) +const UserStatusMenu = dynamic(() => import("../SecretDerivation/UserStatus.tsx").then((comp) => comp.UserStatusMenu), { + loading: () => <> +}) + const MenuList: FC<{ goToStep: (step: MenuStep, path?: string) => void }> = ({ goToStep }) => { const router = useRouter(); const { boot, show, update } = useIntercom() @@ -35,6 +39,7 @@ const MenuList: FC<{ goToStep: (step: MenuStep, path?: string) => void }> = ({ g return
+ diff --git a/components/LayerswapMenu/index.tsx b/components/LayerswapMenu/index.tsx index d502e78e..5859f861 100644 --- a/components/LayerswapMenu/index.tsx +++ b/components/LayerswapMenu/index.tsx @@ -53,10 +53,9 @@ const Comp = () => { return <>
- setOpenTopModal(true)} icon={ + setOpenTopModal(true)} icon={ - }> - + } />
-
+
{title}
-
- - }> - -
+ + }> +
+ className='select-text max-h-full overflow-y-auto overflow-x-hidden styled-scroll px-4 h-full' id="virtualListContainer"> {children}
diff --git a/components/SecretDerivation/UserStatus.tsx b/components/SecretDerivation/UserStatus.tsx new file mode 100644 index 00000000..5caa05a1 --- /dev/null +++ b/components/SecretDerivation/UserStatus.tsx @@ -0,0 +1,328 @@ +import { useState } from "react" +import { Fingerprint, LogOut } from "lucide-react" +import toast from "react-hot-toast" +import VaulDrawer from "../Modal/vaulModal" +import { Popover, PopoverContent, PopoverTrigger } from "../shadcn/popover" +import { useSecretDerivationStore } from "@/stores/secretDerivationStore" +import shortenAddress from "../utils/ShortenAddress" +import useWindowDimensions from "@/hooks/useWindowDimensions" +import { getStoredCredentialId, formatPasskeyIdForDisplay } from "@/lib/htlc/secretDerivation/passkeyService" +import WalletIcon from "../Icons/WalletIcon" + +interface UserStatusContentProps { + method: 'passkey' | 'wallet_sign' | null + loginWallet: { + address: string + chainId?: string | number + providerName: string + displayName?: string + } | null + logout: () => void + onClose?: () => void +} + +const UserStatusContent = ({ method, loginWallet, logout, onClose }: UserStatusContentProps) => { + const handleLogout = () => { + logout() + onClose?.() + toast.success('Logged out successfully') + } + + const handleCopyAddress = () => { + if (loginWallet?.address) { + navigator.clipboard.writeText(loginWallet.address) + toast.success('Address copied') + } + } + + const passkeyCredId = method === 'passkey' ? getStoredCredentialId() : null + const displayId = passkeyCredId ? formatPasskeyIdForDisplay(passkeyCredId) : null + + return ( +
+

Connected with

+
+ {method === 'passkey' ? ( + <> +
+ +
+
+ Passkey + {displayId && ( + {displayId} + )} +
+ + ) : ( + <> +
+ +
+
+ + {loginWallet?.displayName || 'EVM Wallet'} + + {loginWallet?.address && ( + + )} +
+ + )} +
+ + {method === 'passkey' && ( +
+

+ Store your passkeys securely. Losing your passkey means losing access to your account and any associated funds permanently. +

+
+ )} + + +
+ ) +} + +export const UserStatusHeader = () => { + const { + method, + isLoggedIn, + loginWallet, + logout, + } = useSecretDerivationStore() + const [openDrawer, setOpenDrawer] = useState(false) + const [openPopover, setOpenPopover] = useState(false) + const { isMobile } = useWindowDimensions() + + if (!isLoggedIn) return null + + const pillLabel = method === 'passkey' + ? 'passkey' + : (loginWallet?.displayName || shortenAddress(loginWallet?.address ?? '') || 'Wallet') + + const pillContent = ( + <> + {method === 'passkey' ? ( + + ) : ( + + )} + {pillLabel} + + ) + + const pillClassName = "inline-flex items-center gap-2 py-2 px-3 rounded-full bg-secondary-500 text-primary-text hover:bg-secondary-400 focus:outline-none transition-colors active:animate-press-down" + + return ( + <> + {isMobile ? ( + <> + + setOpenDrawer(false)} + method={method} + loginWallet={loginWallet} + logout={logout} + /> + + ) : ( + + + + + + setOpenPopover(false)} + /> + + + )} + + ) +} + +export const UserStatusMenu = () => { + const { isLoggedIn, method, loginWallet, logout } = useSecretDerivationStore() + const [openModal, setOpenModal] = useState(false) + + if (!isLoggedIn) return null + + return ( + <> + + setOpenModal(false)} + method={method} + loginWallet={loginWallet} + logout={logout} + /> + + ) +} + +interface UserStatusDrawerProps { + isOpen: boolean + onClose: () => void + method: 'passkey' | 'wallet_sign' | null + loginWallet: { + address: string + chainId?: string | number + providerName: string + displayName?: string + } | null + logout: () => void +} + +/** Old drawer content for mobile: Login Status card + logout (no "Connected with" / warning) */ +const UserStatusDrawerContent = ({ method, loginWallet, logout, onClose }: UserStatusContentProps) => { + const handleLogout = () => { + logout() + onClose?.() + toast.success('Logged out successfully') + } + + const handleCopyAddress = () => { + if (loginWallet?.address) { + navigator.clipboard.writeText(loginWallet.address) + toast.success('Address copied') + } + } + + return ( +
+
+ {method === 'passkey' ? ( + <> +
+ +
+
+ Passkey + Face ID, Touch ID, or Security Key +
+ + ) : ( + <> +
+ +
+
+ + {loginWallet?.displayName || 'EVM Wallet'} + + {loginWallet?.address && ( + + )} +
+ + )} +
+ +
+ ) +} + +const UserStatusDrawer = ({ isOpen, onClose, method, loginWallet, logout }: UserStatusDrawerProps) => { + const handleClose = () => { + onClose() + } + + return ( + + + + + + ) +} diff --git a/components/SecretDerivation/index.ts b/components/SecretDerivation/index.ts index cfc88530..1a697980 100644 --- a/components/SecretDerivation/index.ts +++ b/components/SecretDerivation/index.ts @@ -2,3 +2,4 @@ export { LoginModal } from './LoginModal'; export { SignFlowModal } from './SignFlowModal'; +export { UserStatusHeader, UserStatusMenu } from './UserStatus'; diff --git a/components/Swap/Atomic/Form.tsx b/components/Swap/Atomic/Form.tsx index 3ab9bf9e..82e2f61e 100644 --- a/components/Swap/Atomic/Form.tsx +++ b/components/Swap/Atomic/Form.tsx @@ -158,7 +158,7 @@ const SwapForm: FC = () => {
-
+
{!(query?.hideFrom && values?.from) &&
} @@ -185,7 +185,7 @@ const SwapForm: FC = () => {
}
-
+
diff --git a/components/Swap/AtomicChat/index.tsx b/components/Swap/AtomicChat/index.tsx index 5583cd43..c5e6c2da 100644 --- a/components/Swap/AtomicChat/index.tsx +++ b/components/Swap/AtomicChat/index.tsx @@ -18,7 +18,7 @@ const Commitment: FC = ({ type }) => { return ( <> - + diff --git a/components/Widget/Content.tsx b/components/Widget/Content.tsx index 462a3b5b..8d4dbc43 100644 --- a/components/Widget/Content.tsx +++ b/components/Widget/Content.tsx @@ -1,17 +1,16 @@ type ContetProps = { center?: boolean, children?: JSX.Element | JSX.Element[]; - className?: string; } -const Content = ({ children, center, className }: ContetProps) => { +const Content = ({ children, center }: ContetProps) => { return center ? -
-
-
+
+
+
{children}
- :
{children}
+ :
{children}
} export default Content \ No newline at end of file diff --git a/components/Widget/Footer.tsx b/components/Widget/Footer.tsx index edf462f7..0c218d9a 100644 --- a/components/Widget/Footer.tsx +++ b/components/Widget/Footer.tsx @@ -50,7 +50,7 @@ const Footer = ({ children, hidden, sticky = true }: FooterProps) => { max-sm:bg-secondary-900 max-sm:shadow-widget-footer max-sm:p-4 - max-sm:px-6 + max-sm:px-4 max-sm:w-full ${hidden ? 'animation-slide-out' : ''}` : ''}>
diff --git a/components/Widget/Index.tsx b/components/Widget/Index.tsx index 29d8f28d..507c89f3 100644 --- a/components/Widget/Index.tsx +++ b/components/Widget/Index.tsx @@ -42,7 +42,7 @@ const Widget = ({ children, className, hideMenu }: Props) => { !hideMenu && } -
+
diff --git a/components/Wizard/Wizard.tsx b/components/Wizard/Wizard.tsx index e79c5260..00da4e16 100644 --- a/components/Wizard/Wizard.tsx +++ b/components/Wizard/Wizard.tsx @@ -15,7 +15,7 @@ const Wizard: FC = ({ children, wizardId, className }) => { const wrapper = useRef(null); const { setWrapperWidth } = useFormWizardaUpdate() - const { wrapperWidth, positionPercent, moving, goBack, noToolBar, hideMenu } = useFormWizardState() + const { wrapperWidth, moving, goBack, noToolBar, hideMenu } = useFormWizardState() useEffect(() => { function handleResize() { @@ -29,7 +29,7 @@ const Wizard: FC = ({ children, wizardId, className }) => { return () => window.removeEventListener("resize", handleResize); }, []); - const width = positionPercent || 0 + return <>
@@ -44,11 +44,11 @@ const Wizard: FC = ({ children, wizardId, className }) => { !hideMenu && } -
+
-
+
{children}
diff --git a/components/Wizard/WizardItem.tsx b/components/Wizard/WizardItem.tsx index 71f7926a..634ee6c0 100644 --- a/components/Wizard/WizardItem.tsx +++ b/components/Wizard/WizardItem.tsx @@ -16,7 +16,7 @@ type Props = { const WizardItem: FC = (({ StepName, children, GoBack, PositionPercent, fitHeight = false, className, inModal }: Props) => { const { currentStepName, wrapperWidth, moving } = useFormWizardState() const { setGoBack, setPositionPercent } = useFormWizardaUpdate() - const styleConfigs = fitHeight ? { width: `${wrapperWidth}px`, height: '100%' } : { width: `${wrapperWidth}px`, minHeight: inModal ? 'inherit' : '400px', height: '100%' } + const styleConfigs = fitHeight ? { width: `${wrapperWidth}px`, height: '100%' } : { width: `${wrapperWidth}px`, minHeight: inModal ? 'inherit' : '300px', height: '100%' } useEffect(() => { if (currentStepName === StepName) { diff --git a/components/buttons/iconButton.tsx b/components/buttons/iconButton.tsx index d76f7def..952aa19c 100644 --- a/components/buttons/iconButton.tsx +++ b/components/buttons/iconButton.tsx @@ -5,19 +5,21 @@ interface IconButtonProps extends Omit, 'color' | 'ref' icon?: React.ReactNode } -const IconButton = forwardRef(function IconButton({ className, icon, ...props }, ref){ +const IconButton = forwardRef(function IconButton({ className, icon, ...props }, ref) { const theirProps = props as object; return ( -
- Icon description - + Icon description + +
) }) diff --git a/components/globalFooter.tsx b/components/globalFooter.tsx index e71aa53b..60fa25b8 100644 --- a/components/globalFooter.tsx +++ b/components/globalFooter.tsx @@ -40,7 +40,7 @@ const GLobalFooter = () => { } return ( -