add project

This commit is contained in:
ZOMBIIIIIII
2026-04-08 14:11:27 +03:00
parent bfa95223a0
commit a81e29807c
115 changed files with 18413 additions and 0 deletions

41
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
apps/web/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

28
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import path from 'path';
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
turbopack: {
// Keep Turbopack pinned to the monorepo root so dev HMR
// does not mis-detect `apps/web` as a standalone project.
root: path.resolve(__dirname, '../..'),
},
webpack(config) {
// Enable WebAssembly for tiny-secp256k1 (used by ecpair/bitcoinjs-lib)
config.experiments = {
...config.experiments,
asyncWebAssembly: true,
};
// Prevent webpack from changing the output of WASM imports
config.module.rules.push({
test: /\.wasm$/,
type: 'webassembly/async',
});
return config;
},
};
export default nextConfig;

38
apps/web/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@scure/bip32": "^2.0.1",
"@scure/bip39": "^2.0.1",
"@solana/web3.js": "^1.98.4",
"@uniswap/router-sdk": "^2.7.1",
"@uniswap/sdk-core": "^7.12.1",
"@uniswap/universal-router-sdk": "^4.34.0",
"@uniswap/v3-sdk": "^3.29.1",
"@uniswap/v4-sdk": "^1.29.1",
"@yudiel/react-qr-scanner": "^2.5.1",
"bip32": "^5.0.1",
"bitcoinjs-lib": "^7.0.1",
"ecpair": "^3.0.1",
"ed25519-hd-key": "^1.3.0",
"ethers": "5.7.2",
"next": "16.1.6",
"qrcode.react": "^4.2.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"tiny-secp256k1": "^2.2.4",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5"
}
}

1770
apps/web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

1
apps/web/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
apps/web/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,327 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
BRIDGE_CHAINS,
BRIDGE_CHAIN_OPTIONS,
getDestinationChainOptions,
getTokenOptions,
getDefaultToken,
type BridgeChainKey,
} from '@/lib/bridge/constants';
import { useBridge } from '@/hooks/useBridge';
import { useGasPrice } from '@/hooks/useGasPrice';
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
import { useAuthStore } from '@/store/auth-store';
const GAS_MODE_LABELS: Record<GasMode, string> = {
slow: 'Slow',
normal: 'Normal',
fast: 'Fast',
custom: 'Custom',
};
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
export default function BridgePage() {
const router = useRouter();
const user = useAuthStore((state) => state.user);
const { data: gasPriceData, loading: gasLoading } = useGasPrice();
const gas = useGasSettings(gasPriceData);
const {
status, quote, bridgeStatus, requestId, txHashes, error,
sourceChain, setSourceChain, sourceWallet,
fetchQuote, submitBridge, resetBridge,
} = useBridge();
const [sourceToken, setSourceToken] = useState(() => getDefaultToken('ETH'));
const [destChain, setDestChain] = useState<BridgeChainKey>('SOL');
const [destToken, setDestToken] = useState(() => getDefaultToken('SOL'));
const [amount, setAmount] = useState('');
const [confirmed, setConfirmed] = useState(false);
const destChainOptions = useMemo(() => getDestinationChainOptions(sourceChain), [sourceChain]);
const sourceTokenOptions = useMemo(() => getTokenOptions(sourceChain), [sourceChain]);
const destTokenOptions = useMemo(() => getTokenOptions(destChain), [destChain]);
const handleSourceChainChange = (newChain: BridgeChainKey) => {
setSourceChain(newChain);
setSourceToken(getDefaultToken(newChain));
// If dest chain is same as new source, switch dest
const newDestOptions = getDestinationChainOptions(newChain);
if (!newDestOptions.includes(destChain)) {
setDestChain(newDestOptions[0]);
setDestToken(getDefaultToken(newDestOptions[0]));
}
handleReset();
};
const handleDestChainChange = (newChain: BridgeChainKey) => {
setDestChain(newChain);
setDestToken(getDefaultToken(newChain));
handleReset();
};
if (!user) return null;
const canQuote =
Number(amount) > 0 &&
status !== 'quoting' &&
status !== 'executing' &&
status !== 'monitoring';
const canBridge = !!quote && confirmed && status !== 'executing' && status !== 'monitoring';
const isEvmSource = sourceChain === 'ETH' || sourceChain === 'BSC';
const showGasControls = sourceChain === 'ETH'; // BSC uses fixed gas price
const handleQuote = async () => {
setConfirmed(false);
await fetchQuote({ sourceChain, sourceToken, destChain, destToken, amount });
};
const handleBridge = async () => {
await submitBridge(
{ sourceChain, sourceToken, destChain, destToken, amount },
isEvmSource ? gas.effectiveMaxFee : null,
isEvmSource ? gas.effectivePriorityFee : null,
);
};
const handleReset = () => {
setConfirmed(false);
resetBridge();
};
const tierGwei = (mode: GasMode): string => {
if (mode === 'custom') return '';
if (!gasPriceData) return '...';
const v = gasPriceData[mode].maxFeePerGas;
if (v >= 1) return v.toFixed(2);
const s = v.toFixed(4);
return s.replace(/0+$/, '').replace(/\.$/, '');
};
const sourceExplorerBase = BRIDGE_CHAINS[sourceChain].explorerTxBaseUrl;
const destExplorerBase = BRIDGE_CHAINS[destChain].explorerTxBaseUrl;
return (
<div style={{ maxWidth: 720, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h1>Bridge</h1>
<Link href="/dashboard" style={navButtonStyle}>
Back to Dashboard
</Link>
</div>
<div style={{ border: '1px solid #ccc', padding: 16 }}>
{/* Source Chain */}
<div style={fieldGroupStyle}>
<label>Source Chain</label>
<select
value={sourceChain}
onChange={(e) => handleSourceChainChange(e.target.value as BridgeChainKey)}
style={inputStyle}
>
{BRIDGE_CHAIN_OPTIONS.map((key) => (
<option key={key} value={key}>{BRIDGE_CHAINS[key].label}</option>
))}
</select>
</div>
{/* Source Token */}
<div style={fieldGroupStyle}>
<label>Source Token</label>
<select
value={sourceToken}
onChange={(e) => { setSourceToken(e.target.value); handleReset(); }}
style={inputStyle}
>
{sourceTokenOptions.map((sym) => (
<option key={sym} value={sym}>{sym}</option>
))}
</select>
</div>
{/* Destination Chain */}
<div style={fieldGroupStyle}>
<label>Destination Chain</label>
<select
value={destChain}
onChange={(e) => handleDestChainChange(e.target.value as BridgeChainKey)}
style={inputStyle}
>
{destChainOptions.map((key) => (
<option key={key} value={key}>{BRIDGE_CHAINS[key].label}</option>
))}
</select>
</div>
{/* Destination Token */}
<div style={fieldGroupStyle}>
<label>Destination Token</label>
<select
value={destToken}
onChange={(e) => { setDestToken(e.target.value); handleReset(); }}
style={inputStyle}
>
{destTokenOptions.map((sym) => (
<option key={sym} value={sym}>{sym}</option>
))}
</select>
</div>
{/* Amount */}
<div style={fieldGroupStyle}>
<label>Amount</label>
<input
value={amount}
onChange={(e) => { setAmount(e.target.value); handleReset(); }}
type="number"
min="0"
step="any"
style={inputStyle}
/>
</div>
{/* Gas Speed — only for ETH source */}
{showGasControls ? (
<div style={fieldGroupStyle}>
<label>Gas Speed {gasLoading ? '(loading...)' : ''}</label>
<div style={{ display: 'flex', gap: 6 }}>
{GAS_MODES.map((mode) => (
<button
key={mode}
onClick={() => gas.setGasMode(mode)}
style={{
flex: 1,
padding: '6px 4px',
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
borderRadius: 4,
background: gas.gasMode === mode ? '#f0f0f0' : '#fff',
cursor: 'pointer',
fontSize: 13,
}}
>
<div>{GAS_MODE_LABELS[mode]}</div>
{mode !== 'custom' && (
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
{tierGwei(mode)} gwei
</div>
)}
</button>
))}
</div>
{gas.gasMode === 'custom' && (
<input
value={gas.customGwei}
onChange={(e) => gas.setCustomGwei(e.target.value)}
type="number"
min="0"
step="0.01"
placeholder="Enter gwei"
style={{ ...inputStyle, marginTop: 6 }}
/>
)}
<p style={{ marginTop: 4, fontSize: 13, color: '#666' }}>
Effective: {gas.displayGwei}
</p>
</div>
) : sourceChain === 'BSC' ? (
<div style={fieldGroupStyle}>
<label>Fee</label>
<p style={{ fontSize: 13, color: '#666' }}>Fixed: <strong>0.055 gwei</strong> (BSC)</p>
</div>
) : (
<div style={fieldGroupStyle}>
<label>Fee</label>
<p style={{ fontSize: 13, color: '#666' }}>Auto (managed by Relay)</p>
</div>
)}
<button onClick={() => void handleQuote()} disabled={!canQuote} style={{ padding: '8px 16px' }}>
{status === 'quoting' ? 'Getting Quote...' : 'Get Quote'}
</button>
</div>
{/* Quote Review */}
{quote && (
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
<h2 style={{ marginBottom: 12 }}>Review</h2>
<p>Expected output: <strong>{quote.outputAmountFormatted} {quote.outputSymbol}</strong></p>
<p>Minimum output: <strong>{quote.minimumAmountFormatted}</strong></p>
<p>Estimated fee: <strong>{quote.feeSummary}</strong></p>
<p>Estimated time: <strong>{quote.timeEstimateSeconds ? `${quote.timeEstimateSeconds}s` : 'Unavailable'}</strong></p>
{showGasControls && (
<p>Gas: <strong>{gas.displayGwei} ({GAS_MODE_LABELS[gas.gasMode]})</strong></p>
)}
{sourceChain === 'BSC' && (
<p>Gas: <strong>0.055 gwei</strong> (BSC fixed)</p>
)}
<label style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'flex-start' }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<span>I confirm the bridge amount, fee and destination shown above.</span>
</label>
<button onClick={() => void handleBridge()} disabled={!canBridge} style={{ padding: '8px 16px', marginTop: 16 }}>
{status === 'executing' ? 'Executing...' : status === 'monitoring' ? 'Monitoring...' : 'Bridge'}
</button>
</div>
)}
{/* Status */}
{(requestId || txHashes.length > 0 || bridgeStatus) && (
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
<h2 style={{ marginBottom: 12 }}>Status</h2>
{requestId && <p>Request ID: <strong>{requestId}</strong></p>}
{bridgeStatus && <p>Relay status: <strong>{bridgeStatus.status}</strong></p>}
{txHashes.map((hash) => (
<p key={hash}>
Origin tx:{' '}
<a href={`${sourceExplorerBase}${hash}`} target="_blank" rel="noreferrer">
{hash}
</a>
</p>
))}
{(bridgeStatus?.txHashes ?? []).map((hash) => (
<p key={hash}>
Destination tx:{' '}
<a href={`${destExplorerBase}${hash}`} target="_blank" rel="noreferrer">
{hash}
</a>
</p>
))}
</div>
)}
{(error || status === 'error') && (
<p style={{ color: 'red', marginTop: 16 }}>
{error ?? 'Bridge failed'}
</p>
)}
</div>
);
}
const fieldGroupStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
marginBottom: 12,
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: 8,
};
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
};

View File

@@ -0,0 +1,152 @@
'use client';
import Link from 'next/link';
import { useBalances } from '@/hooks/useBalances';
import type { ChainBalance } from '@/lib/balances/types';
import { useAuthStore } from '@/store/auth-store';
export default function DashboardPage() {
const { user, wallets } = useAuthStore();
const { portfolio, loading, refreshing, error, refresh } = useBalances();
return (
<div style={{ maxWidth: 820, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
<h1>Dashboard</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<span>{user?.email || 'Not authenticated'}</span>
<Link href="/send" style={navButtonStyle}>
Send
</Link>
<Link href="/receive" style={navButtonStyle}>
Receive
</Link>
<Link href="/swap" style={navButtonStyle}>
Swap
</Link>
<Link href="/bridge" style={navButtonStyle}>
Bridge
</Link>
<Link href="/settings" style={navButtonStyle}>
Settings
</Link>
<button onClick={() => void refresh()} style={navButtonStyle} disabled={loading || refreshing}>
{refreshing ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20, marginBottom: 20 }}>
<p style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>
Total Portfolio USD
</p>
<h2 style={{ marginBottom: 8 }}>{formatUsd(portfolio?.totalUsd ?? null)}</h2>
<p style={{ color: '#666', fontSize: 14 }}>
{portfolio?.updatedAt
? `Updated ${new Date(portfolio.updatedAt).toLocaleTimeString()}`
: loading
? 'Loading balances...'
: 'Balances will appear after the first refresh.'}
</p>
</div>
{error && (
<p style={{ color: 'red', marginBottom: 12 }}>
{error}
</p>
)}
{portfolio?.priceError && (
<p style={{ color: '#b45309', marginBottom: 12 }}>
USD pricing is partially unavailable: {portfolio.priceError}
</p>
)}
<h2>Your Wallets</h2>
{wallets.map((w) => {
const chainBalance = getChainBalance(w.chain, portfolio?.chains);
return (
<div key={w.chain} style={{ border: '1px solid #ccc', padding: 16, marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'center' }}>
<h3>{w.chain}</h3>
<span style={{ fontWeight: 600 }}>{formatUsd(chainBalance?.totalUsd ?? null)}</span>
</div>
<p style={{ wordBreak: 'break-all' }}>
<strong>Address:</strong> {w.address}
</p>
{chainBalance?.error && chainBalance.error !== '__transient__' && (
<p style={{ color: 'red', marginTop: 8 }}>
{chainBalance.error}
</p>
)}
<div style={{ marginTop: 12 }}>
{chainBalance?.tokens.length ? (
chainBalance.tokens.map((token) => (
<div
key={`${w.chain}-${token.symbol}`}
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: 12,
padding: '8px 0',
borderTop: '1px solid #eee',
}}
>
<span>{token.symbol}</span>
<span>{formatTokenAmount(token.balanceFormatted)}</span>
<span style={{ textAlign: 'right' }}>{formatUsd(token.valueUsd)}</span>
</div>
))
) : (
<p style={{ color: '#666', marginTop: 8 }}>
{loading ? 'Loading balances...' : 'No balances loaded yet.'}
</p>
)}
</div>
</div>
);
})}
</div>
);
}
function getChainBalance(chain: string, chains?: ChainBalance[]): ChainBalance | undefined {
return chains?.find((item) => item.chain === chain);
}
function formatUsd(value: number | null): string {
if (typeof value !== 'number') {
return 'Unavailable';
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2,
}).format(value);
}
function formatTokenAmount(value: string): string {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return value;
}
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 6,
}).format(numericValue);
}
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,42 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

View File

@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Crypto Wallet",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,141 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
min-height: 100vh;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/dashboard');
}

View File

