add project
This commit is contained in:
41
apps/web/.gitignore
vendored
Normal file
41
apps/web/.gitignore
vendored
Normal 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
36
apps/web/README.md
Normal 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
28
apps/web/next.config.ts
Normal 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
38
apps/web/package.json
Normal 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
1770
apps/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
apps/web/pnpm-workspace.yaml
Normal file
3
apps/web/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
1
apps/web/public/file.svg
Normal file
1
apps/web/public/file.svg
Normal 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 |
1
apps/web/public/globe.svg
Normal file
1
apps/web/public/globe.svg
Normal 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
1
apps/web/public/next.svg
Normal 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 |
1
apps/web/public/vercel.svg
Normal file
1
apps/web/public/vercel.svg
Normal 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 |
1
apps/web/public/window.svg
Normal file
1
apps/web/public/window.svg
Normal 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 |
327
apps/web/src/app/bridge/page.tsx
Normal file
327
apps/web/src/app/bridge/page.tsx
Normal 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,
|
||||
};
|
||||
152
apps/web/src/app/dashboard/page.tsx
Normal file
152
apps/web/src/app/dashboard/page.tsx
Normal 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,
|
||||
};
|
||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
42
apps/web/src/app/globals.css
Normal file
42
apps/web/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
17
apps/web/src/app/layout.tsx
Normal file
17
apps/web/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
apps/web/src/app/page.module.css
Normal file
141
apps/web/src/app/page.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
5
apps/web/src/app/page.tsx
Normal file
5
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
225
apps/web/src/app/receive/page.tsx
Normal file
225
apps/web/src/app/receive/page.tsx
Normal 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',
|
||||
};
|
||||
561
apps/web/src/app/send/page.tsx
Normal file
561
apps/web/src/app/send/page.tsx
Normal 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%',
|
||||
};
|
||||
45
apps/web/src/app/settings/page.tsx
Normal file
45
apps/web/src/app/settings/page.tsx
Normal 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',
|
||||
};
|
||||
328
apps/web/src/app/swap/page.tsx
Normal file
328
apps/web/src/app/swap/page.tsx
Normal 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,
|
||||
};
|
||||
52
apps/web/src/hooks/useBalances.ts
Normal file
52
apps/web/src/hooks/useBalances.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
191
apps/web/src/hooks/useBridge.ts
Normal file
191
apps/web/src/hooks/useBridge.ts
Normal 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';
|
||||
}
|
||||
36
apps/web/src/hooks/useGasPrice.ts
Normal file
36
apps/web/src/hooks/useGasPrice.ts
Normal 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 };
|
||||
}
|
||||
64
apps/web/src/hooks/useGasSettings.ts
Normal file
64
apps/web/src/hooks/useGasSettings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
274
apps/web/src/hooks/useSwap.ts
Normal file
274
apps/web/src/hooks/useSwap.ts
Normal 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
28
apps/web/src/lib/api.ts
Normal 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'),
|
||||
};
|
||||
185
apps/web/src/lib/balances/bsc-balances.ts
Normal file
185
apps/web/src/lib/balances/bsc-balances.ts
Normal 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))];
|
||||
}
|
||||
101
apps/web/src/lib/balances/btc-balances.ts
Normal file
101
apps/web/src/lib/balances/btc-balances.ts
Normal 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';
|
||||
}
|
||||
257
apps/web/src/lib/balances/eth-balances.ts
Normal file
257
apps/web/src/lib/balances/eth-balances.ts
Normal 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))];
|
||||
}
|
||||
120
apps/web/src/lib/balances/index.ts
Normal file
120
apps/web/src/lib/balances/index.ts
Normal 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';
|
||||
}
|
||||
70
apps/web/src/lib/balances/prices.ts
Normal file
70
apps/web/src/lib/balances/prices.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
325
apps/web/src/lib/balances/sol-balances.ts
Normal file
325
apps/web/src/lib/balances/sol-balances.ts
Normal 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))];
|
||||
}
|
||||
133
apps/web/src/lib/balances/trx-balances.ts
Normal file
133
apps/web/src/lib/balances/trx-balances.ts
Normal 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';
|
||||
}
|
||||
33
apps/web/src/lib/balances/types.ts
Normal file
33
apps/web/src/lib/balances/types.ts
Normal 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;
|
||||
}
|
||||
112
apps/web/src/lib/bridge/constants.ts
Normal file
112
apps/web/src/lib/bridge/constants.ts
Normal 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;
|
||||
}
|
||||
482
apps/web/src/lib/bridge/execute.ts
Normal file
482
apps/web/src/lib/bridge/execute.ts
Normal 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));
|
||||
}
|
||||
184
apps/web/src/lib/bridge/quote.ts
Normal file
184
apps/web/src/lib/bridge/quote.ts
Normal 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+$/, '')}`;
|
||||
}
|
||||
40
apps/web/src/lib/bridge/status.ts
Normal file
40
apps/web/src/lib/bridge/status.ts
Normal 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';
|
||||
}
|
||||
4
apps/web/src/lib/crypto/bsc-constants.ts
Normal file
4
apps/web/src/lib/crypto/bsc-constants.ts
Normal 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');
|
||||
17
apps/web/src/lib/crypto/btc.ts
Normal file
17
apps/web/src/lib/crypto/btc.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
9
apps/web/src/lib/crypto/eth.ts
Normal file
9
apps/web/src/lib/crypto/eth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
14
apps/web/src/lib/crypto/mnemonic.ts
Normal file
14
apps/web/src/lib/crypto/mnemonic.ts
Normal 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);
|
||||
}
|
||||
13
apps/web/src/lib/crypto/sol.ts
Normal file
13
apps/web/src/lib/crypto/sol.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
58
apps/web/src/lib/crypto/trx.ts
Normal file
58
apps/web/src/lib/crypto/trx.ts
Normal 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
16
apps/web/src/lib/env.ts
Normal 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;
|
||||
25
apps/web/src/lib/eth-provider.ts
Normal file
25
apps/web/src/lib/eth-provider.ts
Normal 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);
|
||||
}
|
||||
66
apps/web/src/lib/gas-price.ts
Normal file
66
apps/web/src/lib/gas-price.ts
Normal 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); });
|
||||
});
|
||||
}
|
||||
146
apps/web/src/lib/qr/generate.ts
Normal file
146
apps/web/src/lib/qr/generate.ts
Normal 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();
|
||||
}
|
||||
203
apps/web/src/lib/qr/parse.ts
Normal file
203
apps/web/src/lib/qr/parse.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
136
apps/web/src/lib/send/constants.ts
Normal file
136
apps/web/src/lib/send/constants.ts
Normal 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' },
|
||||
};
|
||||
622
apps/web/src/lib/send/execute.ts
Normal file
622
apps/web/src/lib/send/execute.ts
Normal 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();
|
||||
}
|
||||
100
apps/web/src/lib/send/validate.ts
Normal file
100
apps/web/src/lib/send/validate.ts
Normal 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;
|
||||
}
|
||||
96
apps/web/src/lib/swap/approve.ts
Normal file
96
apps/web/src/lib/swap/approve.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
203
apps/web/src/lib/swap/bsc/execute.ts
Normal file
203
apps/web/src/lib/swap/bsc/execute.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
95
apps/web/src/lib/swap/bsc/quote.ts
Normal file
95
apps/web/src/lib/swap/bsc/quote.ts
Normal 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+$/, '')}`;
|
||||
}
|
||||
392
apps/web/src/lib/swap/constants.ts
Normal file
392
apps/web/src/lib/swap/constants.ts
Normal 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;
|
||||
}
|
||||
23
apps/web/src/lib/swap/errors.ts
Normal file
23
apps/web/src/lib/swap/errors.ts
Normal 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;
|
||||
}
|
||||
131
apps/web/src/lib/swap/execute.ts
Normal file
131
apps/web/src/lib/swap/execute.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
365
apps/web/src/lib/swap/quote.ts
Normal file
365
apps/web/src/lib/swap/quote.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
65
apps/web/src/lib/swap/sol/execute.ts
Normal file
65
apps/web/src/lib/swap/sol/execute.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
99
apps/web/src/lib/swap/sol/quote.ts
Normal file
99
apps/web/src/lib/swap/sol/quote.ts
Normal 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+$/, '')}`;
|
||||
}
|
||||
91
apps/web/src/lib/swap/trx/execute.ts
Normal file
91
apps/web/src/lib/swap/trx/execute.ts
Normal 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));
|
||||
}
|
||||
90
apps/web/src/lib/swap/trx/quote.ts
Normal file
90
apps/web/src/lib/swap/trx/quote.ts
Normal 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+$/, '')}`;
|
||||
}
|
||||
48
apps/web/src/store/auth-store.ts
Normal file
48
apps/web/src/store/auth-store.ts
Normal 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 }),
|
||||
}));
|
||||
114
apps/web/src/store/balance-store.ts
Normal file
114
apps/web/src/store/balance-store.ts
Normal 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
34
apps/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user