@@ -0,0 +1,225 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { QRCodeSVG } from 'qrcode.react';
import { useAuthStore } from '@/store/auth-store';
import {
SEND_CHAIN_OPTIONS,
SEND_CHAINS,
getTokenOptions,
getDefaultToken,
type SendChain,
} from '@/lib/send/constants';
import { generateReceiveUri } from '@/lib/qr/generate';
export default function ReceivePage() {
const router = useRouter();
const user = useAuthStore((state) => state.user);
const wallets = useAuthStore((state) => state.wallets);
const [chain, setChain] = useState<SendChain>('ETH');
const [token, setToken] = useState<string>(getDefaultToken('ETH'));
const [amount, setAmount] = useState('');
const [copied, setCopied] = useState(false);
// Reset token when chain changes
useEffect(() => {
setToken(getDefaultToken(chain));
setAmount('');
}, [chain]);
const wallet = useMemo(
() => wallets.find((w) => w.chain === SEND_CHAINS[chain].walletChain),
[wallets, chain],
);
const address = wallet?.address ?? '';
// Ensure token is valid for the current chain (guards against stale state during chain switch)
const effectiveToken = useMemo(() => {
const options = getTokenOptions(chain);
return options.includes(token) ? token : getDefaultToken(chain);
}, [chain, token]);
const qrUri = useMemo(() => {
if (!address) return '';
return generateReceiveUri({
chain,
token: effectiveToken,
address,
amount: amount.trim() || undefined,
});
}, [chain, effectiveToken, address, amount]);
const handleCopy = async () => {
if (!address) return;
try {
await navigator.clipboard.writeText(address);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback
const textArea = document.createElement('textarea');
textArea.value = address;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (!user) return null;
const tokenOptions = getTokenOptions(chain);
return (
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1>Receive</h1>
<div style={{ display: 'flex', gap: 8 }}>
<Link href="/send" style={navButtonStyle}>Send</Link>
<Link href="/dashboard" style={navButtonStyle}>Dashboard</Link>
</div>
</div>
{/* Chain selector */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Network</label>
<select
value={chain}
onChange={(e) => setChain(e.target.value as SendChain)}
style={selectStyle}
>
{SEND_CHAIN_OPTIONS.map((c) => (
<option key={c} value={c}>{SEND_CHAINS[c].label}</option>
))}
</select>
</div>
{/* Token selector */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Token</label>
<select
value={token}
onChange={(e) => setToken(e.target.value)}
style={selectStyle}
>
{tokenOptions.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
{/* Amount (optional) */}
<div style={{ marginBottom: 24 }}>
<label style={labelStyle}>Amount (optional)</label>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
value={amount}
onChange={(e) => {
const v = e.target.value;
if (/^\d*\.?\d*$/.test(v)) setAmount(v);
}}
style={inputStyle}
/>
</div>
{/* QR Code */}
{address && (
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<div style={{
display: 'inline-block',
padding: 16,
background: '#fff',
borderRadius: 12,
border: '1px solid #ddd',
}}>
<QRCodeSVG value={qrUri} size={256} level="M" />
</div>
</div>
)}
{/* Address display */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Your {SEND_CHAINS[chain].label} Address</label>
<div style={{
padding: '10px 12px',
border: '1px solid #ccc',
borderRadius: 4,
wordBreak: 'break-all',
fontSize: 13,
fontFamily: 'monospace',
background: '#f9f9f9',
}}>
{address || 'No wallet found for this chain'}
</div>
</div>
{/* Copy button */}
<button
onClick={handleCopy}
disabled={!address}
style={{
width: '100%',
padding: '10px 16px',
border: '1px solid #ccc',
borderRadius: 4,
cursor: address ? 'pointer' : 'not-allowed',
background: copied ? '#d4edda' : '#fff',
fontSize: 14,
}}
>
{copied ? 'Copied!' : 'Copy Address'}
</button>
{/* URI preview */}
{qrUri && (
<div style={{ marginTop: 16, fontSize: 11, color: '#888', wordBreak: 'break-all' }}>
<strong>QR URI:</strong> {qrUri}
</div>
)}
</div>
);
}
const labelStyle: React.CSSProperties = {
display: 'block',
marginBottom: 4,
fontSize: 13,
fontWeight: 600,
};
const selectStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
fontSize: 14,
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
fontSize: 14,
boxSizing: 'border-box',
};
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
textDecoration: 'none',
color: 'inherit',
};

View File

@@ -0,0 +1,561 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Scanner } from '@yudiel/react-qr-scanner';
import { useAuthStore } from '@/store/auth-store';
import { useBalances } from '@/hooks/useBalances';
import { useGasPrice } from '@/hooks/useGasPrice';
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
import {
SEND_CHAIN_OPTIONS,
SEND_CHAINS,
getTokenOptions,
getDefaultToken,
type SendChain,
} from '@/lib/send/constants';
import { validateAddress } from '@/lib/send/validate';
import { parseQrUri } from '@/lib/qr/parse';
import { executeSend, type SendResult } from '@/lib/send/execute';
import type { ChainBalance } from '@/lib/balances/types';
const GAS_MODE_LABELS: Record<GasMode, string> = {
slow: 'Slow',
normal: 'Normal',
fast: 'Fast',
custom: 'Custom',
};
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
type SendStatus = 'idle' | 'review' | 'sending' | 'success' | 'error';
export default function SendPage() {
const router = useRouter();
const user = useAuthStore((state) => state.user);
const wallets = useAuthStore((state) => state.wallets);
const { portfolio } = useBalances();
const { data: gasPriceData } = useGasPrice();
const gas = useGasSettings(gasPriceData);
const [chain, setChain] = useState<SendChain>('ETH');
const [token, setToken] = useState<string>(getDefaultToken('ETH'));
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
const [confirmed, setConfirmed] = useState(false);
const [status, setStatus] = useState<SendStatus>('idle');
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SendResult | null>(null);
const [scannerOpen, setScannerOpen] = useState(false);
// Reset token on chain change
useEffect(() => {
setToken(getDefaultToken(chain));
setRecipient('');
setAmount('');
setConfirmed(false);
setStatus('idle');
setError(null);
setResult(null);
}, [chain]);
const wallet = useMemo(
() => wallets.find((w) => w.chain === SEND_CHAINS[chain].walletChain),
[wallets, chain],
);
const fromAddress = wallet?.address ?? '';
// Get available balance for the selected token
const availableBalance = useMemo(() => {
if (!portfolio?.chains) return null;
const chainBalance: ChainBalance | undefined = portfolio.chains.find(
(c) => c.chain === SEND_CHAINS[chain].walletChain,
);
if (!chainBalance) return null;
const tokenBalance = chainBalance.tokens.find((t) => t.symbol === token);
return tokenBalance?.balanceFormatted ?? null;
}, [portfolio, chain, token]);
// Validate address
const addressValidation = useMemo(() => {
if (!recipient.trim()) return null;
return validateAddress(chain, recipient);
}, [chain, recipient]);
// Handle QR scan
const handleScan = useCallback((results: Array<{ rawValue: string }>) => {
if (!results.length) return;
const raw = results[0].rawValue;
if (!raw) return;
const parsed = parseQrUri(raw);
setScannerOpen(false);
if (parsed.chain) {
setChain(parsed.chain);
// Wait for chain useEffect, then set token/recipient/amount
setTimeout(() => {
if (parsed.token) setToken(parsed.token);
if (parsed.address) setRecipient(parsed.address);
if (parsed.amount) setAmount(parsed.amount);
}, 50);
} else if (parsed.address) {
setRecipient(parsed.address);
if (parsed.amount) setAmount(parsed.amount);
}
}, []);
const handleReview = () => {
setError(null);
if (!recipient.trim()) {
setError('Recipient address is required');
return;
}
const validation = validateAddress(chain, recipient);
if (!validation.valid) {
setError(validation.error || 'Invalid address');
return;
}
if (!amount || Number(amount) <= 0) {
setError('Enter a valid amount');
return;
}
setStatus('review');
};
const handleSend = async () => {
if (!wallet) {
setError('Wallet not found for this chain');
return;
}
setStatus('sending');
setError(null);
try {
const sendResult = await executeSend({
chain,
token,
toAddress: recipient.trim(),
amount,
privateKey: wallet.privateKey,
fromAddress,
maxFeeGwei: chain === 'ETH' ? gas.effectiveMaxFee : null,
priorityFeeGwei: chain === 'ETH' ? gas.effectivePriorityFee : null,
});
setResult(sendResult);
setStatus('success');
} catch (err: any) {
setError(err.message || 'Transaction failed');
setStatus('error');
}
};
const handleReset = () => {
setRecipient('');
setAmount('');
setConfirmed(false);
setStatus('idle');
setError(null);
setResult(null);
};
const handleMax = () => {
if (availableBalance) {
setAmount(availableBalance);
}
};
if (!user) return null;
const tokenOptions = getTokenOptions(chain);
return (
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1>Send</h1>
<div style={{ display: 'flex', gap: 8 }}>
<Link href="/receive" style={navButtonStyle}>Receive</Link>
<Link href="/dashboard" style={navButtonStyle}>Dashboard</Link>
</div>
</div>
{/* QR Scanner Modal */}
{scannerOpen && (
<div style={overlayStyle}>
<div style={modalStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>Scan QR Code</h3>
<button onClick={() => setScannerOpen(false)} style={{ padding: '4px 8px', cursor: 'pointer' }}>
Close
</button>
</div>
<div style={{ width: '100%', maxWidth: 400 }}>
<Scanner
onScan={handleScan}
onError={(err) => {
console.error('QR scanner error:', err);
setScannerOpen(false);
}}
formats={['qr_code']}
styles={{ container: { width: '100%' } }}
/>
</div>
<p style={{ fontSize: 12, color: '#888', marginTop: 8, textAlign: 'center' }}>
Point your camera at a QR code to auto-fill send details
</p>
</div>
</div>
)}
{/* Success state */}
{status === 'success' && result && (
<div style={{ border: '2px solid #28a745', borderRadius: 8, padding: 20, marginBottom: 20 }}>
<h3 style={{ color: '#28a745', marginTop: 0 }}>Transaction Sent!</h3>
<p style={{ wordBreak: 'break-all', fontSize: 13, fontFamily: 'monospace' }}>
<strong>TX Hash:</strong> {result.hash}
</p>
<a
href={result.explorerUrl}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#007bff', fontSize: 13 }}
>
View on Explorer
</a>
<div style={{ marginTop: 16 }}>
<button onClick={handleReset} style={primaryButtonStyle}>
Send Another
</button>
</div>
</div>
)}
{/* Form (hidden during success) */}
{status !== 'success' && (
<>
{/* Chain selector */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Network</label>
<select
value={chain}
onChange={(e) => setChain(e.target.value as SendChain)}
style={selectStyle}
disabled={status === 'sending'}
>
{SEND_CHAIN_OPTIONS.map((c) => (
<option key={c} value={c}>{SEND_CHAINS[c].label}</option>
))}
</select>
</div>
{/* Token selector */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Token</label>
<select
value={token}
onChange={(e) => setToken(e.target.value)}
style={selectStyle}
disabled={status === 'sending'}
>
{tokenOptions.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
{availableBalance !== null && (
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#666' }}>
Available: {availableBalance} {token}
</p>
)}
</div>
{/* Recipient address */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Recipient Address</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
placeholder={`Enter ${SEND_CHAINS[chain].label} address`}
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
style={{ ...inputStyle, flex: 1 }}
disabled={status === 'sending'}
/>
<button
onClick={() => setScannerOpen(true)}
style={{
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
cursor: 'pointer',
whiteSpace: 'nowrap',
fontSize: 13,
}}
disabled={status === 'sending'}
>
Scan QR
</button>
</div>
{addressValidation && !addressValidation.valid && (
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'red' }}>{addressValidation.error}</p>
)}
{addressValidation?.valid && (
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#28a745' }}>Valid address</p>
)}
</div>
{/* Amount */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Amount</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
value={amount}
onChange={(e) => {
const v = e.target.value;
if (/^\d*\.?\d*$/.test(v)) setAmount(v);
}}
style={{ ...inputStyle, flex: 1 }}
disabled={status === 'sending'}
/>
{availableBalance !== null && (
<button
onClick={handleMax}
style={{
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
cursor: 'pointer',
fontSize: 13,
}}
disabled={status === 'sending'}
>
Max
</button>
)}
</div>
</div>
{/* Gas settings (ETH only) */}
{chain === 'ETH' && (
<div style={{ marginBottom: 16, padding: 12, border: '1px solid #eee', borderRadius: 4 }}>
<label style={labelStyle}>Gas Speed</label>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
{GAS_MODES.map((mode) => (
<button
key={mode}
onClick={() => gas.setGasMode(mode)}
style={{
flex: 1,
padding: '6px 4px',
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
borderRadius: 4,
cursor: 'pointer',
fontSize: 12,
fontWeight: gas.gasMode === mode ? 600 : 400,
}}
>
{GAS_MODE_LABELS[mode]}
</button>
))}
</div>
{gas.gasMode === 'custom' && (
<input
type="text"
inputMode="decimal"
placeholder="Max fee in gwei"
value={gas.customGwei}
onChange={(e) => gas.setCustomGwei(e.target.value)}
style={inputStyle}
/>
)}
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#666' }}>
Estimated gas: {gas.displayGwei}
</p>
</div>
)}
{/* Fee info for non-ETH chains */}
{chain !== 'ETH' && (
<div style={{ marginBottom: 16 }}>
<p style={{ fontSize: 12, color: '#666' }}>
{chain === 'SOL' && 'Fee: Auto (~0.000005 SOL)'}
{chain === 'TRX' && 'Fee: Auto (Energy/Bandwidth)'}
{chain === 'BTC' && 'Fee: Auto (market rate sat/vB)'}
</p>
</div>
)}
<p style={{ marginBottom: 16, fontSize: 12, color: '#999' }}>
Platform fee: 0.7% per transaction
</p>
{/* Review section */}
{status === 'review' && (
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16, marginBottom: 16 }}>
<h3 style={{ marginTop: 0 }}>Review Transaction</h3>
<div style={reviewRowStyle}>
<span style={{ color: '#666' }}>From:</span>
<span style={{ wordBreak: 'break-all', fontSize: 12, fontFamily: 'monospace' }}>{fromAddress}</span>
</div>
<div style={reviewRowStyle}>
<span style={{ color: '#666' }}>To:</span>
<span style={{ wordBreak: 'break-all', fontSize: 12, fontFamily: 'monospace' }}>{recipient}</span>
</div>
<div style={reviewRowStyle}>
<span style={{ color: '#666' }}>Amount:</span>
<span style={{ fontWeight: 600 }}>{amount} {token}</span>
</div>
<div style={reviewRowStyle}>
<span style={{ color: '#666' }}>Network:</span>
<span>{SEND_CHAINS[chain].label}</span>
</div>
<div style={{ marginTop: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
I confirm this transaction is correct. This action is irreversible.
</label>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button
onClick={() => { setStatus('idle'); setConfirmed(false); }}
style={{ ...navButtonStyle, flex: 1 }}
>
Back
</button>
<button
onClick={handleSend}
disabled={!confirmed}
style={{
...primaryButtonStyle,
flex: 1,
opacity: confirmed ? 1 : 0.5,
cursor: confirmed ? 'pointer' : 'not-allowed',
}}
>
Confirm & Send
</button>
</div>
</div>
)}
{/* Sending state */}
{status === 'sending' && (
<div style={{ textAlign: 'center', padding: 20 }}>
<p>Sending transaction...</p>
<p style={{ fontSize: 12, color: '#666' }}>Please wait, do not close this page.</p>
</div>
)}
{/* Error display */}
{error && (
<p style={{ color: 'red', marginBottom: 12, fontSize: 13 }}>{error}</p>
)}
{/* Action buttons */}
{(status === 'idle' || status === 'error') && (
<button onClick={handleReview} style={primaryButtonStyle}>
Review Transaction
</button>
)}
</>
)}
</div>
);
}
// ─── Styles ───
const labelStyle: React.CSSProperties = {
display: 'block',
marginBottom: 4,
fontSize: 13,
fontWeight: 600,
};
const selectStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
fontSize: 14,
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 4,
fontSize: 14,
boxSizing: 'border-box',
};
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
textDecoration: 'none',
color: 'inherit',
cursor: 'pointer',
background: '#fff',
};
const primaryButtonStyle: React.CSSProperties = {
width: '100%',
padding: '10px 16px',
border: '1px solid #333',
borderRadius: 4,
cursor: 'pointer',
background: '#333',
color: '#fff',
fontSize: 14,
fontWeight: 600,
};
const reviewRowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
padding: '6px 0',
borderBottom: '1px solid #f0f0f0',
};
const overlayStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
};
const modalStyle: React.CSSProperties = {
background: '#fff',
borderRadius: 12,
padding: 20,
maxWidth: 440,
width: '90%',
};

View File

@@ -0,0 +1,45 @@
'use client';
import Link from 'next/link';
import { useAuthStore } from '@/store/auth-store';
export default function SettingsPage() {
const { user } = useAuthStore();
return (
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1>Settings</h1>
<Link href="/dashboard" style={navButtonStyle}>
Dashboard
</Link>
</div>
{/* Account Section */}
<div style={{ border: '1px solid #ccc', borderRadius: 4, padding: 16, marginBottom: 16 }}>
<h3 style={{ marginTop: 0 }}>Account</h3>
<div>
<p style={{ margin: 0, fontWeight: 600 }}>Email</p>
<p style={{ margin: '4px 0 0', color: '#666', fontSize: 13 }}>{user?.email || 'Not authenticated'}</p>
</div>
</div>
</div>
);
}
// ── Styles ──
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
textDecoration: 'none',
color: 'inherit',
cursor: 'pointer',
background: '#fff',
};

View File

@@ -0,0 +1,328 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
type SwapChain,
SWAP_TOKEN_OPTIONS_BY_CHAIN,
CHAIN_DEFAULT_TOKENS,
getSlippageBpsForChain,
getExplorerTxUrl,
} from '@/lib/swap/constants';
import { useSwap, type MultiChainSwapRequest } from '@/hooks/useSwap';
import { useGasPrice } from '@/hooks/useGasPrice';
import { useGasSettings, type GasMode } from '@/hooks/useGasSettings';
import { useAuthStore } from '@/store/auth-store';
const GAS_MODE_LABELS: Record<GasMode, string> = {
slow: 'Slow',
normal: 'Normal',
fast: 'Fast',
custom: 'Custom',
};
const GAS_MODES: GasMode[] = ['slow', 'normal', 'fast', 'custom'];
const CHAINS: SwapChain[] = ['ETH', 'SOL', 'TRX', 'BSC'];
export default function SwapPage() {
const router = useRouter();
const user = useAuthStore((state) => state.user);
const { data: gasPriceData, loading: gasLoading } = useGasPrice();
const gas = useGasSettings(gasPriceData);
const {
status,
quote,
error,
txHash,
approvalHashes,
liveEstimate,
chain,
setChain,
fetchQuote,
submitSwap,
resetSwap,
estimateOutput,
} = useSwap();
const tokenOptions = SWAP_TOKEN_OPTIONS_BY_CHAIN[chain];
const defaults = CHAIN_DEFAULT_TOKENS[chain];
const [fromSymbol, setFromSymbol] = useState(defaults.from);
const [toSymbol, setToSymbol] = useState(defaults.to);
const [amount, setAmount] = useState('');
const [confirmed, setConfirmed] = useState(false);
const slippageBps = useMemo(() => getSlippageBpsForChain(chain, fromSymbol, toSymbol), [chain, fromSymbol, toSymbol]);
const slippagePercent = (slippageBps / 100).toFixed(2);
const request = useMemo<MultiChainSwapRequest>(
() => ({
chain,
fromSymbol,
toSymbol,
amount,
slippageBps,
}),
[chain, amount, fromSymbol, slippageBps, toSymbol]
);
useEffect(() => {
estimateOutput(request);
}, [estimateOutput, request]);
if (!user) {
return null;
}
const canQuote = fromSymbol !== toSymbol && Number(amount) > 0 && request.slippageBps > 0;
const canSwap = !!quote && confirmed && status !== 'approving' && status !== 'swapping' && status !== 'quoting';
const handleChainChange = (newChain: SwapChain) => {
setChain(newChain);
const newDefaults = CHAIN_DEFAULT_TOKENS[newChain];
setFromSymbol(newDefaults.from);
setToSymbol(newDefaults.to);
setAmount('');
setConfirmed(false);
resetSwap();
};
const handleQuote = async () => {
setConfirmed(false);
await fetchQuote(request);
};
const handleSwap = async () => {
await submitSwap(request, gas.effectiveMaxFee, gas.effectivePriorityFee);
};
const handleFieldReset = () => {
setConfirmed(false);
resetSwap();
};
const tierGwei = (mode: GasMode): string => {
if (mode === 'custom') return '';
if (!gasPriceData) return '...';
const v = gasPriceData[mode].maxFeePerGas;
if (v >= 1) return v.toFixed(2);
const s = v.toFixed(4);
return s.replace(/0+$/, '').replace(/\.$/, '');
};
return (
<div style={{ maxWidth: 720, margin: '50px auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h1>Swap</h1>
<Link href="/dashboard" style={navButtonStyle}>
Back to Dashboard
</Link>
</div>
<div style={{ border: '1px solid #ccc', padding: 16 }}>
{/* Chain selector */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{CHAINS.map((c) => (
<button
key={c}
onClick={() => handleChainChange(c)}
style={{
flex: 1,
padding: '10px 8px',
border: chain === c ? '2px solid #333' : '1px solid #ccc',
borderRadius: 6,
background: chain === c ? '#f0f0f0' : '#fff',
cursor: 'pointer',
fontWeight: chain === c ? 700 : 400,
fontSize: 15,
}}
>
{c}
</button>
))}
</div>
<div style={fieldGroupStyle}>
<label>From</label>
<select value={fromSymbol} onChange={(event) => { setFromSymbol(event.target.value); handleFieldReset(); }} style={inputStyle}>
{tokenOptions.map((symbol) => (
<option key={symbol} value={symbol}>{symbol}</option>
))}
</select>
</div>
<div style={fieldGroupStyle}>
<label>To</label>
<select value={toSymbol} onChange={(event) => { setToSymbol(event.target.value); handleFieldReset(); }} style={inputStyle}>
{tokenOptions.map((symbol) => (
<option key={symbol} value={symbol}>{symbol}</option>
))}
</select>
</div>
<div style={fieldGroupStyle}>
<label>Amount</label>
<input value={amount} onChange={(event) => { setAmount(event.target.value); handleFieldReset(); }} type="number" min="0" step="any" style={inputStyle} />
{liveEstimate && fromSymbol !== toSymbol && (
<p style={{ marginTop: 4, fontSize: 14, color: '#666' }}>
{liveEstimate.loading ? '...' : `~${liveEstimate.amountOut} ${toSymbol}`}
</p>
)}
</div>
{/* Gas speed — only for ETH */}
{chain === 'ETH' && (
<div style={fieldGroupStyle}>
<label>Gas Speed {gasLoading ? '(loading...)' : ''}</label>
<div style={{ display: 'flex', gap: 6 }}>
{GAS_MODES.map((mode) => (
<button
key={mode}
onClick={() => gas.setGasMode(mode)}
style={{
flex: 1,
padding: '6px 4px',
border: gas.gasMode === mode ? '2px solid #333' : '1px solid #ccc',
borderRadius: 4,
background: gas.gasMode === mode ? '#f0f0f0' : '#fff',
cursor: 'pointer',
fontSize: 13,
}}
>
<div>{GAS_MODE_LABELS[mode]}</div>
{mode !== 'custom' && (
<div style={{ fontSize: 11, color: '#666', marginTop: 2 }}>
{tierGwei(mode)} gwei
</div>
)}
</button>
))}
</div>
{gas.gasMode === 'custom' && (
<input
value={gas.customGwei}
onChange={(event) => gas.setCustomGwei(event.target.value)}
type="number"
min="0"
step="0.01"
placeholder="Enter gwei"
style={{ ...inputStyle, marginTop: 6 }}
/>
)}
<p style={{ marginTop: 4, fontSize: 13, color: '#666' }}>
Effective: {gas.displayGwei}
</p>
</div>
)}
{/* Fee info for non-ETH */}
{chain === 'SOL' && (
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
Fee: <strong>Auto</strong> (Jupiter Priority)
</p>
)}
{chain === 'TRX' && (
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
Fee: <strong>Auto</strong> (Energy/Bandwidth)
</p>
)}
{chain === 'BSC' && (
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
Fee: <strong>0.055 gwei</strong> (BSC fixed)
</p>
)}
<p style={{ marginBottom: 12, fontSize: 14, color: '#666' }}>
Slippage: <strong>{slippagePercent}%</strong> (auto)
</p>
<p style={{ marginBottom: 12, fontSize: 13, color: '#999' }}>
Platform fee: 0.7% per swap
</p>
{fromSymbol === toSymbol && (
<p style={{ color: 'red', marginBottom: 12 }}>From and To tokens must be different.</p>
)}
<button onClick={() => void handleQuote()} disabled={!canQuote || status === 'quoting'} style={{ padding: '8px 16px' }}>
{status === 'quoting' ? 'Getting Quote...' : 'Get Quote'}
</button>
</div>
{quote && (
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
<h2 style={{ marginBottom: 12 }}>Review</h2>
<p>Expected output: <strong>{quote.amountOutFormatted} {toSymbol}</strong></p>
<p>Minimum output after slippage: <strong>{quote.minimumAmountOutFormatted} {toSymbol}</strong></p>
{'executionPrice' in quote && <p>Execution price: <strong>{quote.executionPrice}</strong></p>}
{'priceImpact' in quote && <p>Price impact: <strong>{quote.priceImpact}%</strong></p>}
{'routeSymbols' in quote && <p>Route: <strong>{quote.routeSymbols.join(' -> ')}</strong></p>}
{'routeFees' in quote && <p>Pool fees: <strong>{quote.routeFees.join(' / ')}</strong></p>}
{'routeLabels' in quote && (quote as any).routeLabels?.length > 0 && (
<p>Route: <strong>{(quote as any).routeLabels.join(' → ')}</strong></p>
)}
{chain === 'ETH' && <p>Gas: <strong>{gas.displayGwei} ({GAS_MODE_LABELS[gas.gasMode]})</strong></p>}
<p>Slippage: <strong>{slippagePercent}%</strong></p>
<label style={{ display: 'flex', gap: 8, marginTop: 16, alignItems: 'flex-start' }}>
<input type="checkbox" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} />
<span>I confirm the amount, route and slippage shown above.</span>
</label>
<button onClick={() => void handleSwap()} disabled={!canSwap} style={{ padding: '8px 16px', marginTop: 16 }}>
{status === 'approving' ? 'Approving...' : status === 'swapping' ? 'Swapping...' : 'Swap'}
</button>
</div>
)}
{(approvalHashes.length > 0 || txHash) && (
<div style={{ border: '1px solid #ccc', padding: 16, marginTop: 20 }}>
<h2 style={{ marginBottom: 12 }}>Transaction Status</h2>
{approvalHashes.map((hash) => (
<p key={hash}>
Approval tx:{' '}
<a href={getExplorerTxUrl(chain, hash)} target="_blank" rel="noreferrer">
{hash.slice(0, 16)}...
</a>
</p>
))}
{txHash && (
<p>
Swap tx:{' '}
<a href={getExplorerTxUrl(chain, txHash)} target="_blank" rel="noreferrer">
{txHash.slice(0, 16)}...
</a>
</p>
)}
</div>
)}
{(error || status === 'error') && (
<p style={{ color: 'red', marginTop: 16 }}>
{error ?? 'Swap failed'}
</p>
)}
</div>
);
}
const fieldGroupStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
marginBottom: 12,
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: 8,
};
const navButtonStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 12px',
border: '1px solid #ccc',
borderRadius: 4,
};

View File

@@ -0,0 +1,52 @@
'use client';
import { useCallback, useEffect } from 'react';
import { useAuthStore } from '@/store/auth-store';
import { useBalanceStore } from '@/store/balance-store';
const BALANCE_REFRESH_INTERVAL_MS = 30_000;
export function useBalances() {
const user = useAuthStore((state) => state.user);
const wallets = useAuthStore((state) => state.wallets);
const portfolio = useBalanceStore((state) => state.portfolio);
const loading = useBalanceStore((state) => state.loading);
const refreshing = useBalanceStore((state) => state.refreshing);
const error = useBalanceStore((state) => state.error);
const fetchBalances = useBalanceStore((state) => state.fetchBalances);
const clearBalances = useBalanceStore((state) => state.clearBalances);
const refresh = useCallback(async () => {
if (!user || !wallets.length) {
clearBalances();
return;
}
await fetchBalances(wallets);
}, [clearBalances, fetchBalances, user, wallets]);
useEffect(() => {
if (!user || !wallets.length) {
clearBalances();
return;
}
void fetchBalances(wallets);
const intervalId = window.setInterval(() => {
void fetchBalances(wallets);
}, BALANCE_REFRESH_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [clearBalances, fetchBalances, user, wallets]);
return {
portfolio,
loading,
refreshing,
error,
refresh,
};
}

View File

@@ -0,0 +1,191 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { executeBridge, type ExecuteBridgeResult } from '@/lib/bridge/execute';
import { getBridgeQuote, type BridgeQuoteResult } from '@/lib/bridge/quote';
import { getBridgeStatus, isBridgeTerminalStatus, type BridgeStatusResult } from '@/lib/bridge/status';
import {
BRIDGE_CHAINS,
getTokenConfig,
type BridgeChainKey,
} from '@/lib/bridge/constants';
import { useAuthStore } from '@/store/auth-store';
export type BridgeStatus = 'idle' | 'quoting' | 'quoted' | 'executing' | 'monitoring' | 'success' | 'error';
const BRIDGE_POLL_INTERVAL_MS = 1_000;
export interface BridgeRequestParams {
sourceChain: BridgeChainKey;
sourceToken: string;
destChain: BridgeChainKey;
destToken: string;
amount: string;
}
export function useBridge() {
const wallets = useAuthStore((state) => state.wallets);
const [status, setStatus] = useState<BridgeStatus>('idle');
const [quote, setQuote] = useState<BridgeQuoteResult | null>(null);
const [bridgeStatus, setBridgeStatus] = useState<BridgeStatusResult | null>(null);
const [requestId, setRequestId] = useState<string | null>(null);
const [txHashes, setTxHashes] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [sourceChain, setSourceChain] = useState<BridgeChainKey>('ETH');
const pollTimeoutRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (pollTimeoutRef.current) {
window.clearTimeout(pollTimeoutRef.current);
}
};
}, []);
const sourceWallet = useMemo(
() => wallets.find((w) => w.chain === BRIDGE_CHAINS[sourceChain].walletChain) ?? null,
[wallets, sourceChain],
);
function getWalletAddress(chainKey: BridgeChainKey): string | null {
const chain = BRIDGE_CHAINS[chainKey];
const wallet = wallets.find((w) => w.chain === chain.walletChain);
return wallet?.address ?? null;
}
const fetchQuote = async (request: BridgeRequestParams) => {
const userAddress = getWalletAddress(request.sourceChain);
if (!userAddress) {
throw new Error(`${request.sourceChain} wallet is not available`);
}
const recipientAddress = getWalletAddress(request.destChain);
if (!recipientAddress) {
throw new Error(`${request.destChain} wallet is not available`);
}
setStatus('quoting');
setError(null);
setBridgeStatus(null);
setRequestId(null);
setTxHashes([]);
try {
const nextQuote = await getBridgeQuote({
...request,
userAddress,
recipientAddress,
});
setQuote(nextQuote);
setStatus('quoted');
return nextQuote;
} catch (nextError) {
setQuote(null);
setStatus('error');
setError(getErrorMessage(nextError));
throw nextError;
}
};
const submitBridge = async (
request: BridgeRequestParams,
maxFeeGwei?: string | null,
priorityFeeGwei?: string | null,
) => {
if (!sourceWallet?.privateKey) {
throw new Error(`${request.sourceChain} private key is not available`);
}
if (!quote) {
throw new Error('Get a bridge quote before executing');
}
setStatus('executing');
setError(null);
try {
const tokenConfig = getTokenConfig(request.sourceChain, request.sourceToken);
const execution = await executeBridge({
sourceChain: request.sourceChain,
sourceToken: request.sourceToken,
originalAmount: request.amount,
sourceTokenDecimals: tokenConfig.decimals,
sourceTokenAddress: tokenConfig.address,
privateKey: sourceWallet.privateKey,
quote: quote.quote,
maxFeeGwei,
priorityFeeGwei,
});
setTxHashes(execution.txHashes);
if (!execution.requestId) {
throw new Error('Relay request ID was not returned');
}
setRequestId(execution.requestId);
setStatus('monitoring');
await pollBridgeStatus(execution.requestId);
return execution;
} catch (nextError) {
setStatus('error');
setError(getErrorMessage(nextError));
throw nextError;
}
};
const resetBridge = () => {
if (pollTimeoutRef.current) {
window.clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
}
setStatus('idle');
setQuote(null);
setBridgeStatus(null);
setRequestId(null);
setTxHashes([]);
setError(null);
};
const pollBridgeStatus = async (nextRequestId: string): Promise<BridgeStatusResult> => {
const nextStatus = await getBridgeStatus(nextRequestId);
setBridgeStatus(nextStatus);
if (isBridgeTerminalStatus(nextStatus.status)) {
if (nextStatus.status === 'success') {
setStatus('success');
} else {
setStatus('error');
setError(nextStatus.details || `Bridge finished with status: ${nextStatus.status}`);
}
return nextStatus;
}
await new Promise<void>((resolve) => {
pollTimeoutRef.current = window.setTimeout(() => resolve(), BRIDGE_POLL_INTERVAL_MS);
});
return pollBridgeStatus(nextRequestId);
};
return {
status,
quote,
bridgeStatus,
requestId,
txHashes,
error,
sourceChain,
setSourceChain,
sourceWallet,
fetchQuote,
submitBridge,
resetBridge,
};
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return 'Bridge request failed';
}

View File

@@ -0,0 +1,36 @@
'use client';
import { useEffect, useState } from 'react';
import { fetchGasPrices, type GasPriceData } from '@/lib/gas-price';
const GAS_REFRESH_INTERVAL_MS = 30_000;
export function useGasPrice() {
const [data, setData] = useState<GasPriceData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const next = await fetchGasPrices();
if (!cancelled) setData(next);
} catch {
/* keep last known data */
} finally {
if (!cancelled) setLoading(false);
}
};
void refresh();
const intervalId = setInterval(refresh, GAS_REFRESH_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(intervalId);
};
}, []);
return { data, loading };
}

View File

@@ -0,0 +1,64 @@
'use client';
import { useMemo, useState } from 'react';
import type { GasPriceData } from '@/lib/gas-price';
export type GasMode = 'slow' | 'normal' | 'fast' | 'custom';
export interface GasSettings {
gasMode: GasMode;
setGasMode: (mode: GasMode) => void;
customGwei: string;
setCustomGwei: (value: string) => void;
effectiveMaxFee: string | null;
effectivePriorityFee: string | null;
displayGwei: string;
}
function fmt(n: number): string {
if (n >= 1) return n.toFixed(2);
const s = n.toFixed(6);
return s.replace(/0+$/, '').replace(/\.$/, '');
}
export function useGasSettings(gasPriceData: GasPriceData | null): GasSettings {
const [gasMode, setGasMode] = useState<GasMode>('normal');
const [customGwei, setCustomGwei] = useState('');
const { effectiveMaxFee, effectivePriorityFee, displayGwei } = useMemo(() => {
if (gasMode === 'custom') {
const v = customGwei.trim();
if (!v || Number(v) <= 0) {
return { effectiveMaxFee: null, effectivePriorityFee: null, displayGwei: '-' };
}
const base = Number(v);
const priority = Math.max(0.01, base * 0.1);
return {
effectiveMaxFee: v,
effectivePriorityFee: fmt(priority),
displayGwei: `${v} gwei`,
};
}
if (!gasPriceData) {
return { effectiveMaxFee: null, effectivePriorityFee: null, displayGwei: '...' };
}
const tier = gasPriceData[gasMode];
return {
effectiveMaxFee: fmt(tier.maxFeePerGas),
effectivePriorityFee: fmt(tier.maxPriorityFeePerGas),
displayGwei: `${fmt(tier.maxFeePerGas)} gwei`,
};
}, [gasMode, customGwei, gasPriceData]);
return {
gasMode,
setGasMode,
customGwei,
setCustomGwei,
effectiveMaxFee,
effectivePriorityFee,
displayGwei,
};
}

View File

@@ -0,0 +1,274 @@
'use client';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ensureSwapApproval } from '@/lib/swap/approve';
import { executeSwap } from '@/lib/swap/execute';
import { getSwapQuote, type SwapQuoteResult } from '@/lib/swap/quote';
import { getSolSwapQuote, type SolSwapQuoteResult } from '@/lib/swap/sol/quote';
import { executeSolSwap } from '@/lib/swap/sol/execute';
import { getTrxSwapQuote, type TrxSwapQuoteResult } from '@/lib/swap/trx/quote';
import { executeTrxSwap } from '@/lib/swap/trx/execute';
import { getBscSwapQuote, type BscSwapQuoteResult } from '@/lib/swap/bsc/quote';
import { executeBscSwap } from '@/lib/swap/bsc/execute';
import { mapSwapError } from '@/lib/swap/errors';
import type { SwapQuoteRequest } from '@/lib/swap/constants';
import { type SwapChain, getSlippageBpsForChain } from '@/lib/swap/constants';
import { useAuthStore } from '@/store/auth-store';
const ESTIMATE_DEBOUNCE_MS = 500;
export type SwapStatus = 'idle' | 'quoting' | 'quoted' | 'approving' | 'swapping' | 'success' | 'error';
export interface LiveEstimate {
amountOut: string;
loading: boolean;
}
export type AnyQuoteResult = SwapQuoteResult | SolSwapQuoteResult | TrxSwapQuoteResult | BscSwapQuoteResult;
export interface MultiChainSwapRequest {
chain: SwapChain;
fromSymbol: string;
toSymbol: string;
amount: string;
slippageBps: number;
}
export function useSwap() {
const wallets = useAuthStore((state) => state.wallets);
const [status, setStatus] = useState<SwapStatus>('idle');
const [quote, setQuote] = useState<AnyQuoteResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [txHash, setTxHash] = useState<string | null>(null);
const [approvalHashes, setApprovalHashes] = useState<string[]>([]);
const [liveEstimate, setLiveEstimate] = useState<LiveEstimate | null>(null);
const [chain, setChain] = useState<SwapChain>('ETH');
const estimateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentWallet = useMemo(
() => wallets.find((w) => w.chain === chain) ?? null,
[wallets, chain]
);
const fetchQuote = async (request: MultiChainSwapRequest) => {
setStatus('quoting');
setError(null);
setTxHash(null);
setApprovalHashes([]);
try {
const nextQuote = await fetchQuoteForChain(request);
setQuote(nextQuote);
setStatus('quoted');
return nextQuote;
} catch (nextError) {
setQuote(null);
setStatus('error');
setError(mapSwapError(request.chain, nextError));
throw nextError;
}
};
const submitSwap = async (
request: MultiChainSwapRequest,
maxFeeGwei?: string | null,
priorityFeeGwei?: string | null,
) => {
if (!currentWallet?.privateKey) {
const walletError = new Error(`${request.chain} private key is not available`);
setStatus('error');
setError(walletError.message);
throw walletError;
}
if (!quote) {
const quoteError = new Error('Get a quote before swapping');
setStatus('error');
setError(quoteError.message);
throw quoteError;
}
setError(null);
setApprovalHashes([]);
try {
let result: { hash: string };
switch (request.chain) {
case 'ETH': {
// Existing ETH swap logic
if (request.fromSymbol !== 'ETH') {
setStatus('approving');
const approvalResult = await ensureSwapApproval({
privateKey: currentWallet.privateKey,
tokenSymbol: request.fromSymbol as any,
amount: request.amount,
maxFeeGwei,
priorityFeeGwei,
});
setApprovalHashes(approvalResult.approvalHashes);
}
setStatus('swapping');
const ethQuote = quote as SwapQuoteResult;
result = await executeSwap({
privateKey: currentWallet.privateKey,
request: request as SwapQuoteRequest,
quote: ethQuote,
maxFeeGwei,
priorityFeeGwei,
});
break;
}
case 'SOL': {
setStatus('swapping');
const solQuote = quote as SolSwapQuoteResult;
result = await executeSolSwap({
privateKeyHex: currentWallet.privateKey,
userPublicKey: currentWallet.address,
quoteResponse: solQuote.quoteResponse,
});
break;
}
case 'TRX': {
setStatus('swapping');
const trxQuote = quote as TrxSwapQuoteResult;
const trxResult = await executeTrxSwap({
privateKeyHex: currentWallet.privateKey,
from: request.fromSymbol,
to: request.toSymbol,
amount: trxQuote.amountInRaw,
amountOutMin: trxQuote.minimumAmountOutRaw,
userAddress: currentWallet.address,
});
setApprovalHashes(trxResult.approvalHashes);
result = trxResult;
break;
}
case 'BSC': {
setStatus('swapping');
const bscQuote = quote as BscSwapQuoteResult;
const bscResult = await executeBscSwap({
privateKeyHex: currentWallet.privateKey,
from: request.fromSymbol,
to: request.toSymbol,
amount: bscQuote.amountIn,
amountOutMin: bscQuote.minimumAmountOutRaw,
userAddress: currentWallet.address,
});
setApprovalHashes(bscResult.approvalHashes);
result = bscResult;
break;
}
}
setTxHash(result.hash);
setStatus('success');
return result;
} catch (nextError) {
setStatus('error');
setError(mapSwapError(request.chain, nextError));
throw nextError;
}
};
const estimateOutput = useCallback((request: MultiChainSwapRequest) => {
if (
request.fromSymbol === request.toSymbol ||
!request.amount ||
Number(request.amount) <= 0 ||
request.slippageBps <= 0
) {
setLiveEstimate(null);
return;
}
if (estimateTimeoutRef.current) {
clearTimeout(estimateTimeoutRef.current);
estimateTimeoutRef.current = null;
}
setLiveEstimate({ amountOut: '', loading: true });
estimateTimeoutRef.current = setTimeout(async () => {
estimateTimeoutRef.current = null;
try {
const result = await fetchQuoteForChain(request);
setLiveEstimate({
amountOut: result.amountOutFormatted,
loading: false,
});
} catch {
setLiveEstimate(null);
}
}, ESTIMATE_DEBOUNCE_MS);
}, []);
const resetSwap = useCallback(() => {
if (estimateTimeoutRef.current) {
clearTimeout(estimateTimeoutRef.current);
estimateTimeoutRef.current = null;
}
setStatus('idle');
setQuote(null);
setError(null);
setTxHash(null);
setApprovalHashes([]);
setLiveEstimate(null);
}, []);
return {
status,
quote,
error,
txHash,
approvalHashes,
liveEstimate,
chain,
setChain,
currentWallet,
fetchQuote,
submitSwap,
resetSwap,
estimateOutput,
};
}
async function fetchQuoteForChain(request: MultiChainSwapRequest): Promise<AnyQuoteResult> {
switch (request.chain) {
case 'ETH':
return getSwapQuote({
fromSymbol: request.fromSymbol as any,
toSymbol: request.toSymbol as any,
amount: request.amount,
slippageBps: request.slippageBps,
});
case 'SOL':
return getSolSwapQuote({
fromSymbol: request.fromSymbol,
toSymbol: request.toSymbol,
amount: request.amount,
slippageBps: request.slippageBps,
});
case 'TRX':
return getTrxSwapQuote({
fromSymbol: request.fromSymbol,
toSymbol: request.toSymbol,
amount: request.amount,
slippageBps: request.slippageBps,
});
case 'BSC':
return getBscSwapQuote({
fromSymbol: request.fromSymbol,
toSymbol: request.toSymbol,
amount: request.amount,
slippageBps: request.slippageBps,
});
}
}

28
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,28 @@
import { webEnv } from './env';
const API_URL = webEnv.apiUrl;
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
const res = await fetch(`${API_URL}${path}`, {
...options,
headers,
credentials: 'include',
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Request failed');
}
return data.data;
}
export const api = {
getWallets: () => request<any>('/api/wallets'),
};

View File

@@ -0,0 +1,185 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
const BSC_CHAIN_ID = 56;
const BSC_BALANCE_TIMEOUT_MS = 6_000;
const BSC_RPC_HEALTHCHECK_TIMEOUT_MS = 4_000;
const BEP20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
const BSC_RPC_CANDIDATES = dedupeUrls([
webEnv.bscRpcUrl,
'https://bsc-dataseed1.defibit.io',
'https://bsc-dataseed1.ninicoin.io',
'https://bsc-dataseed.binance.org',
]);
const BSC_TOKENS: TokenDefinition[] = [
{
chain: 'BSC',
symbol: 'BNB',
decimals: 18,
contractAddress: 'native',
coinGeckoId: 'binancecoin',
isNative: true,
},
{
chain: 'BSC',
symbol: 'USDT',
decimals: 18,
contractAddress: '0x55d398326f99059fF775485246999027B3197955',
coinGeckoId: 'tether',
isNative: false,
},
{
chain: 'BSC',
symbol: 'DOGE',
decimals: 8,
contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
coinGeckoId: 'dogecoin',
isNative: false,
},
];
export async function fetchBscBalances(address: string): Promise<ChainBalance> {
let provider: ethers.providers.StaticJsonRpcProvider;
try {
provider = await getHealthyBscProvider();
} catch (error) {
return {
chain: 'BSC',
address,
tokens: BSC_TOKENS.map(createEmptyTokenBalance),
totalUsd: null,
error: getErrorMessage(error),
};
}
const settled = await Promise.allSettled(
BSC_TOKENS.map(async (token) => readBscTokenBalance(provider, address, token))
);
const tokens: TokenBalance[] = [];
const errors: Array<{ symbol: string; message: string }> = [];
settled.forEach((result, index) => {
const token = BSC_TOKENS[index];
if (result.status === 'fulfilled') {
tokens.push(result.value);
return;
}
tokens.push(createEmptyTokenBalance(token));
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
});
const uniqueMessages = [...new Set(errors.map((item) => item.message))];
const error =
errors.length === BSC_TOKENS.length && uniqueMessages.length === 1
? uniqueMessages[0]
: errors.length
? errors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
: null;
return {
chain: 'BSC',
address,
tokens,
totalUsd: null,
error,
};
}
async function getHealthyBscProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
let lastError: unknown = new Error('No BSC RPC endpoints configured');
for (const rpcUrl of BSC_RPC_CANDIDATES) {
const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, BSC_CHAIN_ID);
try {
await withTimeout(
provider.getBlockNumber(),
BSC_RPC_HEALTHCHECK_TIMEOUT_MS,
`BSC RPC health-check timed out for ${rpcUrl}`
);
return provider;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
async function readBscTokenBalance(provider: ethers.providers.StaticJsonRpcProvider, address: string, token: TokenDefinition): Promise<TokenBalance> {
if (token.isNative) {
const balance = await withTimeout(
provider.getBalance(address),
BSC_BALANCE_TIMEOUT_MS,
'BNB balance request timed out'
);
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatEther(balance),
priceUsd: null,
valueUsd: null,
};
}
const contract = new ethers.Contract(token.contractAddress, BEP20_ABI, provider);
const balance = (await withTimeout(
contract.balanceOf(address),
BSC_BALANCE_TIMEOUT_MS,
`${token.symbol} balance request timed out`
)) as ethers.BigNumber;
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatUnits(balance, token.decimals),
priceUsd: null,
valueUsd: null,
};
}
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
return {
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
};
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
if (error.message.includes('Failed to fetch')) {
return 'BSC RPC is temporarily unavailable';
}
return error.message;
}
return 'Unable to load token balance';
}
function dedupeUrls(urls: string[]): string[] {
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
}

View File

@@ -0,0 +1,101 @@
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenDefinition } from './types';
const BTC_BALANCE_TIMEOUT_MS = 12_000;
const BTC_TOKEN: TokenDefinition = {
chain: 'BTC',
symbol: 'BTC',
decimals: 8,
contractAddress: 'native',
coinGeckoId: 'bitcoin',
isNative: true,
};
interface BlockstreamAddressResponse {
chain_stats?: {
funded_txo_sum?: number;
spent_txo_sum?: number;
};
}
export async function fetchBtcBalances(address: string): Promise<ChainBalance> {
try {
const response = await fetchJsonWithTimeout<BlockstreamAddressResponse>(
`${webEnv.btcApiUrl}/address/${address}`,
BTC_BALANCE_TIMEOUT_MS
);
const funded = response.chain_stats?.funded_txo_sum ?? 0;
const spent = response.chain_stats?.spent_txo_sum ?? 0;
const sats = Math.max(funded - spent, 0);
return {
chain: 'BTC',
address,
tokens: [
{
...BTC_TOKEN,
balanceRaw: sats.toString(),
balanceFormatted: formatFixedBalance(sats, BTC_TOKEN.decimals),
priceUsd: null,
valueUsd: null,
},
],
totalUsd: null,
error: null,
};
} catch (error) {
return {
chain: 'BTC',
address,
tokens: [
{
...BTC_TOKEN,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
},
],
totalUsd: null,
error: getErrorMessage(error),
};
}
}
async function fetchJsonWithTimeout<T>(url: string, timeoutMs: number): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`BTC API returned ${response.status}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeoutId);
}
}
function formatFixedBalance(rawValue: number, decimals: number): string {
if (rawValue === 0) {
return '0';
}
return (rawValue / 10 ** decimals).toString();
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return 'Unable to load BTC balance';
}

View File

@@ -0,0 +1,257 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
const ETH_CHAIN_ID = 1;
const ETH_BALANCE_TIMEOUT_MS = 6_000;
const ETH_RPC_HEALTHCHECK_TIMEOUT_MS = 4_000;
const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
const ETH_RPC_CANDIDATES = dedupeUrls([
webEnv.ethRpcUrl,
'https://ethereum-rpc.publicnode.com',
'https://rpc.ankr.com/eth',
'https://eth.llamarpc.com',
]);
const ETH_TOKENS: TokenDefinition[] = [
{
chain: 'ETH',
symbol: 'ETH',
decimals: 18,
contractAddress: 'native',
coinGeckoId: 'ethereum',
isNative: true,
},
{
chain: 'ETH',
symbol: 'USDT',
decimals: 6,
contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
coinGeckoId: 'tether',
isNative: false,
},
{
chain: 'ETH',
symbol: 'USDC',
decimals: 6,
contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
coinGeckoId: 'usd-coin',
isNative: false,
},
{
chain: 'ETH',
symbol: 'XAUT',
decimals: 6,
contractAddress: '0x68749665FF8D2d112Fa859AA293F07A622782F38',
coinGeckoId: 'tether-gold',
isNative: false,
},
{
chain: 'ETH',
symbol: 'UNI',
decimals: 18,
contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984',
coinGeckoId: 'uniswap',
isNative: false,
},
{
chain: 'ETH',
symbol: 'PEPE',
decimals: 18,
contractAddress: '0x6982508145454Ce325dDbE47a25d4ec3d2311933',
coinGeckoId: 'pepe',
isNative: false,
},
{
chain: 'ETH',
symbol: 'stETH',
decimals: 18,
contractAddress: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84',
coinGeckoId: 'staked-ether',
isNative: false,
},
{
chain: 'ETH',
symbol: 'SHIB',
decimals: 18,
contractAddress: '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE',
coinGeckoId: 'shiba-inu',
isNative: false,
},
{
chain: 'ETH',
symbol: 'LINK',
decimals: 18,
contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA',
coinGeckoId: 'chainlink',
isNative: false,
},
{
chain: 'ETH',
symbol: 'POL',
decimals: 18,
contractAddress: '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6',
coinGeckoId: 'polygon-ecosystem-token',
isNative: false,
},
{
chain: 'ETH',
symbol: 'WLFI',
decimals: 18,
contractAddress: '0x66f85E3865D0cFDC009acf6280a8621f12e46CCf',
coinGeckoId: 'world-liberty-financial',
isNative: false,
},
{
chain: 'ETH',
symbol: 'AAVE',
decimals: 18,
contractAddress: '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9',
coinGeckoId: 'aave',
isNative: false,
},
];
export async function fetchEthBalances(address: string): Promise<ChainBalance> {
let provider: ethers.providers.StaticJsonRpcProvider;
try {
provider = await getHealthyEthProvider();
} catch (error) {
return {
chain: 'ETH',
address,
tokens: ETH_TOKENS.map(createEmptyTokenBalance),
totalUsd: null,
error: getErrorMessage(error),
};
}
const settled = await Promise.allSettled(
ETH_TOKENS.map(async (token) => readEthTokenBalance(provider, address, token))
);
const tokens: TokenBalance[] = [];
const errors: Array<{ symbol: string; message: string }> = [];
settled.forEach((result, index) => {
const token = ETH_TOKENS[index];
if (result.status === 'fulfilled') {
tokens.push(result.value);
return;
}
tokens.push(createEmptyTokenBalance(token));
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
});
const uniqueMessages = [...new Set(errors.map((item) => item.message))];
const error =
errors.length === ETH_TOKENS.length && uniqueMessages.length === 1
? uniqueMessages[0]
: errors.length
? errors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
: null;
return {
chain: 'ETH',
address,
tokens,
totalUsd: null,
error,
};
}
async function getHealthyEthProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
let lastError: unknown = new Error('No Ethereum RPC endpoints configured');
for (const rpcUrl of ETH_RPC_CANDIDATES) {
const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, ETH_CHAIN_ID);
try {
await withTimeout(
provider.getBlockNumber(),
ETH_RPC_HEALTHCHECK_TIMEOUT_MS,
`ETH RPC health-check timed out for ${rpcUrl}`
);
return provider;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
async function readEthTokenBalance(provider: ethers.providers.StaticJsonRpcProvider, address: string, token: TokenDefinition): Promise<TokenBalance> {
if (token.isNative) {
const balance = await withTimeout(
provider.getBalance(address),
ETH_BALANCE_TIMEOUT_MS,
'ETH balance request timed out'
);
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatEther(balance),
priceUsd: null,
valueUsd: null,
};
}
const contract = new ethers.Contract(token.contractAddress, ERC20_ABI, provider);
const balance = (await withTimeout(
contract.balanceOf(address),
ETH_BALANCE_TIMEOUT_MS,
`${token.symbol} balance request timed out`
)) as ethers.BigNumber;
return {
...token,
balanceRaw: balance.toString(),
balanceFormatted: ethers.utils.formatUnits(balance, token.decimals),
priceUsd: null,
valueUsd: null,
};
}
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
return {
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
};
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
if (error.message.includes('Failed to fetch')) {
return 'Ethereum RPC is temporarily unavailable';
}
return error.message;
}
return 'Unable to load token balance';
}
function dedupeUrls(urls: string[]): string[] {
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
}

View File

@@ -0,0 +1,120 @@
import type { DerivedWallet } from '@/lib/crypto/derive-keys';
import { fetchBtcBalances } from './btc-balances';
import { fetchEthBalances } from './eth-balances';
import { fetchUsdPrices } from './prices';
import { fetchSolBalances } from './sol-balances';
import { fetchTrxBalances } from './trx-balances';
import { fetchBscBalances } from './bsc-balances';
import type { BalanceChain, ChainBalance, PortfolioBalance, TokenBalance } from './types';
const SUPPORTED_CHAINS: BalanceChain[] = ['ETH', 'BTC', 'SOL', 'TRX', 'BSC'];
const balanceFetchers: Record<BalanceChain, (address: string) => Promise<ChainBalance>> = {
ETH: fetchEthBalances,
BTC: fetchBtcBalances,
SOL: fetchSolBalances,
TRX: fetchTrxBalances,
BSC: fetchBscBalances,
};
export async function fetchAllBalances(wallets: DerivedWallet[]): Promise<PortfolioBalance> {
const settled = await Promise.allSettled(
SUPPORTED_CHAINS.map(async (chain) => {
const wallet = wallets.find((item) => item.chain === chain);
if (!wallet) {
return createMissingChainBalance(chain);
}
return balanceFetchers[chain](wallet.address);
})
);
const rawChains = settled.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
}
return createMissingChainBalance(SUPPORTED_CHAINS[index], getErrorMessage(result.reason));
});
let prices: Record<string, number> = {};
let priceError: string | null = null;
try {
const coinIds = rawChains.flatMap((chain) => chain.tokens.map((token) => token.coinGeckoId));
prices = await fetchUsdPrices(coinIds);
} catch (error) {
priceError = getErrorMessage(error);
}
const chains = rawChains.map((chain) => enrichChain(chain, prices));
return {
chains,
totalUsd: sumNullable(chains.map((chain) => chain.totalUsd)),
errors: chains.reduce<PortfolioBalance['errors']>((acc, chain) => {
if (chain.error && chain.error !== '__transient__') {
acc[chain.chain] = chain.error;
}
return acc;
}, {}),
priceError,
updatedAt: new Date().toISOString(),
};
}
function enrichChain(chain: ChainBalance, prices: Record<string, number>): ChainBalance {
const tokens = chain.tokens.map((token) => enrichToken(token, prices));
return {
...chain,
tokens,
totalUsd: sumNullable(tokens.map((token) => token.valueUsd)),
};
}
function enrichToken(token: TokenBalance, prices: Record<string, number>): TokenBalance {
const priceUsd = prices[token.coinGeckoId];
if (typeof priceUsd !== 'number') {
return token;
}
const balance = Number(token.balanceFormatted);
const valueUsd = Number.isFinite(balance) ? balance * priceUsd : null;
return {
...token,
priceUsd,
valueUsd,
};
}
function createMissingChainBalance(chain: BalanceChain, error = 'Wallet not available'): ChainBalance {
return {
chain,
address: '',
tokens: [],
totalUsd: null,
error,
};
}
function sumNullable(values: Array<number | null>): number | null {
const filtered = values.filter((value): value is number => typeof value === 'number');
if (!filtered.length) {
return null;
}
return filtered.reduce((total, value) => total + value, 0);
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return 'Unable to load balances';
}

View File

@@ -0,0 +1,70 @@
const PRICE_CACHE_TTL_MS = 60_000;
const PRICE_REQUEST_TIMEOUT_MS = 10_000;
let cachedPrices: Record<string, number> | null = null;
let cachedAt = 0;
interface CoinGeckoPriceResponse {
[coinId: string]: {
usd?: number;
};
}
export async function fetchUsdPrices(coinIds: string[]): Promise<Record<string, number>> {
const uniqueCoinIds = Array.from(new Set(coinIds.filter(Boolean)));
if (!uniqueCoinIds.length) {
return {};
}
if (
cachedPrices &&
Date.now() - cachedAt < PRICE_CACHE_TTL_MS &&
uniqueCoinIds.every((coinId) => coinId in cachedPrices!)
) {
return uniqueCoinIds.reduce<Record<string, number>>((acc, coinId) => {
acc[coinId] = cachedPrices![coinId];
return acc;
}, {});
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), PRICE_REQUEST_TIMEOUT_MS);
try {
const url = new URL('https://api.coingecko.com/api/v3/simple/price');
url.searchParams.set('ids', uniqueCoinIds.join(','));
url.searchParams.set('vs_currencies', 'usd');
const response = await fetch(url.toString(), {
signal: controller.signal,
cache: 'no-store',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`CoinGecko returned ${response.status}`);
}
const payload = (await response.json()) as CoinGeckoPriceResponse;
const prices = uniqueCoinIds.reduce<Record<string, number>>((acc, coinId) => {
const price = payload[coinId]?.usd;
if (typeof price === 'number') {
acc[coinId] = price;
}
return acc;
}, {});
cachedPrices = {
...(cachedPrices ?? {}),
...prices,
};
cachedAt = Date.now();
return prices;
} finally {
clearTimeout(timeoutId);
}
}

View File

@@ -0,0 +1,325 @@
import { Connection, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js';
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenBalance, TokenDefinition } from './types';
const SOL_BALANCE_TIMEOUT_MS = 6_000;
const SOL_RPC_CANDIDATES = dedupeUrls([
webEnv.solRpcUrl,
'https://solana.publicnode.com',
'https://api.mainnet-beta.solana.com',
]);
const SOL_TOKENS: TokenDefinition[] = [
{
chain: 'SOL',
symbol: 'SOL',
decimals: 9,
contractAddress: 'native',
coinGeckoId: 'solana',
isNative: true,
},
{
chain: 'SOL',
symbol: 'USDT',
decimals: 6,
contractAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
coinGeckoId: 'tether',
isNative: false,
},
{
chain: 'SOL',
symbol: 'USDC',
decimals: 6,
contractAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
coinGeckoId: 'usd-coin',
isNative: false,
},
{
chain: 'SOL',
symbol: 'PUMP',
decimals: 6,
contractAddress: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn',
coinGeckoId: 'pump',
isNative: false,
},
{
chain: 'SOL',
symbol: 'JUP',
decimals: 6,
contractAddress: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN',
coinGeckoId: 'jupiter-exchange-solana',
isNative: false,
},
{
chain: 'SOL',
symbol: 'WIF',
decimals: 6,
contractAddress: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm',
coinGeckoId: 'dogwifcoin',
isNative: false,
},
{
chain: 'SOL',
symbol: 'POPCAT',
decimals: 9,
contractAddress: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr',
coinGeckoId: 'popcat',
isNative: false,
},
{
chain: 'SOL',
symbol: 'TRUMP',
decimals: 6,
contractAddress: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN',
coinGeckoId: 'official-trump',
isNative: false,
},
{
chain: 'SOL',
symbol: 'PYTH',
decimals: 6,
contractAddress: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3',
coinGeckoId: 'pyth-network',
isNative: false,
},
{
chain: 'SOL',
symbol: 'JTO',
decimals: 9,
contractAddress: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL',
coinGeckoId: 'jito-governance-token',
isNative: false,
},
{
chain: 'SOL',
symbol: 'W',
decimals: 6,
contractAddress: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ',
coinGeckoId: 'wormhole',
isNative: false,
},
{
chain: 'SOL',
symbol: 'BONK',
decimals: 5,
contractAddress: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
coinGeckoId: 'bonk',
isNative: false,
},
{
chain: 'SOL',
symbol: 'ORCA',
decimals: 6,
contractAddress: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE',
coinGeckoId: 'orca',
isNative: false,
},
{
chain: 'SOL',
symbol: 'PENGU',
decimals: 6,
contractAddress: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv',
coinGeckoId: 'pudgy-penguins',
isNative: false,
},
{
chain: 'SOL',
symbol: 'RAY',
decimals: 6,
contractAddress: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R',
coinGeckoId: 'raydium',
isNative: false,
},
];
export async function fetchSolBalances(address: string): Promise<ChainBalance> {
const owner = new PublicKey(address);
const settled = await Promise.allSettled(
SOL_TOKENS.map(async (token) => readSolTokenWithFallback(owner, token))
);
const tokens: TokenBalance[] = [];
const errors: Array<{ symbol: string; message: string }> = [];
settled.forEach((result, index) => {
const token = SOL_TOKENS[index];
if (result.status === 'fulfilled') {
tokens.push(result.value);
return;
}
tokens.push(createEmptyTokenBalance(token));
errors.push({ symbol: token.symbol, message: getErrorMessage(result.reason) });
});
// Separate transient (rate-limit, access restricted) from permanent errors
const permanentErrors = errors.filter((e) => !isTransientError(e.message));
const transientErrors = errors.filter((e) => isTransientError(e.message));
const uniqueMessages = [...new Set(permanentErrors.map((item) => item.message))];
let error: string | null =
permanentErrors.length === SOL_TOKENS.length && uniqueMessages.length === 1
? uniqueMessages[0]
: permanentErrors.length
? permanentErrors.map((item) => `${item.symbol}: ${item.message}`).join(' | ')
: null;
// If some/all errors were transient, mark chain so store keeps previous balances
// '__transient__' is an internal marker — not displayed in UI
if (!error && transientErrors.length > 0) {
error = '__transient__';
}
return {
chain: 'SOL',
address,
tokens,
totalUsd: null,
error,
};
}
async function readSolTokenWithFallback(
owner: PublicKey,
token: TokenDefinition
): Promise<TokenBalance> {
let lastError: unknown;
for (const rpcUrl of SOL_RPC_CANDIDATES) {
try {
const connection = new Connection(rpcUrl, 'confirmed');
return await readSolTokenBalance(connection, owner, token);
} catch (error) {
lastError = error;
if (isMintNotFoundError(error)) {
return createEmptyTokenBalance(token);
}
}
}
throw lastError;
}
async function readSolTokenBalance(
connection: Connection,
owner: PublicKey,
token: TokenDefinition
): Promise<TokenBalance> {
if (token.isNative) {
const lamports = await withTimeout(
connection.getBalance(owner),
SOL_BALANCE_TIMEOUT_MS,
'SOL balance request timed out'
);
return {
...token,
balanceRaw: lamports.toString(),
balanceFormatted: (lamports / LAMPORTS_PER_SOL).toString(),
priceUsd: null,
valueUsd: null,
};
}
const mint = new PublicKey(token.contractAddress);
const response = await withTimeout(
connection.getParsedTokenAccountsByOwner(owner, { mint }),
SOL_BALANCE_TIMEOUT_MS,
`${token.symbol} balance request timed out`
);
const amountRaw = response.value.reduce((sum, account) => {
const parsed = account.account.data.parsed;
const amount = parsed.info.tokenAmount.amount;
return sum + BigInt(amount);
}, 0n);
return {
...token,
balanceRaw: amountRaw.toString(),
balanceFormatted: formatBigIntBalance(amountRaw, token.decimals),
priceUsd: null,
valueUsd: null,
};
}
function isMintNotFoundError(error: unknown): boolean {
if (error instanceof Error) {
const msg = error.message;
return msg.includes('could not find mint') || msg.includes('Invalid param');
}
return false;
}
function createEmptyTokenBalance(token: TokenDefinition): TokenBalance {
return {
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
};
}
function formatBigIntBalance(rawValue: bigint, decimals: number): string {
if (rawValue === 0n) {
return '0';
}
const divisor = 10n ** BigInt(decimals);
const whole = rawValue / divisor;
const fraction = rawValue % divisor;
if (fraction === 0n) {
return whole.toString();
}
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}
function isTransientError(msg: string): boolean {
return msg.includes('access restricted') ||
msg.includes('temporarily unavailable') ||
msg.includes('timed out') ||
msg.includes('rate') ||
msg.includes('429') ||
msg.includes('403');
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
const msg = error.message;
if (msg.includes('403') || msg.includes('API key is not allowed')) {
return 'Solana RPC access restricted';
}
if (msg.includes('Failed to fetch')) {
return 'Solana RPC is temporarily unavailable';
}
return msg;
}
return 'Unable to load SOL balance';
}
function dedupeUrls(urls: string[]): string[] {
return [...new Set(urls.map((url) => url.trim()).filter(Boolean))];
}

View File

@@ -0,0 +1,133 @@
import { webEnv } from '@/lib/env';
import type { ChainBalance, TokenDefinition } from './types';
const TRX_BALANCE_TIMEOUT_MS = 12_000;
const TRX_TOKENS: TokenDefinition[] = [
{
chain: 'TRX',
symbol: 'TRX',
decimals: 6,
contractAddress: 'native',
coinGeckoId: 'tron',
isNative: true,
},
{
chain: 'TRX',
symbol: 'USDT',
decimals: 6,
contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
coinGeckoId: 'tether',
isNative: false,
},
];
interface TronGridAccountResponse {
data?: Array<{
balance?: number;
trc20?: Array<Record<string, string>>;
}>;
}
export async function fetchTrxBalances(address: string): Promise<ChainBalance> {
try {
const response = await fetchJsonWithTimeout<TronGridAccountResponse>(
`${webEnv.apiUrl}/api/tron/account/${address}`,
TRX_BALANCE_TIMEOUT_MS
);
const account = response.data?.[0];
const nativeRaw = account?.balance ?? 0;
const trc20Balances = account?.trc20 ?? [];
const usdtRaw = trc20Balances.reduce((current, entry) => {
const next = entry[TRX_TOKENS[1].contractAddress];
return next ?? current;
}, '0');
return {
chain: 'TRX',
address,
tokens: [
{
...TRX_TOKENS[0],
balanceRaw: nativeRaw.toString(),
balanceFormatted: formatBalance(nativeRaw.toString(), TRX_TOKENS[0].decimals),
priceUsd: null,
valueUsd: null,
},
{
...TRX_TOKENS[1],
balanceRaw: usdtRaw,
balanceFormatted: formatBalance(usdtRaw, TRX_TOKENS[1].decimals),
priceUsd: null,
valueUsd: null,
},
],
totalUsd: null,
error: null,
};
} catch (error) {
console.warn(`[TRX] balance fetch failed:`, error);
return {
chain: 'TRX',
address,
tokens: TRX_TOKENS.map((token) => ({
...token,
balanceRaw: '0',
balanceFormatted: '0',
priceUsd: null,
valueUsd: null,
})),
totalUsd: null,
error: getErrorMessage(error),
};
}
}
async function fetchJsonWithTimeout<T>(url: string, timeoutMs: number): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`TRON API returned ${response.status}`);
}
return (await response.json()) as T;
} finally {
clearTimeout(timeoutId);
}
}
function formatBalance(rawValue: string, decimals: number): string {
const bigintValue = BigInt(rawValue || '0');
if (bigintValue === 0n) {
return '0';
}
const divisor = 10n ** BigInt(decimals);
const whole = bigintValue / divisor;
const fraction = bigintValue % divisor;
if (fraction === 0n) {
return whole.toString();
}
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return 'Unable to load TRX balance';
}

View File

@@ -0,0 +1,33 @@
export type BalanceChain = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
export interface TokenDefinition {
chain: BalanceChain;
symbol: string;
decimals: number;
contractAddress: string | 'native';
coinGeckoId: string;
isNative: boolean;
}
export interface TokenBalance extends TokenDefinition {
balanceRaw: string;
balanceFormatted: string;
priceUsd: number | null;
valueUsd: number | null;
}
export interface ChainBalance {
chain: BalanceChain;
address: string;
tokens: TokenBalance[];
totalUsd: number | null;
error: string | null;
}
export interface PortfolioBalance {
chains: ChainBalance[];
totalUsd: number | null;
errors: Partial<Record<BalanceChain, string>>;
priceError: string | null;
updatedAt: string;
}

View File

@@ -0,0 +1,112 @@
export const RELAY_PROXY_BASE_URL = '/api/relay';
export const RELAY_REQUEST_TIMEOUT_MS = 15_000;
// ── Bridge platform fee (0.7%) ──
export const BRIDGE_FEE_BPS = 70; // 0.7%
export const BRIDGE_FEE_RECIPIENT_EVM = '0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718';
export const BRIDGE_FEE_RECIPIENT_SOL = 'Co43MKwqMRMCvhscVVrtQWvma87NEV7ba4cfo8cksgzJ';
export const BRIDGE_FEE_RECIPIENT_TRX = 'TYTfrem65362TFyQSARTheeYza1GQA37Ug';
// ─── Chain types ───
export type BridgeChainKey = 'ETH' | 'BSC' | 'SOL' | 'TRX';
export interface BridgeCurrencyConfig {
symbol: string;
address: string;
decimals: number;
}
export interface BridgeChainConfig {
key: BridgeChainKey;
label: string;
chainId: number;
walletChain: 'ETH' | 'BSC' | 'SOL' | 'TRX';
explorerTxBaseUrl: string;
tokens: Record<string, BridgeCurrencyConfig>;
}
export const BRIDGE_CHAINS: Record<BridgeChainKey, BridgeChainConfig> = {
ETH: {
key: 'ETH',
label: 'Ethereum',
chainId: 1,
walletChain: 'ETH',
explorerTxBaseUrl: 'https://etherscan.io/tx/',
tokens: {
ETH: { symbol: 'ETH', address: '0x0000000000000000000000000000000000000000', decimals: 18 },
USDT: { symbol: 'USDT', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 },
USDC: { symbol: 'USDC', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 },
},
},
SOL: {
key: 'SOL',
label: 'Solana',
chainId: 792703809,
walletChain: 'SOL',
explorerTxBaseUrl: 'https://solscan.io/tx/',
tokens: {
SOL: { symbol: 'SOL', address: '11111111111111111111111111111111', decimals: 9 },
USDT: { symbol: 'USDT', address: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 },
USDC: { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 },
},
},
BSC: {
key: 'BSC',
label: 'BNB Smart Chain',
chainId: 56,
walletChain: 'BSC',
explorerTxBaseUrl: 'https://bscscan.com/tx/',
tokens: {
BNB: { symbol: 'BNB', address: '0x0000000000000000000000000000000000000000', decimals: 18 },
USDT: { symbol: 'USDT', address: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 },
},
},
TRX: {
key: 'TRX',
label: 'TRON',
chainId: 728126428,
walletChain: 'TRX',
explorerTxBaseUrl: 'https://tronscan.org/#/transaction/',
tokens: {
USDT: { symbol: 'USDT', address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 },
},
},
};
export const BRIDGE_CHAIN_OPTIONS: BridgeChainKey[] = ['ETH', 'BSC', 'SOL', 'TRX'];
// ─── Helpers ───
export function getDestinationChainOptions(sourceChain: BridgeChainKey): BridgeChainKey[] {
return BRIDGE_CHAIN_OPTIONS.filter((c) => c !== sourceChain);
}
export function getTokenOptions(chainKey: BridgeChainKey): string[] {
return Object.keys(BRIDGE_CHAINS[chainKey].tokens);
}
export function getDefaultToken(chainKey: BridgeChainKey): string {
const tokens = getTokenOptions(chainKey);
return tokens[0];
}
export function getTokenConfig(chainKey: BridgeChainKey, tokenSymbol: string): BridgeCurrencyConfig {
const token = BRIDGE_CHAINS[chainKey].tokens[tokenSymbol];
if (!token) {
throw new Error(`Token ${tokenSymbol} not found on ${BRIDGE_CHAINS[chainKey].label}`);
}
return token;
}
// ─── Request type ───
export interface BridgeQuoteRequest {
sourceChain: BridgeChainKey;
sourceToken: string;
destChain: BridgeChainKey;
destToken: string;
amount: string;
userAddress: string;
recipientAddress: string;
}

View File

@@ -0,0 +1,482 @@
import { ethers } from 'ethers';
import {
Connection,
Keypair,
PublicKey,
TransactionInstruction,
TransactionMessage,
VersionedTransaction,
AddressLookupTableAccount,
} from '@solana/web3.js';
import { createEthProvider } from '@/lib/eth-provider';
import { webEnv } from '@/lib/env';
import { BSC_GAS_PRICE } from '@/lib/crypto/bsc-constants';
import {
BRIDGE_CHAINS,
BRIDGE_FEE_BPS,
BRIDGE_FEE_RECIPIENT_EVM,
BRIDGE_FEE_RECIPIENT_SOL,
BRIDGE_FEE_RECIPIENT_TRX,
RELAY_PROXY_BASE_URL,
RELAY_REQUEST_TIMEOUT_MS,
type BridgeChainKey,
} from './constants';
import type { RelayQuoteResponse, RelayStep } from './quote';
const provider = createEthProvider();
// TYTfrem65362TFyQSARTheeYza1GQA37Ug → hex (20 bytes, no 0x prefix)
const BRIDGE_FEE_RECIPIENT_TRX_HEX = 'f6b4d4e650fc67982894f37ba97ab2496781ddb6';
interface ExecuteBridgeParams {
sourceChain: BridgeChainKey;
sourceToken: string;
originalAmount: string;
sourceTokenDecimals: number;
sourceTokenAddress: string;
privateKey: string;
quote: RelayQuoteResponse;
maxFeeGwei?: string | null;
priorityFeeGwei?: string | null;
}
export interface ExecuteBridgeResult {
requestId: string | null;
txHashes: string[];
}
export async function executeBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
switch (params.sourceChain) {
case 'ETH':
return executeEvmBridge(params, provider);
case 'BSC':
return executeEvmBridge(params, new ethers.providers.StaticJsonRpcProvider(webEnv.bscRpcUrl, 56));
case 'SOL':
return executeSolBridge(params);
case 'TRX':
return executeTrxBridge(params);
}
}
// ─── EVM origin (existing logic) ───
async function executeEvmBridge(
params: ExecuteBridgeParams,
evmProvider: ethers.providers.Provider,
): Promise<ExecuteBridgeResult> {
const wallet = new ethers.Wallet(params.privateKey, evmProvider);
const isBsc = params.sourceChain === 'BSC';
const txHashes: string[] = [];
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
// ── Send 0.7% platform fee before bridge ──
await sendEvmBridgeFee(wallet, params, isBsc);
for (const step of params.quote.steps) {
if (!step.items?.length) continue;
for (const item of step.items) {
if (step.kind === 'signature') {
await executeSignatureStep(wallet, step, item.data);
} else {
const hash = await executeEvmTransactionStep(wallet, item.data, params.maxFeeGwei, params.priorityFeeGwei, isBsc);
txHashes.push(hash);
}
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
}
}
return { requestId, txHashes };
}
async function executeEvmTransactionStep(
wallet: ethers.Wallet,
data: Record<string, any>,
maxFeeGwei?: string | null,
priorityFeeGwei?: string | null,
isBsc?: boolean,
): Promise<string> {
const gasOverrides = isBsc
? { gasPrice: BSC_GAS_PRICE }
: maxFeeGwei?.trim()
? {
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
}
: {
...(data.maxFeePerGas ? { maxFeePerGas: ethers.BigNumber.from(data.maxFeePerGas) } : {}),
...(data.maxPriorityFeePerGas ? { maxPriorityFeePerGas: ethers.BigNumber.from(data.maxPriorityFeePerGas) } : {}),
};
const response = await wallet.sendTransaction({
to: data.to,
data: data.data,
value: data.value ? ethers.BigNumber.from(data.value) : ethers.constants.Zero,
gasLimit: data.gas ? ethers.BigNumber.from(data.gas) : undefined,
...gasOverrides,
});
const receipt = await response.wait();
if (!receipt || receipt.status !== 1) {
throw new Error('Bridge transaction reverted');
}
return response.hash;
}
// ─── Bridge Fee Helpers ───
async function sendEvmBridgeFee(wallet: ethers.Wallet, params: ExecuteBridgeParams, isBsc?: boolean): Promise<void> {
const fullAmountRaw = ethers.utils.parseUnits(params.originalAmount, params.sourceTokenDecimals);
const feeAmount = fullAmountRaw.mul(BRIDGE_FEE_BPS).div(10000);
if (feeAmount.isZero()) return;
const gasOverrides = isBsc ? { gasPrice: BSC_GAS_PRICE } : {};
const isNative = params.sourceTokenAddress === '0x0000000000000000000000000000000000000000';
if (isNative) {
const tx = await wallet.sendTransaction({
to: BRIDGE_FEE_RECIPIENT_EVM,
value: feeAmount,
...gasOverrides,
});
await tx.wait();
} else {
const tokenContract = new ethers.Contract(
params.sourceTokenAddress,
['function transfer(address to, uint256 amount) returns (bool)'],
wallet,
);
const tx = await tokenContract.transfer(BRIDGE_FEE_RECIPIENT_EVM, feeAmount, gasOverrides);
await tx.wait();
}
}
async function sendSolBridgeFee(
connection: Connection,
keypair: Keypair,
params: ExecuteBridgeParams,
): Promise<void> {
const { SystemProgram } = await import('@solana/web3.js');
const fullAmountRaw = BigInt(
Math.round(Number(params.originalAmount) * 10 ** params.sourceTokenDecimals),
);
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
if (feeAmount === 0n) return;
const feeRecipient = new PublicKey(BRIDGE_FEE_RECIPIENT_SOL);
const isNative = params.sourceTokenAddress === '11111111111111111111111111111111';
// Bridge fee only supports native SOL transfers
// (SOL bridge primarily uses SOL, USDT, USDC — SPL fee handled off-chain if needed)
if (!isNative) return;
const instruction = SystemProgram.transfer({
fromPubkey: keypair.publicKey,
toPubkey: feeRecipient,
lamports: feeAmount,
});
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
const messageV0 = new TransactionMessage({
payerKey: keypair.publicKey,
recentBlockhash: latestBlockhash.blockhash,
instructions: [instruction],
}).compileToV0Message();
const tx = new VersionedTransaction(messageV0);
tx.sign([keypair]);
const sig = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: false });
await connection.confirmTransaction(
{ signature: sig, blockhash: latestBlockhash.blockhash, lastValidBlockHeight: latestBlockhash.lastValidBlockHeight },
'confirmed',
);
}
async function sendTrxBridgeFee(
signingKey: ethers.utils.SigningKey,
apiUrl: string,
params: ExecuteBridgeParams,
): Promise<void> {
const decimals = params.sourceTokenDecimals;
const fullAmountRaw = BigInt(
Math.round(Number(params.originalAmount) * 10 ** decimals),
);
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
if (feeAmount === 0n) return;
// TRX bridge only supports USDT (TRC-20) — build a TRC20 transfer via API
// Use the tron proxy to build a transfer tx, sign it, and broadcast
const buildResp = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: params.quote.steps[0]?.items?.[0]?.data?.parameter?.owner_address
?? ethers.utils.computeAddress(signingKey.publicKey).toLowerCase(),
contract_address: params.sourceTokenAddress,
function_selector: 'transfer(address,uint256)',
parameter:
BRIDGE_FEE_RECIPIENT_TRX_HEX.padStart(64, '0') +
feeAmount.toString(16).padStart(64, '0'),
call_value: 0,
fee_limit: 100000000,
visible: false,
}),
});
if (!buildResp.ok) return; // Fee transfer is best-effort; don't block bridge
const buildResult = await buildResp.json();
const tx = buildResult.transaction;
if (!tx?.txID) return;
// Sign and broadcast
const digest = ethers.utils.arrayify('0x' + tx.txID);
const signature = signingKey.signDigest(digest);
const sigHex = ethers.utils.joinSignature(signature).slice(2);
const broadcastResp = await fetch(`${apiUrl}/api/tron/broadcasttransaction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...tx, signature: [sigHex] }),
});
// Wait for broadcast, but don't fail the bridge if fee transfer fails
await broadcastResp.json();
}
async function executeSignatureStep(wallet: ethers.Wallet, step: RelayStep, data: Record<string, any>): Promise<void> {
const signData = data.sign;
const postData = data.post;
if (!signData || !postData?.endpoint) {
throw new Error(`Invalid signature step payload for ${step.id}`);
}
const signature = await signRelayPayload(wallet, signData);
const endpoint = new URL(`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}${postData.endpoint}`);
endpoint.searchParams.set('signature', signature);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(endpoint.toString(), {
method: postData.method ?? 'POST',
signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postData.body ?? {}),
});
if (!response.ok) {
const payload = await response.text();
throw new Error(payload || 'Relay signature submission failed');
}
} finally {
clearTimeout(timeoutId);
}
}
async function signRelayPayload(wallet: ethers.Wallet, signData: Record<string, any>): Promise<string> {
if (signData.signatureKind === 'eip191') {
const message = typeof signData.message === 'string' && signData.message.startsWith('0x')
? ethers.utils.arrayify(signData.message)
: signData.message;
return wallet.signMessage(message);
}
if (signData.signatureKind === 'eip712') {
const { EIP712Domain, ...types } = signData.types ?? {};
return wallet._signTypedData(signData.domain ?? {}, types, signData.value ?? {});
}
throw new Error(`Unsupported Relay signature kind: ${signData.signatureKind}`);
}
// ─── SOL origin ───
async function executeSolBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
const connection = new Connection(webEnv.solRpcUrl, 'confirmed');
const keypair = Keypair.fromSecretKey(Buffer.from(params.privateKey, 'hex'));
const txHashes: string[] = [];
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
// ── Send 0.7% platform fee before bridge ──
await sendSolBridgeFee(connection, keypair, params);
for (const step of params.quote.steps) {
if (!step.items?.length) continue;
for (const item of step.items) {
const data = item.data;
if (!data.instructions || !Array.isArray(data.instructions)) {
throw new Error('Expected Solana instructions in bridge step');
}
const hash = await executeSolTransactionStep(connection, keypair, data);
txHashes.push(hash);
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
}
}
return { requestId, txHashes };
}
async function executeSolTransactionStep(
connection: Connection,
keypair: Keypair,
data: Record<string, any>,
): Promise<string> {
// Build instructions from Relay response
const instructions: TransactionInstruction[] = data.instructions.map((ix: any) => ({
programId: new PublicKey(ix.programId),
keys: ix.keys.map((k: any) => ({
pubkey: new PublicKey(k.pubkey),
isSigner: k.isSigner,
isWritable: k.isWritable,
})),
data: Buffer.from(ix.data, 'hex'),
}));
// Load address lookup tables
const lookupTableAddresses: string[] = data.addressLookupTableAddresses ?? [];
const lookupTables: AddressLookupTableAccount[] = [];
for (const addr of lookupTableAddresses) {
const account = await connection.getAddressLookupTable(new PublicKey(addr));
if (account.value) {
lookupTables.push(account.value);
}
}
// Build versioned transaction
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
const messageV0 = new TransactionMessage({
payerKey: keypair.publicKey,
recentBlockhash: latestBlockhash.blockhash,
instructions,
}).compileToV0Message(lookupTables);
const transaction = new VersionedTransaction(messageV0);
transaction.sign([keypair]);
// Send and confirm
const signature = await connection.sendRawTransaction(transaction.serialize(), {
skipPreflight: false,
maxRetries: 2,
});
await connection.confirmTransaction(
{
signature,
blockhash: latestBlockhash.blockhash,
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
},
'confirmed',
);
return signature;
}
// ─── TRX origin ───
async function executeTrxBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
const signingKey = new ethers.utils.SigningKey('0x' + params.privateKey);
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const txHashes: string[] = [];
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
// ── Send 0.7% platform fee before bridge ──
await sendTrxBridgeFee(signingKey, apiUrl, params);
for (const step of params.quote.steps) {
if (!step.items?.length) continue;
for (const item of step.items) {
const data = item.data;
if (data.type !== 'TriggerSmartContract') {
throw new Error(`Unsupported TRX step type: ${data.type}`);
}
const hash = await executeTrxTransactionStep(signingKey, apiUrl, data);
txHashes.push(hash);
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
}
// Small delay between steps (e.g., approve → deposit)
if (step.items.length > 0 && step !== params.quote.steps[params.quote.steps.length - 1]) {
await delay(3000);
}
}
return { requestId, txHashes };
}
async function executeTrxTransactionStep(
signingKey: ethers.utils.SigningKey,
apiUrl: string,
data: Record<string, any>,
): Promise<string> {
// 1. Build transaction via TronGrid triggersmartcontract
const buildResponse = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data.parameter),
});
if (!buildResponse.ok) {
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build TRX bridge tx' }));
throw new Error(body.error || `TRX bridge build failed (${buildResponse.status})`);
}
const buildResult = await buildResponse.json();
const tx = buildResult.transaction;
if (!tx?.txID) {
throw new Error('TronGrid did not return a valid transaction');
}
// 2. Sign txID with secp256k1
const digest = ethers.utils.arrayify('0x' + tx.txID);
const signature = signingKey.signDigest(digest);
const sigHex = ethers.utils.joinSignature(signature).slice(2); // 65 bytes hex, no 0x
const signedTx = {
...tx,
signature: [sigHex],
};
// 3. Broadcast
const broadcastResponse = await fetch(`${apiUrl}/api/tron/broadcasttransaction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedTx),
});
const result = await broadcastResponse.json();
if (!result.result) {
const errorMsg = result.message || result.code || 'TRX broadcast failed';
throw new Error(`TRX bridge broadcast error: ${errorMsg}`);
}
return tx.txID;
}
// ─── Utils ───
function extractRequestId(endpoint?: string): string | null {
if (!endpoint) return null;
try {
const url = new URL(endpoint, 'https://api.relay.link');
return url.searchParams.get('requestId');
} catch {
return null;
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,184 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
import {
BRIDGE_CHAINS,
BRIDGE_FEE_BPS,
RELAY_PROXY_BASE_URL,
RELAY_REQUEST_TIMEOUT_MS,
getTokenConfig,
type BridgeQuoteRequest,
} from './constants';
export interface RelayStep {
id: string;
kind: 'transaction' | 'signature';
requestId?: string;
items: Array<{
status: 'complete' | 'incomplete';
data: Record<string, any>;
check?: {
endpoint: string;
method: 'GET' | 'POST';
};
}>;
}
export interface RelayQuoteResponse {
steps: RelayStep[];
fees?: Record<string, { amountUsd?: string; amountFormatted?: string; currency?: { symbol?: string } }>;
details?: {
timeEstimate?: number;
currencyOut?: {
currency?: {
symbol?: string;
decimals?: number;
};
amount?: string;
amountFormatted?: string;
};
totalImpact?: {
usd?: string;
percent?: string;
};
slippageTolerance?: {
destination?: {
percent?: string;
};
};
};
}
export interface BridgeQuoteResult {
quote: RelayQuoteResponse;
sourceChain: string;
requestId: string | null;
outputAmountFormatted: string;
outputSymbol: string;
minimumAmountFormatted: string;
feeSummary: string;
timeEstimateSeconds: number | null;
}
export async function getBridgeQuote(request: BridgeQuoteRequest): Promise<BridgeQuoteResult> {
if (!request.amount || Number(request.amount) <= 0) {
throw new Error('Enter a valid bridge amount');
}
const sourceChainConfig = BRIDGE_CHAINS[request.sourceChain];
const destChainConfig = BRIDGE_CHAINS[request.destChain];
const sourceTokenConfig = getTokenConfig(request.sourceChain, request.sourceToken);
const destTokenConfig = getTokenConfig(request.destChain, request.destToken);
// Apply 0.7% platform fee — bridge only 99.3% of input
const fullAmountRaw = BigInt(parseAmountToRaw(request.amount, sourceTokenConfig.decimals));
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
const amount = (fullAmountRaw - feeAmount).toString();
const quote = await fetchRelayJson<RelayQuoteResponse>(
`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}/quote/v2`,
{
method: 'POST',
body: JSON.stringify({
user: request.userAddress,
recipient: request.recipientAddress,
originChainId: sourceChainConfig.chainId,
destinationChainId: destChainConfig.chainId,
originCurrency: sourceTokenConfig.address,
destinationCurrency: destTokenConfig.address,
amount,
tradeType: 'EXACT_INPUT',
}),
},
);
const requestId = quote.steps.find((step) => step.requestId)?.requestId ?? null;
const currencyOut = quote.details?.currencyOut;
return {
quote,
sourceChain: request.sourceChain,
requestId,
outputAmountFormatted: currencyOut?.amountFormatted ?? 'Unavailable',
outputSymbol: currencyOut?.currency?.symbol ?? destTokenConfig.symbol,
minimumAmountFormatted: computeMinimumAmount(currencyOut, destTokenConfig.decimals),
feeSummary: buildFeeSummary(quote.fees),
timeEstimateSeconds: quote.details?.timeEstimate ?? null,
};
}
async function fetchRelayJson<T>(url: string, options: RequestInit): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(options.headers ?? {}),
},
});
const payload = (await response.json()) as T & { message?: string };
if (!response.ok) {
throw new Error((payload as { message?: string }).message || 'Relay quote request failed');
}
return payload;
} finally {
clearTimeout(timeoutId);
}
}
function buildFeeSummary(fees: RelayQuoteResponse['fees']): string {
if (!fees) return 'Unavailable';
const usdTotal = Object.values(fees).reduce((total, fee) => {
const amountUsd = Number(fee.amountUsd ?? 0);
return Number.isFinite(amountUsd) ? total + amountUsd : total;
}, 0);
if (usdTotal > 0) return `$${usdTotal.toFixed(4)}`;
const relayerFee = fees.relayer;
if (relayerFee?.amountFormatted && relayerFee.currency?.symbol) {
return `${relayerFee.amountFormatted} ${relayerFee.currency.symbol}`;
}
return 'Unavailable';
}
function computeMinimumAmount(
currencyOut: NonNullable<RelayQuoteResponse['details']>['currencyOut'] | undefined,
decimals: number,
): string {
if (!currencyOut?.amount || !currencyOut.currency?.decimals) {
return 'Unavailable';
}
// Apply 2% slippage to displayed minimum
const raw = BigInt(currencyOut.amount);
const minimum = (raw * 98n) / 100n;
return formatRawUnits(minimum.toString(), currencyOut.currency.decimals);
}
function parseAmountToRaw(amount: string, decimals: number): string {
const parts = amount.split('.');
const whole = parts[0] || '0';
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
const raw = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction);
return raw.toString();
}
function formatRawUnits(raw: string, decimals: number): string {
const value = BigInt(raw);
if (value === 0n) return '0';
const divisor = 10n ** BigInt(decimals);
const whole = value / divisor;
const fraction = value % divisor;
if (fraction === 0n) return whole.toString();
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}

View File

@@ -0,0 +1,40 @@
import { webEnv } from '@/lib/env';
import { RELAY_PROXY_BASE_URL, RELAY_REQUEST_TIMEOUT_MS } from './constants';
export interface BridgeStatusResult {
status: 'waiting' | 'pending' | 'submitted' | 'success' | 'delayed' | 'refunded' | 'failure';
details?: string;
inTxHashes?: string[];
txHashes?: string[];
updatedAt?: number;
originChainId?: number;
destinationChainId?: number;
}
export async function getBridgeStatus(requestId: string): Promise<BridgeStatusResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(
`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}/intents/status/v3?requestId=${encodeURIComponent(requestId)}`,
{
signal: controller.signal,
cache: 'no-store',
}
);
const payload = (await response.json()) as BridgeStatusResult & { message?: string };
if (!response.ok) {
throw new Error(payload.message || 'Unable to fetch bridge status');
}
return payload;
} finally {
clearTimeout(timeoutId);
}
}
export function isBridgeTerminalStatus(status: BridgeStatusResult['status']): boolean {
return status === 'success' || status === 'failure' || status === 'refunded';
}

View File

@@ -0,0 +1,4 @@
import { ethers } from 'ethers';
/** Fixed gas price for all BSC transactions (swaps & sends) */
export const BSC_GAS_PRICE = ethers.utils.parseUnits('0.055', 'gwei');

View File

@@ -0,0 +1,17 @@
import { HDKey } from '@scure/bip32';
import * as bitcoin from 'bitcoinjs-lib';
export function deriveBtcWallet(seed: Uint8Array) {
const root = HDKey.fromMasterSeed(seed);
const child = root.derive("m/84'/0'/0'/0/0");
const { address } = bitcoin.payments.p2wpkh({
pubkey: Buffer.from(child.publicKey!),
network: bitcoin.networks.bitcoin,
});
return {
address: address!,
privateKey: Buffer.from(child.privateKey!).toString('hex'),
};
}

View File

@@ -0,0 +1,9 @@
import { ethers } from 'ethers';
export function deriveEthWallet(mnemonicPhrase: string) {
const wallet = ethers.Wallet.fromMnemonic(mnemonicPhrase, "m/44'/60'/0'/0/0");
return {
address: wallet.address,
privateKey: wallet.privateKey,
};
}

View File

@@ -0,0 +1,14 @@
import { generateMnemonic as genMnemonic, mnemonicToSeed, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
export function generateMnemonic(): string {
return genMnemonic(wordlist, 128);
}
export async function mnemonicToSeedBytes(mnemonic: string): Promise<Uint8Array> {
return mnemonicToSeed(mnemonic);
}
export function isValidMnemonic(mnemonic: string): boolean {
return validateMnemonic(mnemonic, wordlist);
}

View File

@@ -0,0 +1,13 @@
import { Keypair } from '@solana/web3.js';
import { derivePath } from 'ed25519-hd-key';
export function deriveSolWallet(seed: Uint8Array) {
const path = "m/44'/501'/0'/0'";
const derived = derivePath(path, Buffer.from(seed).toString('hex'));
const keypair = Keypair.fromSeed(derived.key);
return {
address: keypair.publicKey.toBase58(),
privateKey: Buffer.from(keypair.secretKey).toString('hex'),
};
}

View File

@@ -0,0 +1,58 @@
import { ethers } from 'ethers';
export function deriveTrxWallet(mnemonicPhrase: string) {
const hdNode = ethers.utils.HDNode.fromMnemonic(mnemonicPhrase).derivePath("m/44'/195'/0'/0/0");
const ethAddress = ethers.utils.computeAddress(hdNode.publicKey);
const address = ethToTronAddress(ethAddress);
return {
address,
privateKey: hdNode.privateKey.slice(2),
};
}
function ethToTronAddress(ethAddress: string): string {
const hex = '41' + ethAddress.slice(2);
return hexToBase58Check(hex);
}
function hexToBase58Check(hex: string): string {
const bytes = hexToBytes(hex);
const hash1 = sha256Sync(bytes);
const hash2 = sha256Sync(hash1);
const checksum = hash2.slice(0, 4);
const payload = new Uint8Array(bytes.length + 4);
payload.set(bytes);
payload.set(checksum, bytes.length);
return base58Encode(payload);
}
function hexToBytes(hex: string): Uint8Array {
const arr = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
arr[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return arr;
}
function sha256Sync(data: Uint8Array): Uint8Array {
const { createHash } = require('crypto');
return new Uint8Array(createHash('sha256').update(data).digest());
}
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function base58Encode(data: Uint8Array): string {
let num = BigInt('0x' + Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(''));
let result = '';
while (num > 0n) {
const mod = Number(num % 58n);
result = BASE58_ALPHABET[mod] + result;
num = num / 58n;
}
for (const byte of data) {
if (byte === 0) result = '1' + result;
else break;
}
return result;
}

16
apps/web/src/lib/env.ts Normal file
View File

@@ -0,0 +1,16 @@
function readEnv(name: string, fallback: string): string {
const value = process.env[name];
return value && value.trim() ? value : fallback;
}
function readUrlEnv(name: string, fallback: string): string {
return readEnv(name, fallback).replace(/\/+$/, '');
}
export const webEnv = {
apiUrl: readUrlEnv('NEXT_PUBLIC_API_URL', 'http://localhost:3001'),
ethRpcUrl: readUrlEnv('NEXT_PUBLIC_ETH_RPC_URL', 'https://ethereum-rpc.publicnode.com'),
solRpcUrl: readUrlEnv('NEXT_PUBLIC_SOL_RPC_URL', 'https://solana.publicnode.com'),
btcApiUrl: readUrlEnv('NEXT_PUBLIC_BTC_API_URL', 'https://blockstream.info/api'),
bscRpcUrl: readUrlEnv('NEXT_PUBLIC_BSC_RPC_URL', 'https://bsc-dataseed.binance.org'),
} as const;

View File

@@ -0,0 +1,25 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
const MAINNET_NETWORK = { chainId: 1, name: 'mainnet' } as const;
const ETH_RPC_FALLBACKS = [
'https://ethereum-rpc.publicnode.com',
'https://rpc.ankr.com/eth',
'https://eth.llamarpc.com',
];
function getEthRpcUrls(): string[] {
return [...new Set([webEnv.ethRpcUrl, ...ETH_RPC_FALLBACKS].map((url) => url.trim()).filter(Boolean))];
}
export function createEthProvider(): ethers.providers.FallbackProvider {
const providerConfigs = getEthRpcUrls().map((url, index) => ({
provider: new ethers.providers.StaticJsonRpcProvider(url, MAINNET_NETWORK),
priority: index + 1,
weight: 1,
stallTimeout: 1_200,
}));
return new ethers.providers.FallbackProvider(providerConfigs, 1);
}

View File

@@ -0,0 +1,66 @@
import { ethers } from 'ethers';
import { createEthProvider } from '@/lib/eth-provider';
const FETCH_TIMEOUT_MS = 8_000;
// Fixed small priority tips (gwei) — just enough to get included
const PRIORITY_FEE: Record<'slow' | 'normal' | 'fast', number> = {
slow: 0.01,
normal: 0.015,
fast: 0.03,
};
export interface GasTier {
maxFeePerGas: number;
maxPriorityFeePerGas: number;
confidence: number;
}
export interface GasPriceData {
baseFeeGwei: number;
slow: GasTier;
normal: GasTier;
fast: GasTier;
}
export async function fetchGasPrices(): Promise<GasPriceData> {
const provider = createEthProvider();
const feeData = await withTimeout(
provider.getFeeData(),
FETCH_TIMEOUT_MS,
'ETH fee data request timed out',
);
if (!feeData.lastBaseFeePerGas) {
throw new Error('Could not get base fee from ETH RPC');
}
// Convert wei → gwei as float
const baseFeeGwei = parseFloat(ethers.utils.formatUnits(feeData.lastBaseFeePerGas, 'gwei'));
function buildTier(mode: 'slow' | 'normal' | 'fast', confidence: number): GasTier {
const priority = PRIORITY_FEE[mode];
return {
maxFeePerGas: baseFeeGwei + priority,
maxPriorityFeePerGas: priority,
confidence,
};
}
return {
baseFeeGwei,
slow: buildTier('slow', 70),
normal: buildTier('normal', 90),
fast: buildTier('fast', 99),
};
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => { clearTimeout(timeoutId); resolve(value); })
.catch((error) => { clearTimeout(timeoutId); reject(error); });
});
}

View File

@@ -0,0 +1,146 @@
import { SEND_CHAINS, getDefaultToken, type SendChain } from '@/lib/send/constants';
/** Safely resolve token config, falling back to chain's native token */
function resolveToken(chain: SendChain, token: string) {
const chainCfg = SEND_CHAINS[chain];
return chainCfg.tokens[token] ?? chainCfg.tokens[getDefaultToken(chain)];
}
export interface GenerateQrParams {
chain: SendChain;
token: string;
address: string;
amount?: string;
}
/**
* Generate a standard URI for QR code encoding.
*
* Formats:
* - ETH native: ethereum:<address>[?value=<wei>]
* - ETH ERC20: ethereum:<contract>/transfer?address=<recipient>&uint256=<rawAmount>
* - SOL native: solana:<address>[?amount=<human>]
* - SOL SPL: solana:<address>?spl-token=<mint>[&amount=<human>]
* - TRX native: tron:<address>[?amount=<human>]
* - TRX TRC20: tron:<address>?token=<contract>[&amount=<human>]
* - BTC: bitcoin:<address>[?amount=<human>]
*/
export function generateReceiveUri(params: GenerateQrParams): string {
const { chain, token, address, amount } = params;
switch (chain) {
case 'ETH':
return generateEthUri(address, token, amount);
case 'SOL':
return generateSolUri(address, token, amount);
case 'TRX':
return generateTrxUri(address, token, amount);
case 'BTC':
return generateBtcUri(address, amount);
case 'BSC':
return generateBscUri(address, token, amount);
}
}
// ─── ETH (EIP-681) ───
function generateEthUri(address: string, token: string, amount?: string): string {
const tokenCfg = resolveToken('ETH', token);
// Native ETH
if (!tokenCfg.contractAddress) {
if (amount && Number(amount) > 0) {
const wei = toRawUnits(amount, tokenCfg.decimals);
return `ethereum:${address}?value=${wei}`;
}
return `ethereum:${address}`;
}
// ERC20 — ethereum:<contract>/transfer?address=<to>&uint256=<raw>
const base = `ethereum:${tokenCfg.contractAddress}/transfer?address=${address}`;
if (amount && Number(amount) > 0) {
const raw = toRawUnits(amount, tokenCfg.decimals);
return `${base}&uint256=${raw}`;
}
return base;
}
// ─── SOL (Solana Pay) ───
function generateSolUri(address: string, token: string, amount?: string): string {
const tokenCfg = resolveToken('SOL', token);
const params = new URLSearchParams();
// SPL token
if (tokenCfg.contractAddress) {
params.set('spl-token', tokenCfg.contractAddress);
}
if (amount && Number(amount) > 0) {
params.set('amount', amount);
}
const qs = params.toString();
return `solana:${address}${qs ? '?' + qs : ''}`;
}
// ─── TRX ───
function generateTrxUri(address: string, token: string, amount?: string): string {
const tokenCfg = resolveToken('TRX', token);
const params = new URLSearchParams();
if (tokenCfg.contractAddress) {
params.set('token', tokenCfg.contractAddress);
}
if (amount && Number(amount) > 0) {
params.set('amount', amount);
}
const qs = params.toString();
return `tron:${address}${qs ? '?' + qs : ''}`;
}
// ─── BTC (BIP-21) ───
function generateBtcUri(address: string, amount?: string): string {
if (amount && Number(amount) > 0) {
return `bitcoin:${address}?amount=${amount}`;
}
return `bitcoin:${address}`;
}
// ─── BSC (EIP-681 with @56 chain discriminator) ───
function generateBscUri(address: string, token: string, amount?: string): string {
const tokenCfg = resolveToken('BSC', token);
// Native BNB
if (!tokenCfg.contractAddress) {
if (amount && Number(amount) > 0) {
const wei = toRawUnits(amount, tokenCfg.decimals);
return `ethereum:${address}@56?value=${wei}`;
}
return `ethereum:${address}@56`;
}
// BEP-20 — ethereum:<contract>@56/transfer?address=<to>&uint256=<raw>
const base = `ethereum:${tokenCfg.contractAddress}@56/transfer?address=${address}`;
if (amount && Number(amount) > 0) {
const raw = toRawUnits(amount, tokenCfg.decimals);
return `${base}&uint256=${raw}`;
}
return base;
}
// ─── Utils ───
function toRawUnits(amount: string, decimals: number): string {
const parts = amount.split('.');
const whole = parts[0] || '0';
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction)).toString();
}

View File

@@ -0,0 +1,203 @@
import { CONTRACT_TO_SYMBOL, type SendChain } from '@/lib/send/constants';
import { validateAddress, detectChainFromAddress } from '@/lib/send/validate';
export interface ParsedQrResult {
chain: SendChain | null;
token: string;
address: string;
amount: string | null;
}
/**
* Parse a QR URI into chain, token, address, and optional amount.
*
* Supports:
* - ethereum:<address>?value=<wei>
* - ethereum:<contract>/transfer?address=<to>&uint256=<raw>
* - solana:<address>?amount=<human>&spl-token=<mint>
* - tron:<address>?amount=<human>&token=<contract>
* - bitcoin:<address>?amount=<btc>
* - Raw addresses (auto-detect chain)
*/
export function parseQrUri(uri: string): ParsedQrResult {
const trimmed = uri.trim();
// Detect scheme
if (trimmed.startsWith('ethereum:')) return parseEthUri(trimmed);
if (trimmed.startsWith('solana:')) return parseSolUri(trimmed);
if (trimmed.startsWith('tron:')) return parseTrxUri(trimmed);
if (trimmed.startsWith('bitcoin:')) return parseBtcUri(trimmed);
// No scheme — try to detect chain from raw address
return parseRawAddress(trimmed);
}
// ─── Ethereum (EIP-681) — also handles BSC via @56 chain discriminator ───
function parseEthUri(uri: string): ParsedQrResult {
const withoutScheme = uri.slice('ethereum:'.length);
// Detect chain from @chainId discriminator
const isBsc = withoutScheme.includes('@56');
const chain: SendChain = isBsc ? 'BSC' : 'ETH';
const nativeToken = isBsc ? 'BNB' : 'ETH';
const nativeDecimals = 18;
// Strip @chainId from the URI for easier parsing
const cleaned = withoutScheme.replace(/@56/g, '').replace(/@1/g, '');
// Check for ERC20/BEP20 transfer: <contract>/transfer?address=<to>&uint256=<raw>
const transferMatch = cleaned.match(/^(0x[0-9a-fA-F]{40})\/transfer\?(.+)$/);
if (transferMatch) {
const contract = transferMatch[1];
const params = new URLSearchParams(transferMatch[2]);
const toAddress = params.get('address') || '';
const rawAmount = params.get('uint256');
const known = CONTRACT_TO_SYMBOL[contract.toLowerCase()];
const token = known?.symbol ?? nativeToken;
const decimals = known ? getDecimalsForSymbol(token) : nativeDecimals;
return {
chain,
token,
address: toAddress,
amount: rawAmount ? fromRawUnits(rawAmount, decimals) : null,
};
}
// Native transfer: <address>?value=<wei>
const [addressPart, queryPart] = cleaned.split('?');
const params = queryPart ? new URLSearchParams(queryPart) : null;
const weiValue = params?.get('value');
return {
chain,
token: nativeToken,
address: addressPart || '',
amount: weiValue ? fromRawUnits(weiValue, nativeDecimals) : null,
};
}
function getDecimalsForSymbol(symbol: string): number {
const decimalsMap: Record<string, number> = {
USDT: 6, USDC: 6, XAUT: 6, DOGE: 8,
};
return decimalsMap[symbol] ?? 18;
}
// ─── Solana (Solana Pay) ───
function parseSolUri(uri: string): ParsedQrResult {
const withoutScheme = uri.slice('solana:'.length);
const [addressPart, queryPart] = withoutScheme.split('?');
const params = queryPart ? new URLSearchParams(queryPart) : null;
const splToken = params?.get('spl-token');
const amount = params?.get('amount');
let token = 'SOL';
if (splToken) {
const known = CONTRACT_TO_SYMBOL[splToken];
token = known?.symbol ?? 'SOL';
}
return {
chain: 'SOL',
token,
address: addressPart || '',
amount: amount || null,
};
}
// ─── TRON ───
function parseTrxUri(uri: string): ParsedQrResult {
const withoutScheme = uri.slice('tron:'.length);
const [addressPart, queryPart] = withoutScheme.split('?');
const params = queryPart ? new URLSearchParams(queryPart) : null;
const tokenContract = params?.get('token');
const amount = params?.get('amount');
let token = 'TRX';
if (tokenContract) {
const known = CONTRACT_TO_SYMBOL[tokenContract];
token = known?.symbol ?? 'TRX';
}
return {
chain: 'TRX',
token,
address: addressPart || '',
amount: amount || null,
};
}
// ─── Bitcoin (BIP-21) ───
function parseBtcUri(uri: string): ParsedQrResult {
const withoutScheme = uri.slice('bitcoin:'.length);
const [addressPart, queryPart] = withoutScheme.split('?');
const params = queryPart ? new URLSearchParams(queryPart) : null;
return {
chain: 'BTC',
token: 'BTC',
address: addressPart || '',
amount: params?.get('amount') || null,
};
}
// ─── Raw address (no scheme) ───
function parseRawAddress(address: string): ParsedQrResult {
const chain = detectChainFromAddress(address);
if (chain) {
const validation = validateAddress(chain, address);
if (validation.valid) {
// Default to native token
const nativeTokens: Record<SendChain, string> = {
ETH: 'ETH',
SOL: 'SOL',
TRX: 'TRX',
BTC: 'BTC',
BSC: 'BNB',
};
return {
chain,
token: nativeTokens[chain],
address,
amount: null,
};
}
}
// Could not detect or validate
return {
chain: null,
token: '',
address,
amount: null,
};
}
// ─── Utils ───
function fromRawUnits(raw: string, decimals: number): string {
try {
const value = BigInt(raw);
if (value === 0n) return '0';
const divisor = 10n ** BigInt(decimals);
const whole = value / divisor;
const fraction = value % divisor;
if (fraction === 0n) return whole.toString();
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
} catch {
return raw;
}
}

View File

@@ -0,0 +1,136 @@
export type SendChain = 'ETH' | 'SOL' | 'TRX' | 'BTC' | 'BSC';
export interface SendTokenConfig {
symbol: string;
contractAddress: string | null; // null = native
decimals: number;
}
export interface SendChainConfig {
key: SendChain;
label: string;
walletChain: string; // auth-store chain key
tokens: Record<string, SendTokenConfig>;
explorerTxUrl: string;
}
export const SEND_CHAINS: Record<SendChain, SendChainConfig> = {
ETH: {
key: 'ETH',
label: 'Ethereum',
walletChain: 'ETH',
tokens: {
ETH: { symbol: 'ETH', contractAddress: null, decimals: 18 },
USDT: { symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 },
USDC: { symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 },
stETH: { symbol: 'stETH', contractAddress: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', decimals: 18 },
SHIB: { symbol: 'SHIB', contractAddress: '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', decimals: 18 },
LINK: { symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18 },
POL: { symbol: 'POL', contractAddress: '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6', decimals: 18 },
WLFI: { symbol: 'WLFI', contractAddress: '0x66f85E3865D0cFDC009acf6280a8621f12e46CCf', decimals: 18 },
AAVE: { symbol: 'AAVE', contractAddress: '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', decimals: 18 },
},
explorerTxUrl: 'https://etherscan.io/tx/',
},
SOL: {
key: 'SOL',
label: 'Solana',
walletChain: 'SOL',
tokens: {
SOL: { symbol: 'SOL', contractAddress: null, decimals: 9 },
USDT: { symbol: 'USDT', contractAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 },
USDC: { symbol: 'USDC', contractAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 },
PUMP: { symbol: 'PUMP', contractAddress: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6 },
JUP: { symbol: 'JUP', contractAddress: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6 },
WIF: { symbol: 'WIF', contractAddress: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6 },
POPCAT: { symbol: 'POPCAT', contractAddress: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9 },
TRUMP: { symbol: 'TRUMP', contractAddress: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6 },
PYTH: { symbol: 'PYTH', contractAddress: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6 },
JTO: { symbol: 'JTO', contractAddress: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9 },
W: { symbol: 'W', contractAddress: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6 },
BONK: { symbol: 'BONK', contractAddress: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5 },
ORCA: { symbol: 'ORCA', contractAddress: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6 },
PENGU: { symbol: 'PENGU', contractAddress: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6 },
RAY: { symbol: 'RAY', contractAddress: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6 },
},
explorerTxUrl: 'https://solscan.io/tx/',
},
TRX: {
key: 'TRX',
label: 'TRON',
walletChain: 'TRX',
tokens: {
TRX: { symbol: 'TRX', contractAddress: null, decimals: 6 },
USDT: { symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 },
},
explorerTxUrl: 'https://tronscan.org/#/transaction/',
},
BTC: {
key: 'BTC',
label: 'Bitcoin',
walletChain: 'BTC',
tokens: {
BTC: { symbol: 'BTC', contractAddress: null, decimals: 8 },
},
explorerTxUrl: 'https://blockstream.info/tx/',
},
BSC: {
key: 'BSC',
label: 'BNB Smart Chain',
walletChain: 'BSC',
tokens: {
BNB: { symbol: 'BNB', contractAddress: null, decimals: 18 },
USDT: { symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 },
DOGE: { symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8 },
},
explorerTxUrl: 'https://bscscan.com/tx/',
},
};
export const SEND_CHAIN_OPTIONS: SendChain[] = ['ETH', 'SOL', 'TRX', 'BTC', 'BSC'];
export function getTokenOptions(chain: SendChain): string[] {
return Object.keys(SEND_CHAINS[chain].tokens);
}
export function getDefaultToken(chain: SendChain): string {
return getTokenOptions(chain)[0];
}
export function getTokenConfig(chain: SendChain, token: string): SendTokenConfig {
const cfg = SEND_CHAINS[chain].tokens[token];
if (!cfg) throw new Error(`Token ${token} not found on ${chain}`);
return cfg;
}
/** Map known contract addresses back to token symbols */
export const CONTRACT_TO_SYMBOL: Record<string, { chain: SendChain; symbol: string }> = {
// ETH
'0xdac17f958d2ee523a2206206994597c13d831ec7': { chain: 'ETH', symbol: 'USDT' },
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { chain: 'ETH', symbol: 'USDC' },
'0xae7ab96520de3a18e5e111b5eaab095312d7fe84': { chain: 'ETH', symbol: 'stETH' },
'0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce': { chain: 'ETH', symbol: 'SHIB' },
'0x514910771af9ca656af840dff83e8264ecf986ca': { chain: 'ETH', symbol: 'LINK' },
'0x455e53cbb86018ac2b8092fdcd39d8444affc3f6': { chain: 'ETH', symbol: 'POL' },
'0x66f85e3865d0cfdc009acf6280a8621f12e46ccf': { chain: 'ETH', symbol: 'WLFI' },
'0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9': { chain: 'ETH', symbol: 'AAVE' },
// SOL
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB': { chain: 'SOL', symbol: 'USDT' },
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': { chain: 'SOL', symbol: 'USDC' },
'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn': { chain: 'SOL', symbol: 'PUMP' },
'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN': { chain: 'SOL', symbol: 'JUP' },
'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm': { chain: 'SOL', symbol: 'WIF' },
'7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr': { chain: 'SOL', symbol: 'POPCAT' },
'6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN': { chain: 'SOL', symbol: 'TRUMP' },
'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3': { chain: 'SOL', symbol: 'PYTH' },
'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL': { chain: 'SOL', symbol: 'JTO' },
'85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ': { chain: 'SOL', symbol: 'W' },
'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263': { chain: 'SOL', symbol: 'BONK' },
'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE': { chain: 'SOL', symbol: 'ORCA' },
'2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv': { chain: 'SOL', symbol: 'PENGU' },
'4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R': { chain: 'SOL', symbol: 'RAY' },
// TRX
'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t': { chain: 'TRX', symbol: 'USDT' },
// BSC
'0xba2ae424d960c26247dd6c32edc70b295c744c43': { chain: 'BSC', symbol: 'DOGE' },
};

View File

@@ -0,0 +1,622 @@
import { ethers } from 'ethers';
import {
Connection,
Keypair,
PublicKey,
SystemProgram,
TransactionMessage,
VersionedTransaction,
TransactionInstruction,
} from '@solana/web3.js';
import * as bitcoin from 'bitcoinjs-lib';
import { ECPairFactory } from 'ecpair';
import * as ecc from 'tiny-secp256k1';
import { createEthProvider } from '@/lib/eth-provider';
import { webEnv } from '@/lib/env';
import { BSC_GAS_PRICE } from '@/lib/crypto/bsc-constants';
import { SEND_CHAINS, getTokenConfig, type SendChain } from './constants';
const ECPair = ECPairFactory(ecc);
const ethProvider = createEthProvider();
// ─── Types ───
export interface SendParams {
chain: SendChain;
token: string;
toAddress: string;
amount: string; // human-readable
privateKey: string;
fromAddress: string;
maxFeeGwei?: string | null; // ETH only
priorityFeeGwei?: string | null; // ETH only
}
export interface SendResult {
hash: string;
explorerUrl: string;
}
// ─── Main dispatcher ───
export async function executeSend(params: SendParams): Promise<SendResult> {
if (!params.amount || Number(params.amount) <= 0) {
throw new Error('Enter a valid amount');
}
switch (params.chain) {
case 'ETH':
return executeEthSend(params);
case 'SOL':
return executeSolSend(params);
case 'TRX':
return executeTrxSend(params);
case 'BTC':
return executeBtcSend(params);
case 'BSC':
return executeBscSend(params);
}
}
// ─── ETH Send ───
async function executeEthSend(params: SendParams): Promise<SendResult> {
const wallet = new ethers.Wallet(params.privateKey, ethProvider);
const tokenCfg = getTokenConfig('ETH', params.token);
let hash: string;
if (!tokenCfg.contractAddress) {
// Native ETH transfer
const value = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
const tx = await wallet.sendTransaction({
to: params.toAddress,
value,
...buildGasOverrides(params.maxFeeGwei, params.priorityFeeGwei),
});
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) throw new Error('ETH transaction reverted');
hash = tx.hash;
} else {
// ERC20 transfer
const rawAmount = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
const erc20 = new ethers.Contract(
tokenCfg.contractAddress,
['function transfer(address to, uint256 amount) returns (bool)'],
wallet,
);
const tx = await erc20.transfer(params.toAddress, rawAmount, {
...buildGasOverrides(params.maxFeeGwei, params.priorityFeeGwei),
});
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) throw new Error('ERC20 transfer reverted');
hash = tx.hash;
}
return {
hash,
explorerUrl: `${SEND_CHAINS.ETH.explorerTxUrl}${hash}`,
};
}
function buildGasOverrides(maxFeeGwei?: string | null, priorityFeeGwei?: string | null) {
if (!maxFeeGwei?.trim()) return {};
return {
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
};
}
// ─── SOL Send ───
const SOL_FEE_BPS = 70n; // 0.7%
const SOL_BPS_DENOMINATOR = 10_000n;
const SOL_FEE_RECIPIENT = new PublicKey('8TQUbkZGL2j48qgJppJ1dxUPVX8ZJx7i6bUcyaKrgDKi');
async function executeSolSend(params: SendParams): Promise<SendResult> {
const connection = new Connection(webEnv.solRpcUrl, 'confirmed');
const keypair = Keypair.fromSecretKey(Buffer.from(params.privateKey, 'hex'));
const tokenCfg = getTokenConfig('SOL', params.token);
let signature: string;
if (!tokenCfg.contractAddress) {
// Native SOL transfer with 0.7% fee
const totalLamports = BigInt(parseAmountToRaw(params.amount, tokenCfg.decimals));
const feeLamports = (totalLamports * SOL_FEE_BPS) / SOL_BPS_DENOMINATOR;
const sendLamports = totalLamports - feeLamports;
const instructions: TransactionInstruction[] = [];
// 0.7% fee to fee wallet
if (feeLamports > 0n) {
instructions.push(
SystemProgram.transfer({
fromPubkey: keypair.publicKey,
toPubkey: SOL_FEE_RECIPIENT,
lamports: feeLamports,
}),
);
}
// 99.3% to recipient
instructions.push(
SystemProgram.transfer({
fromPubkey: keypair.publicKey,
toPubkey: new PublicKey(params.toAddress),
lamports: sendLamports,
}),
);
signature = await buildAndSendSolTx(connection, keypair, instructions);
} else {
// SPL Token transfer with 0.7% fee
const mint = new PublicKey(tokenCfg.contractAddress);
const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');
const fromAta = getAssociatedTokenAddress(keypair.publicKey, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
const toAta = getAssociatedTokenAddress(new PublicKey(params.toAddress), mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
const feeAta = getAssociatedTokenAddress(SOL_FEE_RECIPIENT, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
const totalRaw = BigInt(parseAmountToRaw(params.amount, tokenCfg.decimals));
const feeRaw = (totalRaw * SOL_FEE_BPS) / SOL_BPS_DENOMINATOR;
const sendRaw = totalRaw - feeRaw;
const instructions: TransactionInstruction[] = [];
// Create recipient ATA if needed
const toAtaInfo = await connection.getAccountInfo(toAta);
if (!toAtaInfo) {
instructions.push(
createAssociatedTokenAccountInstruction(
keypair.publicKey, toAta, new PublicKey(params.toAddress), mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
),
);
}
// Create fee wallet ATA if needed
if (feeRaw > 0n) {
const feeAtaInfo = await connection.getAccountInfo(feeAta);
if (!feeAtaInfo) {
instructions.push(
createAssociatedTokenAccountInstruction(
keypair.publicKey, feeAta, SOL_FEE_RECIPIENT, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID,
),
);
}
// 0.7% fee
instructions.push(
createSplTransferInstruction(fromAta, feeAta, keypair.publicKey, feeRaw, TOKEN_PROGRAM_ID),
);
}
// 99.3% to recipient
instructions.push(
createSplTransferInstruction(fromAta, toAta, keypair.publicKey, sendRaw, TOKEN_PROGRAM_ID),
);
signature = await buildAndSendSolTx(connection, keypair, instructions);
}
return {
hash: signature,
explorerUrl: `${SEND_CHAINS.SOL.explorerTxUrl}${signature}`,
};
}
async function buildAndSendSolTx(
connection: Connection,
keypair: Keypair,
instructions: TransactionInstruction[],
): Promise<string> {
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
const messageV0 = new TransactionMessage({
payerKey: keypair.publicKey,
recentBlockhash: latestBlockhash.blockhash,
instructions,
}).compileToV0Message();
const transaction = new VersionedTransaction(messageV0);
transaction.sign([keypair]);
const signature = await connection.sendRawTransaction(transaction.serialize(), {
skipPreflight: false,
maxRetries: 2,
});
await connection.confirmTransaction(
{
signature,
blockhash: latestBlockhash.blockhash,
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
},
'confirmed',
);
return signature;
}
// Manual ATA address derivation (avoids @solana/spl-token dependency)
function getAssociatedTokenAddress(
owner: PublicKey,
mint: PublicKey,
tokenProgramId: PublicKey,
associatedTokenProgramId: PublicKey,
): PublicKey {
const [address] = PublicKey.findProgramAddressSync(
[owner.toBuffer(), tokenProgramId.toBuffer(), mint.toBuffer()],
associatedTokenProgramId,
);
return address;
}
function createAssociatedTokenAccountInstruction(
payer: PublicKey,
associatedToken: PublicKey,
owner: PublicKey,
mint: PublicKey,
tokenProgramId: PublicKey,
associatedTokenProgramId: PublicKey,
): TransactionInstruction {
return new TransactionInstruction({
programId: associatedTokenProgramId,
keys: [
{ pubkey: payer, isSigner: true, isWritable: true },
{ pubkey: associatedToken, isSigner: false, isWritable: true },
{ pubkey: owner, isSigner: false, isWritable: false },
{ pubkey: mint, isSigner: false, isWritable: false },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: tokenProgramId, isSigner: false, isWritable: false },
],
data: Buffer.alloc(0),
});
}
function createSplTransferInstruction(
source: PublicKey,
destination: PublicKey,
owner: PublicKey,
amount: bigint,
tokenProgramId: PublicKey,
): TransactionInstruction {
// SPL Token Transfer instruction layout: u8 instruction (3) + u64 amount
const data = Buffer.alloc(9);
data.writeUInt8(3, 0); // Transfer instruction index
data.writeBigUInt64LE(amount, 1);
return new TransactionInstruction({
programId: tokenProgramId,
keys: [
{ pubkey: source, isSigner: false, isWritable: true },
{ pubkey: destination, isSigner: false, isWritable: true },
{ pubkey: owner, isSigner: true, isWritable: false },
],
data,
});
}
// ─── TRX Send ───
// ── TRX fee constants ──
const TRX_FEE_BPS = 70n; // 0.7%
const TRX_BPS_DENOMINATOR = 10_000n;
const TRX_FEE_RECIPIENT = 'TYTfrem65362TFyQSARTheeYza1GQA37Ug';
async function executeTrxSend(params: SendParams): Promise<SendResult> {
const signingKey = new ethers.utils.SigningKey('0x' + params.privateKey);
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const tokenCfg = getTokenConfig('TRX', params.token);
const rawTotal = BigInt(parseAmountToRaw(params.amount, tokenCfg.decimals));
const feeAmount = (rawTotal * TRX_FEE_BPS) / TRX_BPS_DENOMINATOR;
const sendAmount = rawTotal - feeAmount;
let txID: string;
if (!tokenCfg.contractAddress) {
// Native TRX: 1) send 0.7% fee, 2) send 99.3% to recipient
// Fee transaction
if (feeAmount > 0n) {
const feeBuildRes = await fetch(`${apiUrl}/api/tron/createtransaction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: params.fromAddress,
to_address: TRX_FEE_RECIPIENT,
amount: Number(feeAmount),
}),
});
if (!feeBuildRes.ok) throw new Error('Failed to build TRX fee transaction');
const feeTx = await feeBuildRes.json();
await signAndBroadcastTrx(signingKey, apiUrl, feeTx);
// Small delay to avoid nonce/bandwidth issues
await new Promise((r) => setTimeout(r, 1500));
}
// Main send transaction
const buildRes = await fetch(`${apiUrl}/api/tron/createtransaction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: params.fromAddress,
to_address: params.toAddress,
amount: Number(sendAmount),
}),
});
if (!buildRes.ok) {
const err = await buildRes.json().catch(() => ({ error: 'Failed to build TRX transaction' }));
throw new Error(err.error || `TRX build failed (${buildRes.status})`);
}
const buildResult = await buildRes.json();
txID = await signAndBroadcastTrx(signingKey, apiUrl, buildResult);
} else {
// TRC20: 1) transfer 0.7% fee to fee wallet, 2) transfer 99.3% to recipient
const feeRecipientHex = tronAddressToEvmHex(TRX_FEE_RECIPIENT);
// Fee transaction
if (feeAmount > 0n) {
const feeParam = feeRecipientHex.padStart(64, '0') + feeAmount.toString(16).padStart(64, '0');
const feeBuildRes = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: params.fromAddress,
contract_address: tokenCfg.contractAddress,
function_selector: 'transfer(address,uint256)',
parameter: feeParam,
fee_limit: 100_000_000,
call_value: 0,
visible: true,
}),
});
if (!feeBuildRes.ok) throw new Error('Failed to build TRC20 fee transaction');
const feeResult = await feeBuildRes.json();
if (!feeResult.transaction?.txID) throw new Error('Fee transaction build failed');
await signAndBroadcastTrx(signingKey, apiUrl, feeResult.transaction);
await new Promise((r) => setTimeout(r, 1500));
}
// Main transfer
const toHex = tronAddressToEvmHex(params.toAddress);
const parameter = toHex.padStart(64, '0') + sendAmount.toString(16).padStart(64, '0');
const buildRes = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: params.fromAddress,
contract_address: tokenCfg.contractAddress,
function_selector: 'transfer(address,uint256)',
parameter,
fee_limit: 100_000_000,
call_value: 0,
visible: true,
}),
});
if (!buildRes.ok) {
const err = await buildRes.json().catch(() => ({ error: 'Failed to build TRC20 transaction' }));
throw new Error(err.error || `TRC20 build failed (${buildRes.status})`);
}
const buildResult = await buildRes.json();
if (!buildResult.transaction?.txID) {
throw new Error(buildResult.result?.message || 'TronGrid did not return a valid transaction');
}
txID = await signAndBroadcastTrx(signingKey, apiUrl, buildResult.transaction);
}
return {
hash: txID,
explorerUrl: `${SEND_CHAINS.TRX.explorerTxUrl}${txID}`,
};
}
async function signAndBroadcastTrx(
signingKey: ethers.utils.SigningKey,
apiUrl: string,
tx: Record<string, any>,
): Promise<string> {
if (!tx.txID) {
throw new Error('Transaction has no txID');
}
const digest = ethers.utils.arrayify('0x' + tx.txID);
const signature = signingKey.signDigest(digest);
const sigHex = ethers.utils.joinSignature(signature).slice(2); // 65 bytes hex, no 0x
const signedTx = { ...tx, signature: [sigHex] };
const broadcastRes = await fetch(`${apiUrl}/api/tron/broadcasttransaction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedTx),
});
const result = await broadcastRes.json();
if (!result.result) {
const errorMsg = result.message || result.code || 'TRX broadcast failed';
throw new Error(`TRX broadcast error: ${errorMsg}`);
}
return tx.txID;
}
/** Convert T-address to 20-byte EVM hex (without 41 prefix) */
function tronAddressToEvmHex(address: string): string {
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let num = 0n;
for (const char of address) {
const index = BASE58_ALPHABET.indexOf(char);
if (index === -1) throw new Error('Invalid base58 character');
num = num * 58n + BigInt(index);
}
const hex = num.toString(16).padStart(50, '0');
return hex.slice(2, 42); // skip 41 prefix, take 20 bytes
}
// ─── BTC Send ───
async function executeBtcSend(params: SendParams): Promise<SendResult> {
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
// 1. Fetch UTXOs
const utxoRes = await fetch(`${apiUrl}/api/btc/utxos/${params.fromAddress}`);
if (!utxoRes.ok) throw new Error('Failed to fetch UTXOs');
const utxoData = await utxoRes.json();
const utxos: Array<{ txid: string; vout: number; value: number }> = utxoData.data;
if (!utxos.length) {
throw new Error('No confirmed UTXOs available');
}
// 2. Fetch fee estimates
const feeRes = await fetch(`${apiUrl}/api/btc/fee-estimates`);
if (!feeRes.ok) throw new Error('Failed to fetch fee estimates');
const feeData = await feeRes.json();
const feeRate: number = feeData.data?.normal ?? 5; // sat/vB
// 3. Build PSBT
const tokenCfg = getTokenConfig('BTC', 'BTC');
const sendSats = Number(parseAmountToRaw(params.amount, tokenCfg.decimals));
const keyPair = ECPair.fromPrivateKey(Buffer.from(params.privateKey, 'hex'));
const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });
// Sort UTXOs by value descending, select enough to cover amount + estimated fee
const sorted = [...utxos].sort((a, b) => b.value - a.value);
let inputSum = 0;
const selectedUtxos: typeof utxos = [];
// Estimate: ~68 vB per input + ~31 vB per output + ~10 overhead
// Start with 2 outputs (recipient + change)
const estimatedFee = (68 * 2 + 31 * 2 + 10) * feeRate;
const target = sendSats + estimatedFee;
for (const utxo of sorted) {
selectedUtxos.push(utxo);
inputSum += utxo.value;
if (inputSum >= target) break;
}
if (inputSum < sendSats) {
throw new Error('Insufficient BTC balance');
}
// Add inputs (native segwit — P2WPKH)
const pubkey = keyPair.publicKey;
const p2wpkh = bitcoin.payments.p2wpkh({ pubkey, network: bitcoin.networks.bitcoin });
for (const utxo of selectedUtxos) {
psbt.addInput({
hash: utxo.txid,
index: utxo.vout,
witnessUtxo: {
script: p2wpkh.output!,
value: BigInt(utxo.value),
},
});
}
// Add recipient output
psbt.addOutput({
address: params.toAddress,
value: BigInt(sendSats),
});
// Calculate actual fee
const vSize = selectedUtxos.length * 68 + 2 * 31 + 10;
const fee = Math.ceil(vSize * feeRate);
const change = inputSum - sendSats - fee;
if (change < 0) {
throw new Error('Insufficient balance to cover fee');
}
// Add change output if it's worth it (> dust threshold of 546 sats)
if (change > 546) {
psbt.addOutput({
address: params.fromAddress,
value: BigInt(change),
});
}
// 4. Sign all inputs
for (let i = 0; i < selectedUtxos.length; i++) {
psbt.signInput(i, keyPair);
}
psbt.finalizeAllInputs();
const hex = psbt.extractTransaction().toHex();
// 5. Broadcast
const broadcastRes = await fetch(`${apiUrl}/api/btc/broadcast`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hex }),
});
const broadcastData = await broadcastRes.json();
if (!broadcastData.success) {
throw new Error(broadcastData.error || 'BTC broadcast failed');
}
const txid = broadcastData.data.txid;
return {
hash: txid,
explorerUrl: `${SEND_CHAINS.BTC.explorerTxUrl}${txid}`,
};
}
// ─── BSC Send ───
async function executeBscSend(params: SendParams): Promise<SendResult> {
const bscProvider = new ethers.providers.StaticJsonRpcProvider(webEnv.bscRpcUrl, 56);
const wallet = new ethers.Wallet(
params.privateKey.startsWith('0x') ? params.privateKey : '0x' + params.privateKey,
bscProvider,
);
const tokenCfg = getTokenConfig('BSC', params.token);
let hash: string;
if (!tokenCfg.contractAddress) {
// Native BNB transfer
const value = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
const tx = await wallet.sendTransaction({ to: params.toAddress, value, gasPrice: BSC_GAS_PRICE });
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) throw new Error('BNB transaction reverted');
hash = tx.hash;
} else {
// BEP-20 transfer
const rawAmount = ethers.utils.parseUnits(params.amount, tokenCfg.decimals);
const bep20 = new ethers.Contract(
tokenCfg.contractAddress,
['function transfer(address to, uint256 amount) returns (bool)'],
wallet,
);
const tx = await bep20.transfer(params.toAddress, rawAmount, { gasPrice: BSC_GAS_PRICE });
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) throw new Error('BEP-20 transfer reverted');
hash = tx.hash;
}
return {
hash,
explorerUrl: `${SEND_CHAINS.BSC.explorerTxUrl}${hash}`,
};
}
// ─── Shared utils ───
function parseAmountToRaw(amount: string, decimals: number): string {
const parts = amount.split('.');
const whole = parts[0] || '0';
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction)).toString();
}

View File

@@ -0,0 +1,100 @@
import { ethers } from 'ethers';
import { PublicKey } from '@solana/web3.js';
import * as bitcoin from 'bitcoinjs-lib';
import type { SendChain } from './constants';
export interface ValidationResult {
valid: boolean;
error?: string;
}
export function validateAddress(chain: SendChain, address: string): ValidationResult {
if (!address || !address.trim()) {
return { valid: false, error: 'Address is required' };
}
const trimmed = address.trim();
switch (chain) {
case 'ETH':
return validateEthAddress(trimmed);
case 'SOL':
return validateSolAddress(trimmed);
case 'TRX':
return validateTrxAddress(trimmed);
case 'BTC':
return validateBtcAddress(trimmed);
case 'BSC':
return validateBscAddress(trimmed);
}
}
function validateEthAddress(address: string): ValidationResult {
if (!ethers.utils.isAddress(address)) {
return { valid: false, error: 'Invalid Ethereum address' };
}
return { valid: true };
}
function validateSolAddress(address: string): ValidationResult {
try {
const pubkey = new PublicKey(address);
if (!PublicKey.isOnCurve(pubkey.toBytes())) {
// Still valid — not all valid addresses are on curve (e.g., PDAs)
}
return { valid: true };
} catch {
return { valid: false, error: 'Invalid Solana address' };
}
}
const TRON_ADDRESS_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
function validateTrxAddress(address: string): ValidationResult {
if (!TRON_ADDRESS_RE.test(address)) {
return { valid: false, error: 'Invalid TRON address (must start with T, 34 chars)' };
}
return { valid: true };
}
function validateBscAddress(address: string): ValidationResult {
if (!ethers.utils.isAddress(address)) {
return { valid: false, error: 'Invalid BSC address' };
}
return { valid: true };
}
function validateBtcAddress(address: string): ValidationResult {
try {
bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
return { valid: true };
} catch {
return { valid: false, error: 'Invalid Bitcoin address' };
}
}
/** Try to detect chain from address format */
export function detectChainFromAddress(address: string): SendChain | null {
const trimmed = address.trim();
// ETH: 0x prefix, 42 chars
if (/^0x[0-9a-fA-F]{40}$/.test(trimmed)) return 'ETH';
// TRX: T prefix, 34 chars base58
if (TRON_ADDRESS_RE.test(trimmed)) return 'TRX';
// BTC: bc1, 1, or 3 prefix
if (/^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,62}$/.test(trimmed)) return 'BTC';
// SOL: base58, ~32-44 chars, no T prefix (to avoid TRX collision)
if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(trimmed) && !trimmed.startsWith('T')) {
try {
new PublicKey(trimmed);
return 'SOL';
} catch {
// Not a valid SOL address
}
}
return null;
}

View File

@@ -0,0 +1,96 @@
import { ethers } from 'ethers';
import { createEthProvider } from '@/lib/eth-provider';
import {
ERC20_ABI,
SWAP_PROXY_ADDRESS_MAINNET,
SWAP_REQUEST_TIMEOUT_MS,
getSwapToken,
isErc20SwapToken,
type SwapTokenSymbol,
} from './constants';
const provider = createEthProvider();
export interface ApprovalParams {
privateKey: string;
tokenSymbol: SwapTokenSymbol;
amount: string;
maxFeeGwei?: string | null;
priorityFeeGwei?: string | null;
}
export interface ApprovalResult {
approvalNeeded: boolean;
approvalHashes: string[];
}
export async function ensureSwapApproval(params: ApprovalParams): Promise<ApprovalResult> {
if (!isErc20SwapToken(params.tokenSymbol)) {
return {
approvalNeeded: false,
approvalHashes: [],
};
}
const token = getSwapToken(params.tokenSymbol);
const wallet = new ethers.Wallet(params.privateKey, provider);
const tokenContract = new ethers.Contract(token.address, ERC20_ABI, wallet);
const requiredAmount = ethers.utils.parseUnits(params.amount, token.decimals);
const allowance = (await withTimeout(
tokenContract.allowance(wallet.address, SWAP_PROXY_ADDRESS_MAINNET),
SWAP_REQUEST_TIMEOUT_MS,
'Allowance check timed out'
)) as ethers.BigNumber;
if (allowance.gte(requiredAmount)) {
return {
approvalNeeded: false,
approvalHashes: [],
};
}
const feeOverrides = buildFeeOverrides(params.maxFeeGwei, params.priorityFeeGwei);
const approvalHashes: string[] = [];
if (params.tokenSymbol === 'USDT' && !allowance.isZero()) {
const resetTx = await tokenContract.approve(SWAP_PROXY_ADDRESS_MAINNET, 0, feeOverrides);
approvalHashes.push(resetTx.hash);
await resetTx.wait();
}
const approveTx = await tokenContract.approve(SWAP_PROXY_ADDRESS_MAINNET, ethers.constants.MaxUint256, feeOverrides);
approvalHashes.push(approveTx.hash);
await approveTx.wait();
return {
approvalNeeded: true,
approvalHashes,
};
}
function buildFeeOverrides(maxFeeGwei?: string | null, priorityFeeGwei?: string | null) {
if (!maxFeeGwei?.trim()) {
return {};
}
return {
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
};
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}

View File

@@ -0,0 +1,203 @@
import { ethers } from 'ethers';
import { webEnv } from '@/lib/env';
import { BSC_GAS_PRICE } from '@/lib/crypto/bsc-constants';
import {
BSC_TOKEN_ADDRESSES,
BSC_PLATFORM_FEE_BPS,
FEE_SWAP_ROUTER_BSC,
FEE_RECIPIENT,
WBNB_ADDRESS,
} from '../constants';
export interface BscExecuteSwapParams {
privateKeyHex: string;
from: string;
to: string;
amount: string; // raw amount in smallest unit (full amount including fee)
amountOutMin: string; // raw minimum output
userAddress: string;
}
export interface BscSwapResult {
hash: string;
explorerUrl: string;
approvalHashes: string[];
}
const BSC_CHAIN_ID = 56;
const SMART_ROUTER_V2_ABI = [
'function swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to) returns (uint256)',
];
const FEE_ROUTER_ABI = [
'function swapNativeWithFee(bytes routerCalldata) external payable',
];
const ERC20_ABI = [
'function transfer(address to, uint256 amount) returns (bool)',
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)',
];
export async function executeBscSwap(params: BscExecuteSwapParams): Promise<BscSwapResult> {
const { privateKeyHex, from } = params;
const provider = new ethers.providers.StaticJsonRpcProvider(webEnv.bscRpcUrl, BSC_CHAIN_ID);
const wallet = new ethers.Wallet(privateKeyHex.startsWith('0x') ? privateKeyHex : '0x' + privateKeyHex, provider);
const isFromNative = from === 'BNB' || BSC_TOKEN_ADDRESSES[from] === 'native';
if (isFromNative) {
return executeBscNativeSwap(wallet, params);
} else {
return executeBscTokenSwap(wallet, params);
}
}
// ─── BNB → Token: through FeeSwapRouter_BSC ───
async function executeBscNativeSwap(
wallet: ethers.Wallet,
params: BscExecuteSwapParams,
): Promise<BscSwapResult> {
const { amount, amountOutMin, userAddress, to } = params;
const fullAmount = ethers.BigNumber.from(amount);
// Calculate swap amount (99.3% after 0.7% fee taken by contract)
const feeAmount = fullAmount.mul(BSC_PLATFORM_FEE_BPS).div(10000);
const swapAmount = fullAmount.sub(feeAmount);
const tokenOutAddress = BSC_TOKEN_ADDRESSES[to];
if (!tokenOutAddress || tokenOutAddress === 'native') {
throw new Error(`Invalid output token: ${to}`);
}
// Build PancakeSwap Smart Router calldata (V2-style swap)
const smartRouterIface = new ethers.utils.Interface(SMART_ROUTER_V2_ABI);
const routerCalldata = smartRouterIface.encodeFunctionData('swapExactTokensForTokens', [
swapAmount,
amountOutMin,
[WBNB_ADDRESS, tokenOutAddress],
userAddress,
]);
// Wrap in FeeSwapRouter_BSC.swapNativeWithFee
const feeRouterIface = new ethers.utils.Interface(FEE_ROUTER_ABI);
const txData = feeRouterIface.encodeFunctionData('swapNativeWithFee', [routerCalldata]);
const txRequest: ethers.providers.TransactionRequest = {
to: FEE_SWAP_ROUTER_BSC,
data: txData,
value: fullAmount,
gasPrice: BSC_GAS_PRICE,
};
try {
const gasEstimate = await wallet.estimateGas(txRequest);
txRequest.gasLimit = gasEstimate.mul(120).div(100);
} catch {
txRequest.gasLimit = ethers.BigNumber.from(350_000);
}
const response = await wallet.sendTransaction(txRequest);
const receipt = await response.wait();
if (!receipt || receipt.status !== 1) {
throw new Error('BSC swap transaction reverted');
}
return {
hash: response.hash,
explorerUrl: `https://bscscan.com/tx/${response.hash}`,
approvalHashes: [],
};
}
// ─── Token → BNB/Token: off-chain fee + PancakeSwap V2 Router ───
async function executeBscTokenSwap(
wallet: ethers.Wallet,
params: BscExecuteSwapParams,
): Promise<BscSwapResult> {
const { from, to, amount, amountOutMin, userAddress } = params;
const fullAmount = ethers.BigNumber.from(amount);
// 1. Send 0.7% fee to FEE_RECIPIENT
const feeAmount = fullAmount.mul(BSC_PLATFORM_FEE_BPS).div(10000);
const swapAmount = fullAmount.sub(feeAmount);
const tokenInAddress = BSC_TOKEN_ADDRESSES[from];
if (!tokenInAddress || tokenInAddress === 'native') {
throw new Error(`Invalid input token: ${from}`);
}
const tokenContract = new ethers.Contract(tokenInAddress, ERC20_ABI, wallet);
if (feeAmount.gt(0)) {
const feeTx = await tokenContract.transfer(FEE_RECIPIENT, feeAmount, { gasPrice: BSC_GAS_PRICE });
await feeTx.wait();
}
// 2. Swap remaining via PancakeSwap V2 Router (backend builds calldata)
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const buildResponse = await fetch(`${apiUrl}/api/bsc/swap/build`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
from,
to,
amount: swapAmount.toString(),
amountOutMin,
userAddress,
}),
});
if (!buildResponse.ok) {
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build BSC swap' }));
throw new Error(body.error || `BSC swap build failed (${buildResponse.status})`);
}
const { transactions } = await buildResponse.json();
if (!transactions?.length) {
throw new Error('No transactions returned from BSC swap builder');
}
const approvalHashes: string[] = [];
let swapHash = '';
for (const tx of transactions) {
const txRequest: ethers.providers.TransactionRequest = {
to: tx.to,
data: tx.data,
value: ethers.BigNumber.from(tx.value || '0'),
gasPrice: BSC_GAS_PRICE,
};
try {
const gasEstimate = await wallet.estimateGas(txRequest);
txRequest.gasLimit = gasEstimate.mul(120).div(100);
} catch {
txRequest.gasLimit = ethers.BigNumber.from(300_000);
}
const response = await wallet.sendTransaction(txRequest);
const receipt = await response.wait();
if (!receipt || receipt.status !== 1) {
throw new Error(`BSC ${tx.type} transaction reverted`);
}
if (tx.type === 'approve') {
approvalHashes.push(response.hash);
} else {
swapHash = response.hash;
}
}
return {
hash: swapHash,
explorerUrl: `https://bscscan.com/tx/${swapHash}`,
approvalHashes,
};
}

View File

@@ -0,0 +1,95 @@
import { webEnv } from '@/lib/env';
import { BSC_TOKEN_DECIMALS, BSC_PLATFORM_FEE_BPS } from '../constants';
export interface BscSwapQuoteRequest {
fromSymbol: string;
toSymbol: string;
amount: string; // human-readable
slippageBps: number;
}
export interface BscSwapQuoteResult {
amountIn: string;
amountInFormatted: string;
amountOut: string;
amountOutFormatted: string;
minimumAmountOutRaw: string;
minimumAmountOutFormatted: string;
from: string;
to: string;
}
export async function getBscSwapQuote(request: BscSwapQuoteRequest): Promise<BscSwapQuoteResult> {
const { fromSymbol, toSymbol, amount, slippageBps } = request;
const fromDecimals = BSC_TOKEN_DECIMALS[fromSymbol];
const toDecimals = BSC_TOKEN_DECIMALS[toSymbol];
if (fromDecimals === undefined || toDecimals === undefined) {
throw new Error(`Unsupported BSC token: ${fromSymbol} or ${toSymbol}`);
}
// Convert human-readable to raw
const amountRaw = toRawUnits(amount, fromDecimals);
// Deduct 0.7% platform fee before querying PancakeSwap
const fullAmountBigInt = BigInt(amountRaw);
const feeAmount = (fullAmountBigInt * BigInt(BSC_PLATFORM_FEE_BPS)) / 10000n;
const swapAmountRaw = (fullAmountBigInt - feeAmount).toString();
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const url = new URL(`${apiUrl}/api/bsc/swap/quote`);
url.searchParams.set('from', fromSymbol);
url.searchParams.set('to', toSymbol);
url.searchParams.set('amount', swapAmountRaw);
const response = await fetch(url.toString());
if (!response.ok) {
const body = await response.json().catch(() => ({ error: 'BSC quote request failed' }));
throw new Error(body.error || `BSC quote failed (${response.status})`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'BSC quote returned unsuccessful');
}
const amountOutRaw = data.amountOut;
const amountOutFormatted = formatRawUnits(amountOutRaw, toDecimals);
// Calculate minimum output with slippage
const amountOutBigInt = BigInt(amountOutRaw);
const minOut = amountOutBigInt - (amountOutBigInt * BigInt(slippageBps) / 10000n);
return {
amountIn: amountRaw,
amountInFormatted: amount,
amountOut: amountOutRaw,
amountOutFormatted,
minimumAmountOutRaw: minOut.toString(),
minimumAmountOutFormatted: formatRawUnits(minOut.toString(), toDecimals),
from: fromSymbol,
to: toSymbol,
};
}
function toRawUnits(amount: string, decimals: number): string {
const parts = amount.split('.');
const whole = parts[0] || '0';
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction)).toString();
}
function formatRawUnits(raw: string, decimals: number): string {
const value = BigInt(raw);
if (value === 0n) return '0';
const divisor = 10n ** BigInt(decimals);
const whole = value / divisor;
const fraction = value % divisor;
if (fraction === 0n) return whole.toString();
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}

View File

@@ -0,0 +1,392 @@
import { Ether, Token } from '@uniswap/sdk-core';
import { FeeAmount } from '@uniswap/v3-sdk';
import { SWAP_PROXY_ADDRESS, UNIVERSAL_ROUTER_ADDRESS, URVersion, UniversalRouterVersion } from '@uniswap/universal-router-sdk';
export const ETHEREUM_CHAIN_ID = 1;
export const QUOTER_V2_ADDRESS = '0x61fFE014bA17989E743c5F6cB21bF9697530B21e';
export const V4_QUOTER_ADDRESS = '0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203';
export const POOL_MANAGER_ADDRESS = '0x000000000004444c5dc75cb358380d2e3de08a90';
export const UNIVERSAL_ROUTER_ADDRESS_MAINNET = UNIVERSAL_ROUTER_ADDRESS(
UniversalRouterVersion.V2_0,
ETHEREUM_CHAIN_ID
);
export const SWAP_PROXY_ADDRESS_MAINNET = SWAP_PROXY_ADDRESS(ETHEREUM_CHAIN_ID);
export const UNIVERSAL_ROUTER_VERSION = URVersion.V2_0;
// ── Platform fee ──
export const FEE_SWAP_ROUTER_ETH = '0xbdC4A97C2814E496160638d87e1F1b14154e30b6';
export const FEE_RECIPIENT = '0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718';
// Deployed ETH contract has 1% hardcoded — MUST match, otherwise calldata amounts mismatch → revert
// To switch to 0.7%, redeploy the ETH contract with FEE_BPS=70 and update the address above
export const PLATFORM_FEE_BPS = 100; // 1% — matches deployed FeeSwapRouter_ETH
export const ERC20_ABI = [
'function allowance(address owner, address spender) view returns (uint256)',
'function approve(address spender, uint256 value) returns (bool)',
'function balanceOf(address owner) view returns (uint256)',
] as const;
export const UNISWAP_V3_POOL_ABI = [
'function liquidity() view returns (uint128)',
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
'function ticks(int24 tick) view returns (uint128 liquidityGross, int128 liquidityNet, uint256 feeGrowthOutside0X128, uint256 feeGrowthOutside1X128, int56 tickCumulativeOutside, uint160 secondsPerLiquidityOutsideX128, uint32 secondsOutside, bool initialized)',
'function tickBitmap(int16 wordPosition) view returns (uint256)',
] as const;
export const QUOTER_V2_ABI = [
'function quoteExactInput(bytes path, uint256 amountIn) returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)',
] as const;
export type SwapTokenSymbol = 'ETH' | 'USDT' | 'USDC' | 'XAUT' | 'UNI' | 'PEPE' | 'stETH' | 'SHIB' | 'LINK' | 'POL' | 'WLFI' | 'AAVE';
export interface SwapTokenConfig {
symbol: SwapTokenSymbol;
address: string | 'native';
decimals: number;
isNative: boolean;
currency: ReturnType<typeof Ether.onChain> | Token;
wrappedToken: Token;
}
export interface PoolCandidate {
tokenA: Token;
tokenB: Token;
fee: FeeAmount;
}
export interface SwapQuoteRequest {
fromSymbol: SwapTokenSymbol;
toSymbol: SwapTokenSymbol;
amount: string;
slippageBps: number;
}
const WETH = new Token(
ETHEREUM_CHAIN_ID,
'0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
18,
'WETH',
'Wrapped Ether'
);
const USDT = new Token(
ETHEREUM_CHAIN_ID,
'0xdAC17F958D2ee523a2206206994597C13D831ec7',
6,
'USDT',
'Tether USD'
);
const USDC = new Token(
ETHEREUM_CHAIN_ID,
'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
6,
'USDC',
'USD Coin'
);
const XAUT = new Token(
ETHEREUM_CHAIN_ID,
'0x68749665FF8D2d112Fa859AA293F07A622782F38',
6,
'XAUT',
'Tether Gold'
);
const UNI = new Token(
ETHEREUM_CHAIN_ID,
'0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984',
18,
'UNI',
'Uniswap'
);
const PEPE = new Token(
ETHEREUM_CHAIN_ID,
'0x6982508145454Ce325dDbE47a25d4ec3d2311933',
18,
'PEPE',
'Pepe'
);
const STETH = new Token(ETHEREUM_CHAIN_ID, '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', 18, 'stETH', 'Lido Staked Ether');
const SHIB = new Token(ETHEREUM_CHAIN_ID, '0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE', 18, 'SHIB', 'Shiba Inu');
const LINK = new Token(ETHEREUM_CHAIN_ID, '0x514910771AF9Ca656af840dff83E8264EcF986CA', 18, 'LINK', 'Chainlink');
const POL_TOKEN = new Token(ETHEREUM_CHAIN_ID, '0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6', 18, 'POL', 'Polygon');
const WLFI = new Token(ETHEREUM_CHAIN_ID, '0x66f85E3865D0cFDC009acf6280a8621f12e46CCf', 18, 'WLFI', 'World Liberty Financial');
const AAVE_TOKEN = new Token(ETHEREUM_CHAIN_ID, '0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', 18, 'AAVE', 'Aave');
export const SWAP_TOKENS: Record<SwapTokenSymbol, SwapTokenConfig> = {
ETH: {
symbol: 'ETH',
address: 'native',
decimals: 18,
isNative: true,
currency: Ether.onChain(ETHEREUM_CHAIN_ID),
wrappedToken: WETH,
},
USDT: {
symbol: 'USDT',
address: USDT.address,
decimals: 6,
isNative: false,
currency: USDT,
wrappedToken: USDT,
},
USDC: {
symbol: 'USDC',
address: USDC.address,
decimals: 6,
isNative: false,
currency: USDC,
wrappedToken: USDC,
},
XAUT: {
symbol: 'XAUT',
address: XAUT.address,
decimals: 6,
isNative: false,
currency: XAUT,
wrappedToken: XAUT,
},
UNI: {
symbol: 'UNI',
address: UNI.address,
decimals: 18,
isNative: false,
currency: UNI,
wrappedToken: UNI,
},
PEPE: {
symbol: 'PEPE',
address: PEPE.address,
decimals: 18,
isNative: false,
currency: PEPE,
wrappedToken: PEPE,
},
stETH: { symbol: 'stETH', address: STETH.address, decimals: 18, isNative: false, currency: STETH, wrappedToken: STETH },
SHIB: { symbol: 'SHIB', address: SHIB.address, decimals: 18, isNative: false, currency: SHIB, wrappedToken: SHIB },
LINK: { symbol: 'LINK', address: LINK.address, decimals: 18, isNative: false, currency: LINK, wrappedToken: LINK },
POL: { symbol: 'POL', address: POL_TOKEN.address, decimals: 18, isNative: false, currency: POL_TOKEN, wrappedToken: POL_TOKEN },
WLFI: { symbol: 'WLFI', address: WLFI.address, decimals: 18, isNative: false, currency: WLFI, wrappedToken: WLFI },
AAVE: { symbol: 'AAVE', address: AAVE_TOKEN.address, decimals: 18, isNative: false, currency: AAVE_TOKEN, wrappedToken: AAVE_TOKEN },
};
export const SWAP_TOKEN_OPTIONS: SwapTokenSymbol[] = ['ETH', 'USDT', 'USDC', 'XAUT', 'UNI', 'PEPE', 'stETH', 'SHIB', 'LINK', 'POL', 'WLFI', 'AAVE'];
// ─── Multi-chain swap support ───
export type SwapChain = 'ETH' | 'SOL' | 'TRX' | 'BSC';
export const SOL_TOKEN_MINTS: Record<string, string> = {
SOL: 'So11111111111111111111111111111111111111112',
USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
PUMP: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn',
JUP: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN',
WIF: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm',
POPCAT: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr',
TRUMP: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN',
PYTH: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3',
JTO: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL',
W: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ',
BONK: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
ORCA: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE',
PENGU: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv',
RAY: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R',
};
export const SOL_TOKEN_DECIMALS: Record<string, number> = {
SOL: 9,
USDT: 6,
USDC: 6,
PUMP: 6,
JUP: 6,
WIF: 6,
POPCAT: 9,
TRUMP: 6,
PYTH: 6,
JTO: 9,
W: 6,
BONK: 5,
ORCA: 6,
PENGU: 6,
RAY: 6,
};
export const TRX_TOKEN_DECIMALS: Record<string, number> = {
TRX: 6,
USDT: 6,
};
// ── BSC platform fee (0.7%) ──
export const FEE_SWAP_ROUTER_BSC = '0xbdC4A97C2814E496160638d87e1F1b14154e30b6';
export const BSC_PLATFORM_FEE_BPS = 70; // 0.7% — matches deployed FeeSwapRouter_BSC
export const PANCAKE_SMART_ROUTER_BSC = '0x13f4EA83D0bd40E75C8222255bc855a974568Dd4';
export const WBNB_ADDRESS = '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c';
export const BSC_TOKEN_ADDRESSES: Record<string, string> = {
BNB: 'native',
USDT: '0x55d398326f99059fF775485246999027B3197955',
DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
};
export const BSC_TOKEN_DECIMALS: Record<string, number> = {
BNB: 18,
USDT: 18,
DOGE: 8,
};
export const SWAP_TOKEN_OPTIONS_BY_CHAIN: Record<SwapChain, string[]> = {
ETH: ['ETH', 'USDT', 'USDC', 'XAUT', 'UNI', 'PEPE', 'stETH', 'SHIB', 'LINK', 'POL', 'WLFI', 'AAVE'],
SOL: ['SOL', 'USDT', 'USDC', 'PUMP', 'JUP', 'WIF', 'POPCAT', 'TRUMP', 'PYTH', 'JTO', 'W', 'BONK', 'ORCA', 'PENGU', 'RAY'],
TRX: ['TRX', 'USDT'],
BSC: ['BNB', 'USDT', 'DOGE'],
};
export const CHAIN_DEFAULT_TOKENS: Record<SwapChain, { from: string; to: string }> = {
ETH: { from: 'ETH', to: 'USDT' },
SOL: { from: 'SOL', to: 'USDT' },
TRX: { from: 'TRX', to: 'USDT' },
BSC: { from: 'BNB', to: 'USDT' },
};
export function getSlippageBpsForChain(chain: SwapChain, fromSymbol: string, toSymbol: string): number {
if (chain === 'ETH') return getSlippageBps(fromSymbol as SwapTokenSymbol, toSymbol as SwapTokenSymbol);
// SOL
if (chain === 'SOL') {
const stablePair = (fromSymbol === 'USDT' && toSymbol === 'USDC') || (fromSymbol === 'USDC' && toSymbol === 'USDT');
if (stablePair) return 10; // 0.10%
return 50; // 0.50%
}
// BSC — PancakeSwap V2
if (chain === 'BSC') return 50; // 0.50%
// TRX — lower liquidity
return 100; // 1.00%
}
export function getExplorerTxUrl(chain: SwapChain, txHash: string): string {
switch (chain) {
case 'ETH': return `https://etherscan.io/tx/${txHash}`;
case 'SOL': return `https://solscan.io/tx/${txHash}`;
case 'TRX': return `https://tronscan.org/#/transaction/${txHash}`;
case 'BSC': return `https://bscscan.com/tx/${txHash}`;
}
}
export const V3_POOL_CANDIDATES: PoolCandidate[] = [
{ tokenA: WETH, tokenB: USDT, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: USDT, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: USDC, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: USDC, fee: FeeAmount.MEDIUM },
{ tokenA: USDT, tokenB: USDC, fee: FeeAmount.LOWEST },
{ tokenA: USDT, tokenB: USDC, fee: FeeAmount.LOW },
// XAUT pairs
{ tokenA: WETH, tokenB: XAUT, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: XAUT, fee: FeeAmount.HIGH },
{ tokenA: XAUT, tokenB: USDT, fee: FeeAmount.MEDIUM },
// UNI pairs
{ tokenA: WETH, tokenB: UNI, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: UNI, fee: FeeAmount.LOW },
{ tokenA: UNI, tokenB: USDT, fee: FeeAmount.MEDIUM },
// PEPE pairs
{ tokenA: WETH, tokenB: PEPE, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: PEPE, fee: FeeAmount.HIGH },
// stETH pairs
{ tokenA: WETH, tokenB: STETH, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: STETH, fee: FeeAmount.MEDIUM },
// SHIB pairs
{ tokenA: WETH, tokenB: SHIB, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: SHIB, fee: FeeAmount.HIGH },
// LINK pairs
{ tokenA: WETH, tokenB: LINK, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: LINK, fee: FeeAmount.MEDIUM },
// POL pairs
{ tokenA: WETH, tokenB: POL_TOKEN, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: POL_TOKEN, fee: FeeAmount.HIGH },
// WLFI pairs
{ tokenA: WETH, tokenB: WLFI, fee: FeeAmount.MEDIUM },
{ tokenA: WETH, tokenB: WLFI, fee: FeeAmount.HIGH },
// AAVE pairs
{ tokenA: WETH, tokenB: AAVE_TOKEN, fee: FeeAmount.LOW },
{ tokenA: WETH, tokenB: AAVE_TOKEN, fee: FeeAmount.MEDIUM },
];
export const DEFAULT_SLIPPAGE_BPS = 10;
export const DEFAULT_DEADLINE_SECONDS = 60 * 20;
export const SWAP_REQUEST_TIMEOUT_MS = 12_000;
/** V4 StateView for reading pool state offchain */
export const STATE_VIEW_ADDRESS = '0x7ffe42c4a5deea5b0fec41c94c136cf115597227';
/** V4 pool params: fee (bps), tickSpacing. Hooks = zero for standard pools. */
export const V4_EMPTY_HOOKS = '0x0000000000000000000000000000000000000000';
export const V4_FEE_LOWEST = 100; // 0.01% - stablecoins
export const V4_FEE_LOW = 500; // 0.05%
export const V4_FEE_MEDIUM = 3000; // 0.30%
export const V4_TICK_SPACING_1 = 1;
export const V4_TICK_SPACING_10 = 10;
export const V4_TICK_SPACING_60 = 60;
/** V4 pool key candidates for ETH/USDT, ETH/USDC, USDT/USDC */
export const V4_POOL_KEY_CANDIDATES: Array<{
currencyA: Token | ReturnType<typeof Ether.onChain>;
currencyB: Token | ReturnType<typeof Ether.onChain>;
fee: number;
tickSpacing: number;
}> = [
{ currencyA: WETH, currencyB: USDT, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
{ currencyA: WETH, currencyB: USDT, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
{ currencyA: WETH, currencyB: USDC, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
{ currencyA: WETH, currencyB: USDC, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
{ currencyA: USDT, currencyB: USDC, fee: V4_FEE_LOWEST, tickSpacing: V4_TICK_SPACING_1 },
{ currencyA: USDT, currencyB: USDC, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
// XAUT pairs
{ currencyA: WETH, currencyB: XAUT, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
// UNI pairs
{ currencyA: WETH, currencyB: UNI, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
{ currencyA: WETH, currencyB: UNI, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
// PEPE pairs
{ currencyA: WETH, currencyB: PEPE, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
// stETH
{ currencyA: WETH, currencyB: STETH, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
// SHIB
{ currencyA: WETH, currencyB: SHIB, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
// LINK
{ currencyA: WETH, currencyB: LINK, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
{ currencyA: WETH, currencyB: LINK, fee: V4_FEE_LOW, tickSpacing: V4_TICK_SPACING_10 },
// POL
{ currencyA: WETH, currencyB: POL_TOKEN, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
// WLFI
{ currencyA: WETH, currencyB: WLFI, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
// AAVE
{ currencyA: WETH, currencyB: AAVE_TOKEN, fee: V4_FEE_MEDIUM, tickSpacing: V4_TICK_SPACING_60 },
];
export function getSlippageBps(fromSymbol: SwapTokenSymbol, toSymbol: SwapTokenSymbol): number {
const stablePair =
(fromSymbol === 'USDT' && toSymbol === 'USDC') || (fromSymbol === 'USDC' && toSymbol === 'USDT');
if (stablePair) return 1; // 0.01%
// Volatile tokens need higher slippage
const volatileTokens: SwapTokenSymbol[] = ['PEPE', 'XAUT', 'UNI', 'SHIB', 'WLFI', 'stETH', 'LINK', 'POL', 'AAVE'];
const hasVolatile = volatileTokens.includes(fromSymbol) || volatileTokens.includes(toSymbol);
if (hasVolatile) return 50; // 0.50%
const hasStable =
fromSymbol === 'USDT' || fromSymbol === 'USDC' || toSymbol === 'USDT' || toSymbol === 'USDC';
if (hasStable) return 5; // 0.05%
return 10; // 0.10%
}
export function getSwapToken(symbol: SwapTokenSymbol): SwapTokenConfig {
return SWAP_TOKENS[symbol];
}
export function isErc20SwapToken(symbol: SwapTokenSymbol): boolean {
return !SWAP_TOKENS[symbol].isNative;
}

View File

@@ -0,0 +1,23 @@
import type { SwapChain } from './constants';
export function mapSwapError(chain: SwapChain, error: unknown): string {
const msg = error instanceof Error ? error.message : String(error);
// Jupiter / SOL
if (msg.includes('INSUFFICIENT_FUNDS') || msg.includes('InsufficientFunds')) return 'Insufficient SOL balance';
if (msg.includes('SlippageToleranceExceeded') || msg.includes('Slippage')) return 'Slippage exceeded — try increasing tolerance';
// SunSwap / TRX
if (msg.includes('BANDWIDTH_ERROR') || msg.includes('bandwidth')) return 'Insufficient bandwidth — need to freeze TRX';
if (msg.includes('ENERGY_ERROR') || msg.includes('energy')) return 'Insufficient energy — need TRX for gas';
if (msg.includes('CONTRACT_VALIDATE_ERROR')) return 'Transaction validation failed on TRON';
if (msg.includes('balance is not sufficient')) return 'Insufficient token balance';
// Generic
if (msg.includes('timeout') || msg.includes('timed out')) return 'Request timed out — please try again';
if (msg.includes('429') || msg.includes('rate')) return 'Rate limited — please wait and retry';
if (msg.includes('No route') || msg.includes('No Uniswap route')) return 'No swap route found for this pair';
if (msg.includes('not allowed') || msg.includes('403')) return 'API access restricted';
return msg;
}

View File

@@ -0,0 +1,131 @@
import { ethers } from 'ethers';
import { Percent } from '@uniswap/sdk-core';
import { SwapRouter, TokenTransferMode } from '@uniswap/universal-router-sdk';
import { createEthProvider } from '@/lib/eth-provider';
import {
DEFAULT_DEADLINE_SECONDS,
ETHEREUM_CHAIN_ID,
FEE_RECIPIENT,
FEE_SWAP_ROUTER_ETH,
PLATFORM_FEE_BPS,
SWAP_PROXY_ADDRESS_MAINNET,
UNIVERSAL_ROUTER_ADDRESS_MAINNET,
UNIVERSAL_ROUTER_VERSION,
getSwapToken,
type SwapQuoteRequest,
} from './constants';
import type { SwapQuoteResult } from './quote';
const provider = createEthProvider();
export interface ExecuteSwapParams {
privateKey: string;
request: SwapQuoteRequest;
quote: SwapQuoteResult;
maxFeeGwei?: string | null;
priorityFeeGwei?: string | null;
}
export interface ExecuteSwapResult {
hash: string;
explorerUrl: string;
submittedTo: string;
}
export async function executeSwap(params: ExecuteSwapParams): Promise<ExecuteSwapResult> {
const wallet = new ethers.Wallet(params.privateKey, provider);
const inputToken = getSwapToken(params.request.fromSymbol);
const slippageTolerance = new Percent(params.request.slippageBps, 10_000);
const deadline = Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS;
const routerOptions = {
recipient: wallet.address,
slippageTolerance,
deadlineOrPreviousBlockhash: deadline,
urVersion: UNIVERSAL_ROUTER_VERSION,
...(inputToken.isNative
? {}
: {
tokenTransferMode: TokenTransferMode.ApproveProxy,
chainId: ETHEREUM_CHAIN_ID,
}),
};
const methodParameters = SwapRouter.swapCallParameters(params.quote.trade, routerOptions);
const feeOverrides = await getFeeOverrides(params.maxFeeGwei, params.priorityFeeGwei);
let response: ethers.providers.TransactionResponse;
let submittedTo: string;
if (inputToken.isNative) {
// ── Native ETH → Token: route through FeeSwapRouter contract ──
// Quote was built for 99.3% of user's amount. We send the full original
// amount to the contract — it takes 0.7% fee and forwards 99.3% + calldata
// to the Universal Router.
const fullAmountRaw = ethers.utils.parseUnits(params.request.amount, 18);
const feeRouterIface = new ethers.utils.Interface([
'function swapNativeWithFee(bytes calldata routerCalldata) external payable',
]);
const data = feeRouterIface.encodeFunctionData('swapNativeWithFee', [
methodParameters.calldata,
]);
submittedTo = FEE_SWAP_ROUTER_ETH;
response = await wallet.sendTransaction({
to: FEE_SWAP_ROUTER_ETH,
data,
value: fullAmountRaw,
...feeOverrides,
});
} else {
// ── ERC20 → Token: send 0.7% fee separately, then swap 99.3% normally ──
const token = getSwapToken(params.request.fromSymbol);
const fullAmountRaw = ethers.utils.parseUnits(params.request.amount, token.decimals);
const feeAmount = fullAmountRaw.mul(PLATFORM_FEE_BPS).div(10000);
// Send 0.7% fee to fee wallet
const tokenContract = new ethers.Contract(
token.address,
['function transfer(address to, uint256 amount) returns (bool)'],
wallet,
);
const feeTx = await tokenContract.transfer(FEE_RECIPIENT, feeAmount, feeOverrides);
await feeTx.wait();
// Execute swap with 99% (calldata already built for adjusted amount)
submittedTo = SWAP_PROXY_ADDRESS_MAINNET;
response = await wallet.sendTransaction({
to: submittedTo,
data: methodParameters.calldata,
value: methodParameters.value,
...feeOverrides,
});
}
const receipt = await response.wait();
if (!receipt || receipt.status !== 1) {
throw new Error('Swap transaction reverted');
}
return {
hash: response.hash,
explorerUrl: `https://etherscan.io/tx/${response.hash}`,
submittedTo,
};
}
async function getFeeOverrides(maxFeeGwei?: string | null, priorityFeeGwei?: string | null) {
if (maxFeeGwei?.trim()) {
return {
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
};
}
const feeData = await provider.getFeeData();
return {
...(feeData.maxFeePerGas ? { maxFeePerGas: feeData.maxFeePerGas } : {}),
...(feeData.maxPriorityFeePerGas ? { maxPriorityFeePerGas: feeData.maxPriorityFeePerGas } : {}),
};
}

View File

@@ -0,0 +1,365 @@
import { ethers } from 'ethers';
import { CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core';
import { Pool, Route, TICK_SPACINGS, encodeRouteToPath } from '@uniswap/v3-sdk';
import { Trade as RouterTrade } from '@uniswap/router-sdk';
import { webEnv } from '@/lib/env';
import {
ETHEREUM_CHAIN_ID,
PLATFORM_FEE_BPS,
QUOTER_V2_ABI,
QUOTER_V2_ADDRESS,
SWAP_REQUEST_TIMEOUT_MS,
SWAP_TOKENS,
V3_POOL_CANDIDATES,
UNISWAP_V3_POOL_ABI,
getSlippageBps,
type PoolCandidate,
type SwapQuoteRequest,
type SwapTokenSymbol,
} from './constants';
/** TickDataProvider that fetches tick data from the Uniswap V3 pool contract */
function createContractTickDataProvider(
poolContract: ethers.Contract,
tickSpacing: number
): { getTick: (tick: number) => Promise<{ liquidityNet: string }>; nextInitializedTickWithinOneWord: (tick: number, lte: boolean, _tickSpacing: number) => Promise<[number, boolean]> } {
async function getTick(tick: number): Promise<{ liquidityNet: string }> {
const result = await poolContract.ticks(tick);
return { liquidityNet: result.liquidityNet.toString() };
}
async function nextInitializedTickWithinOneWord(tick: number, lte: boolean, _tickSpacing: number): Promise<[number, boolean]> {
const spacing = tickSpacing;
let compressed = Math.trunc(tick / spacing);
if (tick < 0 && tick % spacing !== 0) compressed--;
const wordPos = Math.floor(compressed / 256);
const bitPos = ((compressed % 256) + 256) % 256;
const word = (await poolContract.tickBitmap(wordPos)) as ethers.BigNumber;
const w = BigInt(word.toString());
let masked: bigint;
let nextCompressed: number;
if (lte) {
const mask = (1n << BigInt(bitPos + 1)) - 1n;
masked = w & mask;
const initialized = masked !== 0n;
if (initialized) {
let msbPos = 0;
for (let i = 255; i >= 0; i--) {
if ((masked >> BigInt(i)) & 1n) {
msbPos = i;
break;
}
}
nextCompressed = compressed - (bitPos - msbPos);
} else {
nextCompressed = compressed - bitPos;
}
return [nextCompressed * spacing, masked !== 0n];
} else {
const nextWordPos = Math.floor((compressed + 1) / 256);
const nextBitPos = (((compressed + 1) % 256) + 256) % 256;
const nextWord = (await poolContract.tickBitmap(nextWordPos)) as ethers.BigNumber;
const nw = BigInt(nextWord.toString());
const mask = ~((1n << BigInt(nextBitPos)) - 1n);
masked = nw & mask;
const initialized = masked !== 0n;
if (initialized) {
let lsbPos = 0;
for (let i = 0; i <= 255; i++) {
if ((masked >> BigInt(i)) & 1n) {
lsbPos = i;
break;
}
}
nextCompressed = compressed + 1 + (lsbPos - nextBitPos);
} else {
nextCompressed = compressed + 1 + (255 - nextBitPos);
}
return [nextCompressed * spacing, initialized];
}
}
return { getTick, nextInitializedTickWithinOneWord };
}
const ETH_RPC_CANDIDATES = [
...new Set([
webEnv.ethRpcUrl,
'https://ethereum-rpc.publicnode.com',
'https://rpc.ankr.com/eth',
'https://eth.llamarpc.com',
].filter(Boolean)),
];
const poolCache = new Map<string, Pool>();
const poolCacheTimestamps = new Map<string, number>();
const POOL_CACHE_TTL_MS = 30_000; // 30 seconds
async function getHealthyProvider(): Promise<ethers.providers.StaticJsonRpcProvider> {
let lastError: unknown = new Error('No Ethereum RPC available');
for (const rpcUrl of ETH_RPC_CANDIDATES) {
if (!rpcUrl?.trim()) continue;
const provider = new ethers.providers.StaticJsonRpcProvider(rpcUrl, ETHEREUM_CHAIN_ID);
try {
await withTimeout(
provider.getBlockNumber(),
4_000,
'RPC health-check timed out'
);
return provider;
} catch (e) {
lastError = e;
}
}
throw lastError;
}
export interface SwapQuoteResult {
trade: RouterTrade<any, any, TradeType.EXACT_INPUT>;
amountInRaw: string;
amountInFormatted: string;
amountOutRaw: string;
amountOutFormatted: string;
minimumAmountOutRaw: string;
minimumAmountOutFormatted: string;
executionPrice: string;
priceImpact: string;
routeSymbols: SwapTokenSymbol[];
routeFees: number[];
}
/**
* Query the on-chain Quoter for each candidate pool and pick the best output.
* This replaces the fragile off-chain bestTradeExactIn simulation that fails
* with RATIO_CURRENT when tick data is stale.
*/
async function findBestRouteOnChain(
pools: Pool[],
inputToken: (typeof SWAP_TOKENS)[SwapTokenSymbol],
outputToken: (typeof SWAP_TOKENS)[SwapTokenSymbol],
amountInRaw: ethers.BigNumber,
quoter: ethers.Contract,
): Promise<{ bestPool: Pool; bestAmountOut: ethers.BigNumber; bestRoutePath: string }> {
const inputCurrency = inputToken.currency;
const outputCurrency = outputToken.currency;
// Filter pools that contain both input and output tokens
const relevantPools = pools.filter((pool) => {
const t0 = pool.token0.address.toLowerCase();
const t1 = pool.token1.address.toLowerCase();
const inAddr = (inputToken.wrappedToken?.address ?? inputToken.address).toLowerCase();
const outAddr = (outputToken.wrappedToken?.address ?? outputToken.address).toLowerCase();
return (t0 === inAddr || t1 === inAddr) && (t0 === outAddr || t1 === outAddr);
});
if (!relevantPools.length) {
throw new Error('No Uniswap pool available for this token pair');
}
let bestPool: Pool | null = null;
let bestAmountOut = ethers.BigNumber.from(0);
let bestRoutePath = '';
for (const pool of relevantPools) {
try {
const route = new Route([pool], inputCurrency, outputCurrency);
const routePath = encodeRouteToPath(route, false);
const [amountOut] = await withTimeout(
quoter.callStatic.quoteExactInput(routePath, amountInRaw),
SWAP_REQUEST_TIMEOUT_MS,
'Quote timed out',
);
if (amountOut.gt(bestAmountOut)) {
bestPool = pool;
bestAmountOut = amountOut;
bestRoutePath = routePath;
}
} catch {
// Pool doesn't have enough liquidity or RPC issue — skip it
continue;
}
}
if (!bestPool) {
throw new Error('No Uniswap route found for this token pair');
}
return { bestPool, bestAmountOut, bestRoutePath };
}
export async function getSwapQuote(request: SwapQuoteRequest): Promise<SwapQuoteResult> {
validateSwapRequest(request);
const provider = await getHealthyProvider();
const inputToken = SWAP_TOKENS[request.fromSymbol];
const outputToken = SWAP_TOKENS[request.toSymbol];
const originalAmountInRaw = ethers.utils.parseUnits(request.amount, inputToken.decimals);
// Apply 0.7% platform fee — swap only 99.3% of input (BigNumber math, no float)
const amountInRaw = originalAmountInRaw.mul(10000 - PLATFORM_FEE_BPS).div(10000);
const amountIn = CurrencyAmount.fromRawAmount(inputToken.currency, amountInRaw.toString());
const pools = await loadCandidatePools(provider);
const quoter = new ethers.Contract(QUOTER_V2_ADDRESS, QUOTER_V2_ABI, provider);
// Find best route via on-chain Quoter (avoids RATIO_CURRENT from off-chain simulation)
const { bestPool, bestAmountOut, bestRoutePath } = await findBestRouteOnChain(
pools, inputToken, outputToken, amountInRaw, quoter,
);
const route = new Route([bestPool], inputToken.currency, outputToken.currency);
const outputAmount = CurrencyAmount.fromRawAmount(outputToken.currency, bestAmountOut.toString());
const routerTrade = new RouterTrade({
v3Routes: [{
routev3: route,
inputAmount: amountIn,
outputAmount,
}],
tradeType: TradeType.EXACT_INPUT,
});
const slippageTolerance = new Percent(request.slippageBps, 10_000);
const minimumAmountOut = routerTrade.minimumAmountOut(slippageTolerance);
const outputDecimals = outputToken.decimals;
return {
trade: routerTrade,
amountInRaw: amountInRaw.toString(),
amountInFormatted: request.amount,
amountOutRaw: bestAmountOut.toString(),
amountOutFormatted: formatUnitsSafe(bestAmountOut.toString(), outputDecimals),
minimumAmountOutRaw: minimumAmountOut.quotient.toString(),
minimumAmountOutFormatted: formatUnitsSafe(minimumAmountOut.quotient.toString(), outputDecimals),
executionPrice: routerTrade.executionPrice.toSignificant(6),
priceImpact: routerTrade.priceImpact.toFixed(4),
routeSymbols: route.tokenPath.map((token) => tokenAddressToSymbol(token.address)),
routeFees: route.pools.map((pool) => pool.fee),
};
}
async function loadCandidatePools(provider: ethers.providers.StaticJsonRpcProvider): Promise<Pool[]> {
const settled = await Promise.allSettled(V3_POOL_CANDIDATES.map((c) => loadPool(c, provider)));
const pools = settled
.filter((result): result is PromiseFulfilledResult<Pool> => result.status === 'fulfilled')
.map((result) => result.value);
if (!pools.length) {
const errors = settled
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map((r) => r.reason?.message ?? String(r.reason));
const hasRpcIssue = errors.some(
(e) => e?.includes('Failed to fetch') || e?.includes('timeout') || e?.includes('ECONNREFUSED')
);
const hint = hasRpcIssue
? ' Check your connection or try a different RPC in NEXT_PUBLIC_ETH_RPC_URL.'
: '';
throw new Error(`No supported Uniswap pools are currently reachable.${hint}`);
}
return pools;
}
async function loadPool(candidate: PoolCandidate, provider: ethers.providers.StaticJsonRpcProvider): Promise<Pool> {
const cacheKey = `${candidate.tokenA.address}-${candidate.tokenB.address}-${candidate.fee}`;
const cachedPool = poolCache.get(cacheKey);
const cachedAt = poolCacheTimestamps.get(cacheKey) ?? 0;
if (cachedPool && Date.now() - cachedAt < POOL_CACHE_TTL_MS) {
return cachedPool;
}
const poolAddress = Pool.getAddress(candidate.tokenA, candidate.tokenB, candidate.fee);
const code = await withTimeout(
provider.getCode(poolAddress),
SWAP_REQUEST_TIMEOUT_MS,
'Pool discovery timed out'
);
if (!code || code === '0x') {
throw new Error(`Pool ${poolAddress} is unavailable`);
}
const poolContract = new ethers.Contract(poolAddress, UNISWAP_V3_POOL_ABI, provider);
const [liquidity, slot0] = await Promise.all([
withTimeout(poolContract.liquidity() as Promise<bigint>, SWAP_REQUEST_TIMEOUT_MS, 'Pool liquidity request timed out'),
withTimeout(
poolContract.slot0() as Promise<{ sqrtPriceX96: bigint; tick: number }>,
SWAP_REQUEST_TIMEOUT_MS,
'Pool slot0 request timed out'
),
]);
const tickSpacing = TICK_SPACINGS[candidate.fee];
const tickDataProvider = createContractTickDataProvider(poolContract, tickSpacing);
const pool = new Pool(
candidate.tokenA,
candidate.tokenB,
candidate.fee,
slot0.sqrtPriceX96.toString(),
liquidity.toString(),
slot0.tick,
tickDataProvider
);
poolCache.set(cacheKey, pool);
poolCacheTimestamps.set(cacheKey, Date.now());
return pool;
}
function validateSwapRequest(request: SwapQuoteRequest): void {
if (request.fromSymbol === request.toSymbol) {
throw new Error('Select two different tokens');
}
if (!request.amount || Number(request.amount) <= 0) {
throw new Error('Enter a valid swap amount');
}
if (!Number.isFinite(request.slippageBps) || request.slippageBps <= 0) {
throw new Error('Enter a valid slippage value');
}
}
function tokenAddressToSymbol(address: string): SwapTokenSymbol {
const normalizedAddress = address.toLowerCase();
const matched = Object.values(SWAP_TOKENS).find((token) => {
if (token.address === 'native') {
return token.wrappedToken.address.toLowerCase() === normalizedAddress;
}
return token.address.toLowerCase() === normalizedAddress;
});
return matched?.symbol ?? 'ETH';
}
function formatUnitsSafe(rawValue: bigint | string, decimals: number): string {
const bigintValue = typeof rawValue === 'bigint' ? rawValue : BigInt(rawValue);
if (bigintValue === 0n) {
return '0';
}
const divisor = 10n ** BigInt(decimals);
const whole = bigintValue / divisor;
const fraction = bigintValue % divisor;
if (fraction === 0n) {
return whole.toString();
}
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
}

View File

@@ -0,0 +1,65 @@
import { Connection, Keypair, VersionedTransaction } from '@solana/web3.js';
import { webEnv } from '@/lib/env';
export interface SolExecuteSwapParams {
privateKeyHex: string;
userPublicKey: string;
quoteResponse: Record<string, unknown>;
}
export interface SolSwapResult {
hash: string;
explorerUrl: string;
}
export async function executeSolSwap(params: SolExecuteSwapParams): Promise<SolSwapResult> {
const { privateKeyHex, userPublicKey, quoteResponse } = params;
// 1. Build swap transaction via backend proxy
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const buildResponse = await fetch(`${apiUrl}/api/sol/swap/build`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quoteResponse, userPublicKey }),
});
if (!buildResponse.ok) {
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build swap' }));
throw new Error(body.error || `Swap build failed (${buildResponse.status})`);
}
const { swapTransaction } = await buildResponse.json();
if (!swapTransaction) {
throw new Error('No swap transaction returned from Jupiter');
}
// 2. Deserialize the VersionedTransaction
const txBuffer = Buffer.from(swapTransaction, 'base64');
const transaction = VersionedTransaction.deserialize(txBuffer);
// 3. Sign with user's Keypair
const keypair = Keypair.fromSecretKey(Buffer.from(privateKeyHex, 'hex'));
transaction.sign([keypair]);
// 4. Send to Solana RPC
const connection = new Connection(webEnv.solRpcUrl, 'confirmed');
const rawTx = transaction.serialize();
const signature = await connection.sendRawTransaction(rawTx, {
skipPreflight: false,
maxRetries: 2,
});
// 5. Confirm transaction
const latestBlockhash = await connection.getLatestBlockhash();
await connection.confirmTransaction({
signature,
blockhash: latestBlockhash.blockhash,
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
}, 'confirmed');
return {
hash: signature,
explorerUrl: `https://solscan.io/tx/${signature}`,
};
}

View File

@@ -0,0 +1,99 @@
import { SOL_TOKEN_MINTS, SOL_TOKEN_DECIMALS } from '../constants';
import { webEnv } from '@/lib/env';
export interface SolSwapQuoteResult {
chain: 'SOL';
quoteResponse: Record<string, unknown>;
amountInRaw: string;
amountInFormatted: string;
amountOutRaw: string;
amountOutFormatted: string;
minimumAmountOutRaw: string;
minimumAmountOutFormatted: string;
priceImpact: string;
routeLabels: string[];
}
export interface SolSwapQuoteRequest {
fromSymbol: string;
toSymbol: string;
amount: string;
slippageBps: number;
}
export async function getSolSwapQuote(request: SolSwapQuoteRequest): Promise<SolSwapQuoteResult> {
const inputMint = SOL_TOKEN_MINTS[request.fromSymbol];
const outputMint = SOL_TOKEN_MINTS[request.toSymbol];
if (!inputMint || !outputMint) {
throw new Error(`Unknown SOL token: ${request.fromSymbol} or ${request.toSymbol}`);
}
const fromDecimals = SOL_TOKEN_DECIMALS[request.fromSymbol];
const toDecimals = SOL_TOKEN_DECIMALS[request.toSymbol];
// Convert human-readable amount to raw (lamports / smallest unit)
const amountRaw = parseUnitsToRaw(request.amount, fromDecimals);
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const url = new URL(`${apiUrl}/api/sol/swap/quote`);
url.searchParams.set('inputMint', inputMint);
url.searchParams.set('outputMint', outputMint);
url.searchParams.set('amount', amountRaw);
url.searchParams.set('slippageBps', String(request.slippageBps));
const response = await fetch(url.toString());
if (!response.ok) {
const body = await response.json().catch(() => ({ error: 'Jupiter API error' }));
throw new Error(body.error || `Jupiter quote failed (${response.status})`);
}
const data = await response.json();
// Jupiter response fields
const outAmount = String(data.outAmount ?? '0');
const otherAmountThreshold = String(data.otherAmountThreshold ?? '0');
const priceImpactPct = String(data.priceImpactPct ?? '0');
// Extract route labels
const routeLabels: string[] = [];
if (Array.isArray(data.routePlan)) {
for (const step of data.routePlan) {
if (step.swapInfo?.label) routeLabels.push(step.swapInfo.label);
}
}
return {
chain: 'SOL',
quoteResponse: data,
amountInRaw: amountRaw,
amountInFormatted: request.amount,
amountOutRaw: outAmount,
amountOutFormatted: formatRawUnits(outAmount, toDecimals),
minimumAmountOutRaw: otherAmountThreshold,
minimumAmountOutFormatted: formatRawUnits(otherAmountThreshold, toDecimals),
priceImpact: priceImpactPct,
routeLabels,
};
}
function parseUnitsToRaw(amount: string, decimals: number): string {
const parts = amount.split('.');
const whole = parts[0] || '0';
let fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
const raw = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction);
return raw.toString();
}
function formatRawUnits(raw: string, decimals: number): string {
const value = BigInt(raw);
if (value === 0n) return '0';
const divisor = 10n ** BigInt(decimals);
const whole = value / divisor;
const fraction = value % divisor;
if (fraction === 0n) return whole.toString();
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}

View File

@@ -0,0 +1,91 @@
import { ethers, utils } from 'ethers';
import { webEnv } from '@/lib/env';
export interface TrxExecuteSwapParams {
privateKeyHex: string; // 32-byte secp256k1 key (hex, no 0x prefix)
from: string;
to: string;
amount: string; // raw amount in sun
amountOutMin: string; // raw minimum output
userAddress: string; // TRX base58 address
}
export interface TrxSwapResult {
hash: string;
explorerUrl: string;
approvalHashes: string[];
}
export async function executeTrxSwap(params: TrxExecuteSwapParams): Promise<TrxSwapResult> {
const { privateKeyHex, from, to, amount, amountOutMin, userAddress } = params;
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
// 1. Build transaction(s) via backend
const buildResponse = await fetch(`${apiUrl}/api/tron/swap/build`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to, amount, amountOutMin, userAddress }),
});
if (!buildResponse.ok) {
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build TRX swap' }));
throw new Error(body.error || `TRX swap build failed (${buildResponse.status})`);
}
const { transactions } = await buildResponse.json();
if (!transactions || !transactions.length) {
throw new Error('No transactions returned from TRX swap builder');
}
// 2. Sign and broadcast each transaction in order
const signingKey = new utils.SigningKey('0x' + privateKeyHex);
const approvalHashes: string[] = [];
let swapHash = '';
for (const tx of transactions) {
// Sign the txID (which is SHA256 of raw_data)
const txID: string = tx.txID;
const digest = ethers.utils.arrayify('0x' + txID);
const signature = signingKey.signDigest(digest);
const sigHex = ethers.utils.joinSignature(signature).slice(2); // remove 0x, 65 bytes hex
// Add signature to transaction
const signedTx = {
...tx,
signature: [sigHex],
};
// Broadcast
const broadcastResponse = await fetch(`${apiUrl}/api/tron/swap/broadcast`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ signedTransaction: signedTx }),
});
const result = await broadcastResponse.json();
if (!result.result) {
const errorMsg = result.message || result.code || 'Broadcast failed';
throw new Error(`TRX broadcast error: ${errorMsg}`);
}
if (tx.type === 'approve') {
approvalHashes.push(txID);
// Wait a bit for approval to be confirmed before swapping
await delay(3000);
} else {
swapHash = txID;
}
}
return {
hash: swapHash,
explorerUrl: `https://tronscan.org/#/transaction/${swapHash}`,
approvalHashes,
};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,90 @@
import { TRX_TOKEN_DECIMALS } from '../constants';
import { webEnv } from '@/lib/env';
export interface TrxSwapQuoteResult {
chain: 'TRX';
amountInRaw: string;
amountInFormatted: string;
amountOutRaw: string;
amountOutFormatted: string;
minimumAmountOutRaw: string;
minimumAmountOutFormatted: string;
from: string;
to: string;
}
export interface TrxSwapQuoteRequest {
fromSymbol: string;
toSymbol: string;
amount: string;
slippageBps: number;
}
export async function getTrxSwapQuote(request: TrxSwapQuoteRequest): Promise<TrxSwapQuoteResult> {
const fromDecimals = TRX_TOKEN_DECIMALS[request.fromSymbol];
const toDecimals = TRX_TOKEN_DECIMALS[request.toSymbol];
if (fromDecimals === undefined || toDecimals === undefined) {
throw new Error(`Unknown TRX token: ${request.fromSymbol} or ${request.toSymbol}`);
}
// Convert human-readable amount to raw (sun / smallest unit)
const amountRaw = parseUnitsToRaw(request.amount, fromDecimals);
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
const url = new URL(`${apiUrl}/api/tron/swap/quote`);
url.searchParams.set('from', request.fromSymbol);
url.searchParams.set('to', request.toSymbol);
url.searchParams.set('amount', amountRaw);
const response = await fetch(url.toString());
if (!response.ok) {
const body = await response.json().catch(() => ({ error: 'TRX quote failed' }));
throw new Error(body.error || `TRX quote failed (${response.status})`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'TRX quote returned error');
}
const amountOut = String(data.amountOut);
// Apply slippage to get minimum output
const amountOutBigInt = BigInt(amountOut);
const minOut = amountOutBigInt - (amountOutBigInt * BigInt(request.slippageBps) / 10000n);
return {
chain: 'TRX',
amountInRaw: amountRaw,
amountInFormatted: request.amount,
amountOutRaw: amountOut,
amountOutFormatted: formatRawUnits(amountOut, toDecimals),
minimumAmountOutRaw: minOut.toString(),
minimumAmountOutFormatted: formatRawUnits(minOut.toString(), toDecimals),
from: request.fromSymbol,
to: request.toSymbol,
};
}
function parseUnitsToRaw(amount: string, decimals: number): string {
const parts = amount.split('.');
const whole = parts[0] || '0';
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
const raw = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction);
return raw.toString();
}
function formatRawUnits(raw: string, decimals: number): string {
const value = BigInt(raw);
if (value === 0n) return '0';
const divisor = 10n ** BigInt(decimals);
const whole = value / divisor;
const fraction = value % divisor;
if (fraction === 0n) return whole.toString();
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}

View File

@@ -0,0 +1,48 @@
'use client';
import { create } from 'zustand';
import { api } from '@/lib/api';
interface Wallet {
chain: string;
address: string;
derivationPath: string;
}
interface AuthState {
user: { id: string; email: string } | null;
wallets: Wallet[];
loading: boolean;
error: string | null;
init: () => Promise<void>;
logout: () => void;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
wallets: [],
loading: false,
error: null,
init: async () => {
set({ loading: true, error: null });
try {
const wallets = await api.getWallets();
set({
user: { id: '', email: '' },
wallets,
loading: false,
});
} catch {
set({ user: null, wallets: [], loading: false });
}
},
logout: () => {
set({ user: null, wallets: [] });
},
clearError: () => set({ error: null }),
}));

View File

@@ -0,0 +1,114 @@
'use client';
import { create } from 'zustand';
import type { DerivedWallet } from '@/lib/crypto/derive-keys';
import { fetchAllBalances } from '@/lib/balances';
import type { ChainBalance, PortfolioBalance } from '@/lib/balances/types';
interface BalanceState {
portfolio: PortfolioBalance | null;
loading: boolean;
refreshing: boolean;
error: string | null;
fetchBalances: (wallets: DerivedWallet[]) => Promise<void>;
clearBalances: () => void;
}
export const useBalanceStore = create<BalanceState>((set, get) => ({
portfolio: null,
loading: false,
refreshing: false,
error: null,
fetchBalances: async (wallets) => {
if (!wallets.length) {
set({ portfolio: null, loading: false, refreshing: false, error: null });
return;
}
const hasPortfolio = !!get().portfolio;
set({
loading: !hasPortfolio,
refreshing: hasPortfolio,
error: null,
});
try {
const fresh = await fetchAllBalances(wallets);
const prev = get().portfolio;
// Merge: if a chain had an error but we have previous data, keep previous
const portfolio = prev ? mergePortfolios(prev, fresh) : fresh;
set({
portfolio,
loading: false,
refreshing: false,
error: null,
});
} catch (error) {
const prev = get().portfolio;
set({
portfolio: prev,
loading: false,
refreshing: false,
error: prev ? null : getErrorMessage(error),
});
}
},
clearBalances: () => {
set({
portfolio: null,
loading: false,
refreshing: false,
error: null,
});
},
}));
/**
* If a chain in fresh data has an error and all its token balances are zero,
* but previous data had real balances, keep the previous chain data.
*/
function mergePortfolios(prev: PortfolioBalance, fresh: PortfolioBalance): PortfolioBalance {
const chains = fresh.chains.map((freshChain) => {
if (!freshChain.error) return freshChain;
const prevChain = prev.chains.find((c) => c.chain === freshChain.chain);
if (!prevChain || prevChain.error) return freshChain;
// Chain has error and all balances are zero — keep previous
const allZero = freshChain.tokens.every((t) => t.balanceRaw === '0');
if (allZero) return prevChain;
return freshChain;
});
const totalUsd = sumNullable(chains.map((c) => c.totalUsd));
const errors: PortfolioBalance['errors'] = {};
for (const chain of chains) {
if (chain.error && chain.error !== '__transient__') errors[chain.chain] = chain.error;
}
return {
chains,
totalUsd,
errors,
priceError: fresh.priceError,
updatedAt: fresh.updatedAt,
};
}
function sumNullable(values: Array<number | null>): number | null {
const filtered = values.filter((v): v is number => typeof v === 'number');
return filtered.length ? filtered.reduce((a, b) => a + b, 0) : null;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return 'Unable to refresh balances';
}

34
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}