feat: OMNL Bank online production — 128 chains, Central Bank UI, settlement stack
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m22s
CI/CD Pipeline / Security Scanning (push) Successful in 2m30s
CI/CD Pipeline / Lint and Format (push) Failing after 44s
CI/CD Pipeline / Terraform Validation (push) Failing after 27s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 31s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 56s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 29s
Validation / validate-genesis (push) Successful in 34s
Validation / validate-terraform (push) Failing after 39s
Validation / validate-kubernetes (push) Failing after 14s
Validation / validate-smart-contracts (push) Failing after 20s
Validation / validate-security (push) Failing after 1m19s
Validation / validate-documentation (push) Failing after 27s
Verify Deployment / Verify Deployment (push) Failing after 1m13s
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m22s
CI/CD Pipeline / Security Scanning (push) Successful in 2m30s
CI/CD Pipeline / Lint and Format (push) Failing after 44s
CI/CD Pipeline / Terraform Validation (push) Failing after 27s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 31s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 56s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 29s
Validation / validate-genesis (push) Successful in 34s
Validation / validate-terraform (push) Failing after 39s
Validation / validate-kubernetes (push) Failing after 14s
Validation / validate-smart-contracts (push) Failing after 20s
Validation / validate-security (push) Failing after 1m19s
Validation / validate-documentation (push) Failing after 27s
Verify Deployment / Verify Deployment (push) Failing after 1m13s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,27 +7,27 @@ import { AdminProvider } from './contexts/AdminContext'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import BridgePage from './pages/BridgePage'
|
||||
import SwapPage from './pages/SwapPage'
|
||||
import TradePage from './pages/TradePage'
|
||||
import CentralBankPage from './pages/CentralBankPage'
|
||||
import Office24Page from './pages/Office24Page'
|
||||
import ReservePage from './pages/ReservePage'
|
||||
import HistoryPage from './pages/HistoryPage'
|
||||
import AdminPanel from './pages/AdminPanel'
|
||||
import DocsPage from './pages/DocsPage'
|
||||
import WalletsDemoPage from './pages/WalletsDemoPage'
|
||||
import Layout from './components/layout/Layout'
|
||||
import OmnlProductLayout from './components/layout/OmnlProductLayout'
|
||||
import ToastProvider from './components/ui/ToastProvider'
|
||||
|
||||
// Configure QueryClient to handle contract revert errors gracefully
|
||||
// Don't retry on contract revert errors (expected when contracts aren't deployed)
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: (failureCount, error: any) => {
|
||||
// Don't retry on contract revert errors (CALL_EXCEPTION)
|
||||
// These are expected when contracts aren't deployed at the specified addresses
|
||||
if (error?.code === 'CALL_EXCEPTION' || error?.message?.includes('call revert exception')) {
|
||||
return false;
|
||||
retry: (failureCount, error: unknown) => {
|
||||
const err = error as { code?: string; message?: string }
|
||||
if (err?.code === 'CALL_EXCEPTION' || err?.message?.includes('call revert exception')) {
|
||||
return false
|
||||
}
|
||||
// Retry other errors up to 2 times
|
||||
return failureCount < 2;
|
||||
return failureCount < 2
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -48,8 +48,13 @@ function App() {
|
||||
>
|
||||
<ToastProvider />
|
||||
<AdminProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Routes>
|
||||
<Route element={<OmnlProductLayout />}>
|
||||
<Route path="/central-bank" element={<CentralBankPage />} />
|
||||
<Route path="/office-24" element={<Office24Page />} />
|
||||
<Route path="/trade" element={<TradePage />} />
|
||||
</Route>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<BridgePage />} />
|
||||
<Route path="/swap" element={<SwapPage />} />
|
||||
<Route path="/reserve" element={<ReservePage />} />
|
||||
@@ -57,8 +62,8 @@ function App() {
|
||||
<Route path="/admin" element={<AdminPanel />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
<Route path="/wallets" element={<WalletsDemoPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AdminProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
@@ -69,4 +74,3 @@ function App() {
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import CopyButton from '../ui/CopyButton';
|
||||
import ConfirmationModal from '../ui/ConfirmationModal';
|
||||
import Tooltip from '../ui/Tooltip';
|
||||
import LoadingSkeleton from '../ui/LoadingSkeleton';
|
||||
import ChainIcon from '../ui/ChainIcon';
|
||||
import TokenIcon from '../ui/TokenIcon';
|
||||
|
||||
interface BridgeButtonsProps {
|
||||
@@ -384,38 +383,28 @@ function BridgeButtonsConnected({
|
||||
/>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-[20px] font-semibold text-white flex items-center gap-2">
|
||||
Bridge to
|
||||
{destination && (
|
||||
<>
|
||||
<ChainIcon chainId={destination.chainId} name={destination.name} size={24} />
|
||||
<span>{destination.name}</span>
|
||||
</>
|
||||
)}
|
||||
{!destination && <span>Ethereum Mainnet</span>}
|
||||
</h2>
|
||||
<Tooltip content="Refresh all balances and allowances">
|
||||
<button
|
||||
onClick={handleRefreshBalances}
|
||||
disabled={!address}
|
||||
className="p-2 text-teal-400 hover:text-teal-300 hover:bg-teal-500/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-white/20"
|
||||
aria-label="Refresh balances"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-[#A0A0A0] text-sm mt-1">
|
||||
Wrap ETH, approve tokens, and bridge WETH9 via CCIP
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<p className="ui-muted text-sm">
|
||||
Wrap ETH, approve tokens, and bridge WETH9 via CCIP to{' '}
|
||||
{destination?.name ?? 'Ethereum Mainnet'}
|
||||
</p>
|
||||
<Tooltip content="Refresh all balances and allowances">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefreshBalances}
|
||||
disabled={!address}
|
||||
className="ui-btn-icon disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
aria-label="Refresh balances"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<label className="flex items-center gap-2 text-[13px] font-medium mb-2 text-[#A0A0A0]">
|
||||
<label className="ui-label">
|
||||
<TokenIcon symbol="ETH" size={20} />
|
||||
<span>Amount (ETH)</span>
|
||||
<Tooltip content="Enter the amount of ETH you want to bridge. This will be wrapped to WETH9 first.">
|
||||
@@ -429,10 +418,8 @@ function BridgeButtonsConnected({
|
||||
min="0"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className={`w-full min-h-[48px] pl-4 pr-24 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 focus:ring-offset-[#252830] transition-all text-lg bg-[#1a1d24] text-white placeholder:text-[#A0A0A0] hover:border-white/30 ${
|
||||
amountError
|
||||
? 'border-red-500 focus:border-red-500'
|
||||
: 'border-white/20 focus:border-teal-500'
|
||||
className={`ui-input pl-4 pr-24 text-lg ${
|
||||
amountError ? '!border-red-500 focus:!border-red-500' : ''
|
||||
}`}
|
||||
placeholder="0.0"
|
||||
aria-invalid={!!amountError}
|
||||
@@ -444,7 +431,7 @@ function BridgeButtonsConnected({
|
||||
setAmount(ethBalance.displayValue);
|
||||
setAmountError('');
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 px-4 py-2 min-h-[36px] text-sm font-medium bg-teal-600 text-white rounded-lg hover:bg-teal-500 transition-colors"
|
||||
className="ui-btn-primary absolute right-2 top-1/2 -translate-y-1/2 !py-2 !min-h-0 text-sm"
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
@@ -461,7 +448,7 @@ function BridgeButtonsConnected({
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<label htmlFor="bridge-recipient-address" className="block text-[13px] font-medium mb-2 text-[#A0A0A0]">
|
||||
<label htmlFor="bridge-recipient-address" className="ui-label">
|
||||
Recipient Address
|
||||
<Tooltip content="The Ethereum address that will receive the bridged tokens on the destination chain.">
|
||||
<span className="ml-2 text-white/60 cursor-help text-xs">ℹ️</span>
|
||||
@@ -474,10 +461,8 @@ function BridgeButtonsConnected({
|
||||
type="text"
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
className={`w-full min-h-[48px] p-4 border rounded-xl focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 focus:ring-offset-[#252830] transition-all font-mono text-base bg-[#1a1d24] text-white placeholder:text-[#A0A0A0] hover:border-white/30 ${
|
||||
recipientError
|
||||
? 'border-red-500 focus:border-red-500'
|
||||
: 'border-white/20 focus:border-teal-500'
|
||||
className={`ui-input font-mono pr-36 ${
|
||||
recipientError ? '!border-red-500 focus:!border-red-500' : ''
|
||||
}`}
|
||||
placeholder="0x..."
|
||||
aria-invalid={!!recipientError}
|
||||
@@ -490,7 +475,7 @@ function BridgeButtonsConnected({
|
||||
setRecipient(address);
|
||||
setRecipientError('');
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium bg-[#252830] text-white rounded-lg hover:bg-white/10 transition-colors border border-white/20"
|
||||
className="ui-btn-secondary !py-2 text-sm"
|
||||
>
|
||||
Use my address
|
||||
</button>
|
||||
@@ -510,9 +495,9 @@ function BridgeButtonsConnected({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 p-6 bg-[#1a1d24] rounded-xl border border-white/10">
|
||||
<div className="mb-8 p-6 ui-inset rounded-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[20px] font-semibold text-[#A0A0A0]">Balances & Fees</h3>
|
||||
<h3 className="text-lg font-semibold ui-muted">Balances & Fees</h3>
|
||||
{address && (
|
||||
<CopyButton text={address} className="text-xs">
|
||||
<span className="text-xs">Copy Address</span>
|
||||
@@ -520,49 +505,49 @@ function BridgeButtonsConnected({
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex justify-between items-center p-4 bg-[#252830] rounded-lg border border-white/10 font-mono text-sm">
|
||||
<span className="font-medium text-[#A0A0A0] flex items-center gap-2">
|
||||
<div className="ui-stat-row">
|
||||
<span className="ui-muted font-medium flex items-center gap-2">
|
||||
<TokenIcon symbol="ETH" size={20} />
|
||||
ETH Balance:
|
||||
</span>
|
||||
<span className="text-white font-medium">
|
||||
{ethBalance ? ethBalance.displayValue : <LoadingSkeleton />} <span className="text-[#A0A0A0]">ETH</span>
|
||||
<span className="font-medium">
|
||||
{ethBalance ? ethBalance.displayValue : <LoadingSkeleton />} <span className="ui-muted">ETH</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-4 bg-[#252830] rounded-lg border border-white/10 font-mono text-sm">
|
||||
<span className="font-medium text-[#A0A0A0] flex items-center gap-2">
|
||||
<div className="ui-stat-row">
|
||||
<span className="ui-muted font-medium flex items-center gap-2">
|
||||
<TokenIcon symbol="WETH9" size={20} />
|
||||
WETH9 Balance:
|
||||
</span>
|
||||
<span className="text-white font-medium">
|
||||
<span className="font-medium">
|
||||
{address && weth9Balance !== undefined
|
||||
? `${ethers.utils.formatEther(weth9Balance.toString())}`
|
||||
: address
|
||||
? <LoadingSkeleton />
|
||||
: '0'} <span className="text-[#A0A0A0]">WETH9</span>
|
||||
: '0'} <span className="ui-muted">WETH9</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-4 bg-[#252830] rounded-lg border border-white/10 font-mono text-sm">
|
||||
<span className="font-medium text-[#A0A0A0] flex items-center gap-2">
|
||||
<div className="ui-stat-row">
|
||||
<span className="ui-muted font-medium flex items-center gap-2">
|
||||
<TokenIcon symbol="LINK" size={20} />
|
||||
LINK Balance:
|
||||
</span>
|
||||
<span className="text-white font-medium">
|
||||
<span className="font-medium">
|
||||
{address && linkBalance !== undefined
|
||||
? `${ethers.utils.formatEther(linkBalance.toString())}`
|
||||
: address
|
||||
? <LoadingSkeleton />
|
||||
: '0'} <span className="text-[#A0A0A0]">LINK</span>
|
||||
: '0'} <span className="ui-muted">LINK</span>
|
||||
</span>
|
||||
</div>
|
||||
{ccipFee != null && ccipFee.gt(0) && (
|
||||
<div className="flex justify-between items-center p-4 bg-[#252830] rounded-lg border border-teal-500/30 font-mono text-sm">
|
||||
<span className="font-medium text-[#A0A0A0] flex items-center gap-2">
|
||||
<div className="ui-stat-row ui-stat-row--highlight">
|
||||
<span className="ui-muted font-medium flex items-center gap-2">
|
||||
<TokenIcon symbol="LINK" size={20} />
|
||||
CCIP Fee:
|
||||
</span>
|
||||
<span className="text-white font-medium">
|
||||
{ethers.utils.formatEther(ccipFee)} <span className="text-[#A0A0A0]">LINK</span>
|
||||
<span className="font-medium">
|
||||
{ethers.utils.formatEther(ccipFee)} <span className="ui-muted">LINK</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -573,7 +558,7 @@ function BridgeButtonsConnected({
|
||||
<button
|
||||
onClick={() => setShowWrapModal(true)}
|
||||
disabled={!needsWrapping || !address || isWrapping}
|
||||
className="px-6 py-4 bg-[#252830] text-white rounded-xl font-semibold hover:bg-white/10 disabled:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-white/20"
|
||||
className="ui-btn-secondary w-full"
|
||||
aria-label="Wrap ETH to WETH9"
|
||||
>
|
||||
{isWrapping ? (
|
||||
@@ -592,7 +577,7 @@ function BridgeButtonsConnected({
|
||||
<button
|
||||
onClick={() => setShowApproveModal(true)}
|
||||
disabled={!needsApproval || !address || isApproving}
|
||||
className="px-6 py-4 bg-[#252830] text-white rounded-xl font-semibold hover:bg-white/10 disabled:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-white/20"
|
||||
className="ui-btn-secondary w-full"
|
||||
aria-label="Approve tokens for bridge"
|
||||
>
|
||||
{isApproving ? (
|
||||
@@ -612,7 +597,7 @@ function BridgeButtonsConnected({
|
||||
<button
|
||||
onClick={() => setShowBridgeModal(true)}
|
||||
disabled={!canBridge || !address || isBridging}
|
||||
className="w-full min-h-[56px] py-4 text-lg font-semibold bg-teal-600 text-white rounded-xl hover:bg-teal-500 disabled:bg-[#252830] disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 focus:ring-offset-[#252830] shadow-lg"
|
||||
className="ui-btn-primary w-full min-h-[56px] text-lg"
|
||||
aria-label="Start bridge transfer"
|
||||
>
|
||||
{isBridging ? (
|
||||
@@ -683,26 +668,11 @@ export default function BridgeButtons(props: BridgeButtonsProps) {
|
||||
const destination = CCIP_DESTINATIONS.find((d) => d.selector === destinationChainSelector);
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-[20px] font-semibold text-white flex items-center gap-2">
|
||||
Bridge to
|
||||
{destination && (
|
||||
<>
|
||||
<ChainIcon chainId={destination.chainId} name={destination.name} size={24} />
|
||||
<span>{destination.name}</span>
|
||||
</>
|
||||
)}
|
||||
{!destination && <span>Ethereum Mainnet</span>}
|
||||
</h2>
|
||||
<p className="text-[#A0A0A0] text-sm mt-1">
|
||||
Wrap ETH, approve tokens, and bridge WETH9 via CCIP
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-8 p-5 bg-amber-500/10 border border-amber-500/30 rounded-xl text-sm text-white font-medium flex items-center gap-4">
|
||||
<svg className="h-6 w-6 text-yellow-300 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<div className="ui-alert">
|
||||
<svg className="h-5 w-5 shrink-0 ui-accent-text" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Please connect your wallet to continue</span>
|
||||
<span>Connect your wallet to bridge to {destination?.name ?? 'Ethereum Mainnet'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
60
frontend-dapp/src/components/layout/OmnlProductLayout.tsx
Normal file
60
frontend-dapp/src/components/layout/OmnlProductLayout.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useRef } from 'react';
|
||||
import WalletConnect from '../wallet/WalletConnect';
|
||||
import WalletDisconnectNotice from '../wallet/WalletDisconnectNotice';
|
||||
|
||||
const PRODUCTS = [
|
||||
{ path: '/central-bank', label: 'Central Bank', short: 'CB' },
|
||||
{ path: '/office-24', label: 'Office 24', short: 'O24' },
|
||||
{ path: '/trade', label: 'DBIS Trade', short: 'Trade' },
|
||||
] as const;
|
||||
|
||||
export default function OmnlProductLayout() {
|
||||
const location = useLocation();
|
||||
const userInitiatedDisconnectRef = useRef(false);
|
||||
|
||||
const isActive = (path: string) => location.pathname.startsWith(path);
|
||||
|
||||
return (
|
||||
<div className="omnl-app min-h-screen flex flex-col bg-[#0b0e11]">
|
||||
<WalletDisconnectNotice userInitiatedDisconnectRef={userInitiatedDisconnectRef} />
|
||||
<header className="omnl-app-header sticky top-0 z-50 border-b border-[#2b3139] bg-[#181a20]">
|
||||
<div className="max-w-[1600px] mx-auto px-4 h-14 flex items-center gap-4">
|
||||
<Link to="/central-bank" className="flex items-center gap-2 shrink-0">
|
||||
<span className="w-8 h-8 rounded bg-[#f0b90b] text-[#181a20] font-bold flex items-center justify-center text-sm">
|
||||
O
|
||||
</span>
|
||||
<span className="font-semibold text-[#eaecef] hidden sm:inline">OMNL</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-1 flex-1" aria-label="OMNL products">
|
||||
{PRODUCTS.map(({ path, label }) => (
|
||||
<Link
|
||||
key={path}
|
||||
to={path}
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
isActive(path)
|
||||
? 'bg-[#2b3139] text-[#f0b90b]'
|
||||
: 'text-[#848e9c] hover:text-[#eaecef] hover:bg-[#1e2329]'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
className="text-xs text-[#848e9c] hover:text-[#f0b90b] hidden md:inline shrink-0"
|
||||
>
|
||||
Bridge →
|
||||
</Link>
|
||||
<WalletConnect onBeforeDisconnect={() => { userInitiatedDisconnectRef.current = true; }} />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
frontend-dapp/src/config/dex.ts
Normal file
71
frontend-dapp/src/config/dex.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/** DBIS Exchange — Chain 138 DEX addresses (Pancake/Uniswap V2 compatible) */
|
||||
|
||||
export const DBIS_EXCHANGE_CHAIN_ID = 138;
|
||||
|
||||
|
||||
|
||||
export const TOKEN_AGGREGATION_URL =
|
||||
|
||||
import.meta.env.VITE_TOKEN_AGGREGATION_URL || 'http://localhost:3000';
|
||||
|
||||
|
||||
|
||||
export const DBIS_EXCHANGE_URL =
|
||||
|
||||
import.meta.env.VITE_DBIS_EXCHANGE_URL || 'http://localhost:3012';
|
||||
|
||||
|
||||
|
||||
export const SETTLEMENT_MIDDLEWARE_URL =
|
||||
|
||||
import.meta.env.VITE_SETTLEMENT_MIDDLEWARE_URL || 'http://localhost:3011';
|
||||
|
||||
|
||||
|
||||
export const ENHANCED_SWAP_ROUTER_V2 =
|
||||
|
||||
import.meta.env.VITE_ENHANCED_SWAP_ROUTER_V2 ||
|
||||
|
||||
'0xa421706768aeb7fafa2d912c5e10824ef3437ad4';
|
||||
|
||||
|
||||
|
||||
export const UNISWAP_V2_ROUTER_138 =
|
||||
|
||||
import.meta.env.VITE_UNISWAP_V2_ROUTER_138 ||
|
||||
|
||||
'0x3019A7fDc76ba7F64F18d78e66842760037ee638';
|
||||
|
||||
|
||||
|
||||
export type DexToken = {
|
||||
|
||||
symbol: string;
|
||||
|
||||
name: string;
|
||||
|
||||
address: string;
|
||||
|
||||
decimals: number;
|
||||
|
||||
native?: boolean;
|
||||
|
||||
omnlLine?: string;
|
||||
|
||||
moneyLayer?: string;
|
||||
|
||||
currencyCode?: string;
|
||||
|
||||
swappable?: boolean;
|
||||
|
||||
convertible?: boolean;
|
||||
|
||||
transferableInternal?: boolean;
|
||||
|
||||
transferableExternal?: boolean;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
/** Fallback when exchange API unavailable */
|
||||
31
frontend-dapp/src/config/supported-chains.ts
Normal file
31
frontend-dapp/src/config/supported-chains.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { SETTLEMENT_MIDDLEWARE_URL } from './dex';
|
||||
|
||||
export type OmnlChainSummary = {
|
||||
chainId: number;
|
||||
name: string;
|
||||
status: string;
|
||||
tier: string;
|
||||
omnlRole: string;
|
||||
nativeSymbol: string;
|
||||
settlement?: { swapEnabled?: boolean; bridgeEnabled?: boolean; m2LoadEnabled?: boolean };
|
||||
};
|
||||
|
||||
export type ChainRegistryResponse = {
|
||||
brand: string;
|
||||
maxCapacity: number;
|
||||
primaryChainId: number;
|
||||
mirrorChainId: number;
|
||||
stats?: { total: number; active: number; staged: number; reserved: number };
|
||||
chains: OmnlChainSummary[];
|
||||
};
|
||||
|
||||
export async function fetchOmnlChainRegistry(): Promise<ChainRegistryResponse | null> {
|
||||
try {
|
||||
const base = SETTLEMENT_MIDDLEWARE_URL.replace(/\/$/, '');
|
||||
const res = await fetch(`${base}/api/v1/settlement/chains`);
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
170
frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx
Normal file
170
frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useCentralBank } from './useCentralBank';
|
||||
import ChainMatrixPanel from './ChainMatrixPanel';
|
||||
import { SETTLEMENT_MIDDLEWARE_URL, DBIS_EXCHANGE_URL } from '../../config/dex';
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
accent?: 'gold' | 'green' | 'blue';
|
||||
}) {
|
||||
const border =
|
||||
accent === 'gold' ? 'border-l-[#f0b90b]' : accent === 'green' ? 'border-l-[#0ecb81]' : 'border-l-[#3861fb]';
|
||||
return (
|
||||
<div className={`cb-card border-l-4 ${border}`}>
|
||||
<p className="text-xs text-[#848e9c] uppercase tracking-wide mb-1">{label}</p>
|
||||
<p className="text-2xl font-semibold text-[#eaecef]">{value}</p>
|
||||
{sub && <p className="text-xs text-[#848e9c] mt-1">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CentralBankDashboard() {
|
||||
const cb = useCentralBank();
|
||||
const office = cb.office as {
|
||||
officeId?: number;
|
||||
name?: string;
|
||||
externalId?: string;
|
||||
settlement?: { moneyLayers?: string[]; glM0?: string; glM1?: string; glM2?: string };
|
||||
} | null;
|
||||
|
||||
return (
|
||||
<div className="cb-page max-w-[1400px] mx-auto px-4 py-6">
|
||||
<header className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-[#3861fb]/20 text-[#3861fb]">
|
||||
OMNL Central Bank
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-[#0ecb81]/20 text-[#0ecb81]">
|
||||
Production · 128 chains
|
||||
</span>
|
||||
<span className="text-xs text-[#848e9c]">Meta fiat · M0 / M1 / M2</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-[#eaecef]">Central Bank Operations</h1>
|
||||
<p className="text-[#848e9c] mt-1">
|
||||
Settlement orchestration · SWIFT · HYBX · Chain 138 token loading
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{cb.error && (
|
||||
<div className="mb-4 p-3 rounded bg-[#f6465d]/10 text-[#f6465d] text-sm">{cb.error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
label="M0 Reserve (GL 1050)"
|
||||
value={cb.moneySupply?.M0?.gl1050 ?? '—'}
|
||||
sub={`Backing ${cb.moneySupply?.M0?.backingRatio ?? 1.2}×`}
|
||||
accent="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="M1 Circulating (GL 2100)"
|
||||
value={cb.moneySupply?.M1?.circulating ?? '—'}
|
||||
sub="Narrow money · eMoney"
|
||||
accent="green"
|
||||
/>
|
||||
<StatCard
|
||||
label="M2 Broad (GL 2200)"
|
||||
value={cb.moneySupply?.M2?.broadMoney ?? '—'}
|
||||
sub="Token load source"
|
||||
accent="gold"
|
||||
/>
|
||||
<StatCard
|
||||
label="M2 tokens registered"
|
||||
value={String(cb.tokenCount)}
|
||||
sub="Swappable · convertible · transferable"
|
||||
accent="gold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!cb.moneySupply && (
|
||||
<p className="text-xs text-[#848e9c] mb-6">
|
||||
Set <code className="text-[#f0b90b]">VITE_OMNL_API_KEY</code> in .env.local to load live M0/M1/M2
|
||||
balances from Fineract.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<section className="cb-card">
|
||||
<h2 className="text-lg font-semibold text-[#eaecef] mb-4">Settlement service</h2>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[#848e9c]">Status</dt>
|
||||
<dd className="text-[#0ecb81] font-medium">{(cb.health as { status?: string })?.status ?? '—'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[#848e9c]">Role</dt>
|
||||
<dd className="text-[#eaecef] text-right max-w-[60%]">
|
||||
{(cb.health as { role?: string })?.role ?? '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[#848e9c]">API</dt>
|
||||
<dd className="text-[#f0b90b] text-xs truncate max-w-[60%]">{SETTLEMENT_MIDDLEWARE_URL}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 w-full py-2 rounded bg-[#2b3139] text-[#eaecef] text-sm hover:bg-[#474d57]"
|
||||
onClick={() => cb.refresh()}
|
||||
>
|
||||
{cb.loading ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="cb-card">
|
||||
<h2 className="text-lg font-semibold text-[#eaecef] mb-4">Money layers (Office {office?.officeId ?? 24})</h2>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{(office?.settlement?.moneyLayers ?? ['M0', 'M1', 'M2']).map((layer) => (
|
||||
<span key={layer} className="px-3 py-1 rounded-full bg-[#2b3139] text-[#f0b90b] text-xs font-medium">
|
||||
{layer}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[#848e9c]">GL M0</dt>
|
||||
<dd>{office?.settlement?.glM0 ?? '1050'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[#848e9c]">GL M1</dt>
|
||||
<dd>{office?.settlement?.glM1 ?? '2100'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-[#848e9c]">GL M2</dt>
|
||||
<dd>{office?.settlement?.glM2 ?? '2200'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="cb-card lg:col-span-2">
|
||||
<h2 className="text-lg font-semibold text-[#eaecef] mb-4">Recent exchange activity</h2>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{((cb.monitor as { swaps?: { status: string; tokenIn: string; createdAt: string }[] })?.swaps ?? [])
|
||||
.slice(0, 8)
|
||||
.map((s, i) => (
|
||||
<li key={i} className="flex justify-between py-2 border-b border-[#2b3139] last:border-0">
|
||||
<span className="text-[#848e9c]">{s.status}</span>
|
||||
<span className="text-[#eaecef] font-mono text-xs truncate max-w-[50%]">{s.tokenIn?.slice(0, 10)}…</span>
|
||||
<span className="text-[#848e9c] text-xs">{s.createdAt?.slice(0, 16)}</span>
|
||||
</li>
|
||||
))}
|
||||
{!((cb.monitor as { swaps?: unknown[] })?.swaps?.length) && (
|
||||
<li className="text-[#848e9c]">No swaps recorded yet</li>
|
||||
)}
|
||||
</ul>
|
||||
<p className="text-xs text-[#848e9c] mt-4">
|
||||
DBIS Exchange: {DBIS_EXCHANGE_URL} · Token-load & transfers via settlement middleware
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ChainMatrixPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend-dapp/src/features/central-bank/ChainMatrixPanel.tsx
Normal file
100
frontend-dapp/src/features/central-bank/ChainMatrixPanel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchOmnlChainRegistry, type ChainRegistryResponse, type OmnlChainSummary } from '../../config/supported-chains';
|
||||
|
||||
type Filter = 'all' | 'active' | 'primary' | 'l2';
|
||||
|
||||
export default function ChainMatrixPanel() {
|
||||
const [registry, setRegistry] = useState<ChainRegistryResponse | null>(null);
|
||||
const [filter, setFilter] = useState<Filter>('active');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchOmnlChainRegistry().then(setRegistry);
|
||||
}, []);
|
||||
|
||||
const chains = (registry?.chains ?? []).filter((c) => {
|
||||
if (filter === 'active' && c.status !== 'active') return false;
|
||||
if (filter === 'primary' && c.omnlRole !== 'settlement-hub' && c.omnlRole !== 'mirror-target') return false;
|
||||
if (filter === 'l2' && c.tier !== 'l2') return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return c.name.toLowerCase().includes(q) || String(c.chainId).includes(q) || c.nativeSymbol.toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="cb-card lg:col-span-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[#eaecef]">128-chain network matrix</h2>
|
||||
<p className="text-xs text-[#848e9c]">
|
||||
{registry?.stats?.active ?? 0} active · {registry?.stats?.staged ?? 0} staged ·{' '}
|
||||
{registry?.stats?.reserved ?? 0} reserved · primary {registry?.primaryChainId ?? 138}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chain…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="bg-[#2b3139] border border-[#474d57] rounded px-3 py-1.5 text-sm w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{(['all', 'active', 'primary', 'l2'] as Filter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 rounded text-xs capitalize ${
|
||||
filter === f ? 'bg-[#f0b90b] text-[#181a20]' : 'bg-[#2b3139] text-[#848e9c]'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto max-h-[360px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[#1e2329]">
|
||||
<tr className="text-[#848e9c] text-xs uppercase text-left">
|
||||
<th className="py-2 pr-3">ID</th>
|
||||
<th className="py-2 pr-3">Network</th>
|
||||
<th className="py-2 pr-3">Role</th>
|
||||
<th className="py-2 pr-3">Status</th>
|
||||
<th className="py-2 pr-3">Swap</th>
|
||||
<th className="py-2">Bridge</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{chains.map((c: OmnlChainSummary) => (
|
||||
<tr key={c.chainId} className="border-t border-[#2b3139] hover:bg-[#2b3139]/40">
|
||||
<td className="py-1.5 pr-3 font-mono text-[#f0b90b]">{c.chainId}</td>
|
||||
<td className="py-1.5 pr-3 text-[#eaecef]">{c.name}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-[#848e9c]">{c.omnlRole}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
c.status === 'active'
|
||||
? 'bg-[#0ecb81]/15 text-[#0ecb81]'
|
||||
: c.status === 'staged'
|
||||
? 'bg-[#f0b90b]/15 text-[#f0b90b]'
|
||||
: 'bg-[#848e9c]/15 text-[#848e9c]'
|
||||
}`}
|
||||
>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">{c.settlement?.swapEnabled ? '✓' : '—'}</td>
|
||||
<td className="py-1.5">{c.settlement?.bridgeEnabled ? '✓' : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
60
frontend-dapp/src/features/central-bank/useCentralBank.ts
Normal file
60
frontend-dapp/src/features/central-bank/useCentralBank.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { DBIS_EXCHANGE_URL, SETTLEMENT_MIDDLEWARE_URL } from '../../config/dex';
|
||||
|
||||
export type MoneySupply = {
|
||||
asOf?: string;
|
||||
officeId?: number;
|
||||
M0?: { gl1050?: string; backingRatio?: number };
|
||||
M1?: { circulating?: string; gl2100?: string };
|
||||
M2?: { broadMoney?: string; gl2200?: string; metaFiatPending?: string };
|
||||
};
|
||||
|
||||
export function useCentralBank() {
|
||||
const [health, setHealth] = useState<Record<string, unknown> | null>(null);
|
||||
const [office, setOffice] = useState<Record<string, unknown> | null>(null);
|
||||
const [moneySupply, setMoneySupply] = useState<MoneySupply | null>(null);
|
||||
const [tokenCount, setTokenCount] = useState(0);
|
||||
const [monitor, setMonitor] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const settlement = SETTLEMENT_MIDDLEWARE_URL.replace(/\/$/, '');
|
||||
const exchange = DBIS_EXCHANGE_URL.replace(/\/$/, '');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [h, o, m2, mon] = await Promise.all([
|
||||
fetch(`${settlement}/api/v1/settlement/health`).then((r) => r.json()),
|
||||
fetch(`${settlement}/api/v1/settlement/office`).then((r) => r.json()),
|
||||
fetch(`${settlement}/api/v1/settlement/tokens/m2`).then((r) => r.json()),
|
||||
fetch(`${exchange}/api/v1/exchange/swap/monitor?limit=10`).then((r) => r.json()),
|
||||
]);
|
||||
setHealth(h);
|
||||
setOffice(o);
|
||||
setTokenCount(Array.isArray(m2.tokens) ? m2.tokens.length : 0);
|
||||
setMonitor(mon);
|
||||
|
||||
const apiKey = import.meta.env.VITE_OMNL_API_KEY;
|
||||
if (apiKey) {
|
||||
const ms = await fetch(`${settlement}/api/v1/settlement/money-supply`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
if (ms.ok) setMoneySupply(await ms.json());
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load central bank data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [settlement, exchange]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const id = setInterval(refresh, 30000);
|
||||
return () => clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
return { health, office, moneySupply, tokenCount, monitor, loading, error, refresh };
|
||||
}
|
||||
123
frontend-dapp/src/features/office24/Office24Dashboard.tsx
Normal file
123
frontend-dapp/src/features/office24/Office24Dashboard.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useOffice24 } from './useOffice24';
|
||||
|
||||
export default function Office24Dashboard() {
|
||||
const { office, tokens, health, loading, refresh } = useOffice24();
|
||||
const profile = office as {
|
||||
officeId?: number;
|
||||
externalId?: string;
|
||||
name?: string;
|
||||
locked?: boolean;
|
||||
tenantUser?: string;
|
||||
settlement?: { defaultCurrency?: string; moneyLayers?: string[] };
|
||||
rails?: { swift?: { enabled?: boolean }; hybx?: { enabled?: boolean }; chain138?: { enabled?: boolean } };
|
||||
} | null;
|
||||
|
||||
return (
|
||||
<div className="o24-page max-w-[1400px] mx-auto px-4 py-6">
|
||||
<header className="mb-8 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-[#0ecb81]/20 text-[#0ecb81]">
|
||||
Office 24 · Locked
|
||||
</span>
|
||||
{profile?.locked && <span className="text-xs text-[#848e9c]">Single-office enforcement</span>}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-[#eaecef]">{profile?.name ?? 'HOSPITALLERS Ali Iraq-Iran'}</h1>
|
||||
<p className="text-[#848e9c] mt-1 font-mono text-sm">{profile?.externalId ?? 'HOSPITALLERS-ALI-IRAQ-IRAN'}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/trade"
|
||||
className="px-5 py-2.5 rounded bg-[#f0b90b] text-[#181a20] font-semibold text-sm hover:bg-[#fcd535]"
|
||||
>
|
||||
Open DBIS Trade →
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div className="o24-card">
|
||||
<p className="text-xs text-[#848e9c] uppercase">Office ID</p>
|
||||
<p className="text-2xl font-bold text-[#0ecb81]">{profile?.officeId ?? 24}</p>
|
||||
</div>
|
||||
<div className="o24-card">
|
||||
<p className="text-xs text-[#848e9c] uppercase">Default currency</p>
|
||||
<p className="text-2xl font-bold text-[#eaecef]">{profile?.settlement?.defaultCurrency ?? 'USD'}</p>
|
||||
</div>
|
||||
<div className="o24-card">
|
||||
<p className="text-xs text-[#848e9c] uppercase">M2 tradable tokens</p>
|
||||
<p className="text-2xl font-bold text-[#f0b90b]">{tokens.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<section className="o24-card lg:col-span-1">
|
||||
<h2 className="font-semibold text-[#eaecef] mb-4">Settlement rails</h2>
|
||||
<ul className="space-y-3 text-sm">
|
||||
<li className="flex justify-between">
|
||||
<span className="text-[#848e9c]">SWIFT</span>
|
||||
<span className={profile?.rails?.swift?.enabled ? 'text-[#0ecb81]' : 'text-[#848e9c]'}>
|
||||
{profile?.rails?.swift?.enabled ? 'Enabled' : 'Off'}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-[#848e9c]">HYBX</span>
|
||||
<span className={profile?.rails?.hybx?.enabled ? 'text-[#0ecb81]' : 'text-[#848e9c]'}>
|
||||
{profile?.rails?.hybx?.enabled ? 'Enabled' : 'Off'}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-[#848e9c]">Chain 138</span>
|
||||
<span className={profile?.rails?.chain138?.enabled ? 'text-[#0ecb81]' : 'text-[#848e9c]'}>
|
||||
{profile?.rails?.chain138?.enabled ? 'Enabled' : 'Off'}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-[#848e9c]">Exchange</span>
|
||||
<span className="text-[#0ecb81]">{(health as { status?: string })?.status ?? 'ok'}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs text-[#848e9c] mt-4">Tenant: {profile?.tenantUser ?? 'ali_hospitallers_tenant'}</p>
|
||||
</section>
|
||||
|
||||
<section className="o24-card lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-[#eaecef]">M2 token registry — swap · convert · transfer</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refresh()}
|
||||
className="text-xs text-[#f0b90b] hover:underline"
|
||||
>
|
||||
{loading ? '…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto max-h-[420px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[#1e2329]">
|
||||
<tr className="text-[#848e9c] text-left text-xs uppercase">
|
||||
<th className="py-2 pr-4">Symbol</th>
|
||||
<th className="py-2 pr-4">Line</th>
|
||||
<th className="py-2 pr-4">Swap</th>
|
||||
<th className="py-2 pr-4">Convert</th>
|
||||
<th className="py-2">Xfer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((t) => (
|
||||
<tr key={t.address} className="border-t border-[#2b3139] hover:bg-[#2b3139]/50">
|
||||
<td className="py-2 pr-4 font-medium text-[#eaecef]">{t.symbol}</td>
|
||||
<td className="py-2 pr-4 text-[#848e9c] text-xs">{t.omnlLine ?? 'M2'}</td>
|
||||
<td className="py-2 pr-4">{t.swappable !== false ? '✓' : '—'}</td>
|
||||
<td className="py-2 pr-4">{t.convertible !== false ? '✓' : '—'}</td>
|
||||
<td className="py-2 text-[#0ecb81]">
|
||||
{t.transferableInternal && t.transferableExternal ? 'Int+Ext' : '✓'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
frontend-dapp/src/features/office24/useOffice24.ts
Normal file
42
frontend-dapp/src/features/office24/useOffice24.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { DBIS_EXCHANGE_URL, SETTLEMENT_MIDDLEWARE_URL, type DexToken } from '../../config/dex';
|
||||
|
||||
export type M2Token = DexToken & {
|
||||
moneyLayer?: string;
|
||||
swappable?: boolean;
|
||||
convertible?: boolean;
|
||||
transferableInternal?: boolean;
|
||||
transferableExternal?: boolean;
|
||||
};
|
||||
|
||||
export function useOffice24() {
|
||||
const [office, setOffice] = useState<Record<string, unknown> | null>(null);
|
||||
const [tokens, setTokens] = useState<M2Token[]>([]);
|
||||
const [health, setHealth] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const settlement = SETTLEMENT_MIDDLEWARE_URL.replace(/\/$/, '');
|
||||
const exchange = DBIS_EXCHANGE_URL.replace(/\/$/, '');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [o, t, h] = await Promise.all([
|
||||
fetch(`${settlement}/api/v1/settlement/office`).then((r) => r.json()),
|
||||
fetch(`${settlement}/api/v1/settlement/tokens/m2`).then((r) => r.json()),
|
||||
fetch(`${exchange}/api/v1/exchange/health`).then((r) => r.json()),
|
||||
]);
|
||||
setOffice(o);
|
||||
setTokens(Array.isArray(t.tokens) ? t.tokens : []);
|
||||
setHealth(h);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [settlement, exchange]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { office, tokens, health, loading, refresh };
|
||||
}
|
||||
298
frontend-dapp/src/features/swap/DBISSwapPanel.tsx
Normal file
298
frontend-dapp/src/features/swap/DBISSwapPanel.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useDbisSwap } from './useDbisSwap';
|
||||
import { formatAmountFromWei } from '../../config/dex';
|
||||
|
||||
function TokenSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
tokens,
|
||||
}: {
|
||||
label: string;
|
||||
value: { symbol: string; address: string };
|
||||
onChange: (t: (typeof tokens)[0]) => void;
|
||||
tokens: ReturnType<typeof useDbisSwap>['tokens'];
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs ui-muted uppercase tracking-wide mb-1 block">{label}</span>
|
||||
<select
|
||||
className="ui-input w-full !min-h-[48px] font-semibold"
|
||||
value={value.address}
|
||||
onChange={(e) => {
|
||||
const t = tokens.find((x) => x.address === e.target.value);
|
||||
if (t) onChange(t);
|
||||
}}
|
||||
>
|
||||
{tokens.map((t) => (
|
||||
<option key={t.address} value={t.address}>
|
||||
{t.symbol} — {t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DBISSwapPanel() {
|
||||
const swap = useDbisSwap();
|
||||
|
||||
useEffect(() => {
|
||||
swap.loadMonitor();
|
||||
}, [swap.loadMonitor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (swap.isConnected) swap.loadLedgers();
|
||||
}, [swap.isConnected, swap.address, swap.loadLedgers]);
|
||||
|
||||
const outDisplay =
|
||||
swap.plan?.amountOut && swap.tokenOut
|
||||
? formatAmountFromWei(String(swap.plan.amountOut), swap.tokenOut.decimals)
|
||||
: swap.plan?.plannerResponse?.routePlan?.minAmountOut
|
||||
? formatAmountFromWei(String(swap.plan.plannerResponse.routePlan.minAmountOut), swap.tokenOut.decimals)
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="ui-card p-6 border border-[var(--ui-border)]">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold ui-accent-text">DBIS Exchange</h2>
|
||||
<span className="text-xs ui-muted px-2 py-1 ui-inset rounded">Office 24 · M2 · {swap.tokens.length} tokens</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<TokenSelect label="From" value={swap.tokenIn} onChange={swap.setTokenIn} tokens={swap.tokens} />
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary !rounded-full !p-2"
|
||||
onClick={() => {
|
||||
const a = swap.tokenIn;
|
||||
swap.setTokenIn(swap.tokenOut);
|
||||
swap.setTokenOut(a);
|
||||
}}
|
||||
aria-label="Swap tokens"
|
||||
>
|
||||
↕
|
||||
</button>
|
||||
</div>
|
||||
<TokenSelect label="To" value={swap.tokenOut} onChange={swap.setTokenOut} tokens={swap.tokens} />
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs ui-muted uppercase tracking-wide mb-1 block">Amount</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0.0"
|
||||
value={swap.amountIn}
|
||||
onChange={(e) => swap.setAmountIn(e.target.value)}
|
||||
className="ui-input w-full !min-h-[56px] text-2xl font-semibold"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="ui-muted">Slippage</span>
|
||||
{[25, 50, 100].map((bps) => (
|
||||
<button
|
||||
key={bps}
|
||||
type="button"
|
||||
className={`px-3 py-1 rounded text-xs font-medium ${
|
||||
swap.slippageBps === bps ? 'ui-btn-primary' : 'ui-btn-secondary'
|
||||
}`}
|
||||
onClick={() => swap.setSlippageBps(bps)}
|
||||
>
|
||||
{(bps / 100).toFixed(2)}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ui-inset p-4 rounded-lg">
|
||||
<p className="text-sm ui-muted mb-1">Expected output</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{outDisplay} <span className="text-base ui-accent-text">{swap.tokenOut.symbol}</span>
|
||||
</p>
|
||||
{swap.plan?.plannerResponse?.routePlan?.legs && (
|
||||
<p className="text-xs ui-muted mt-2">
|
||||
Route:{' '}
|
||||
{swap.plan.plannerResponse.routePlan.legs
|
||||
.map((l: { provider?: string }) => l.provider)
|
||||
.filter(Boolean)
|
||||
.join(' → ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{swap.error && <p className="text-sm text-[var(--ui-danger)]">{swap.error}</p>}
|
||||
{swap.txHash && (
|
||||
<p className="text-xs ui-muted break-all">
|
||||
Tx:{' '}
|
||||
<a
|
||||
href={`https://explorer.d-bis.org/tx/${swap.txHash}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ui-accent-text hover:underline"
|
||||
>
|
||||
{swap.txHash}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary flex-1 !min-h-[48px]"
|
||||
disabled={swap.loading || !swap.amountIn}
|
||||
onClick={() => swap.fetchPlan()}
|
||||
>
|
||||
{swap.loading ? '…' : 'Get quote'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-primary flex-1 !min-h-[48px] font-bold"
|
||||
disabled={swap.loading || !swap.isConnected || !swap.amountIn}
|
||||
onClick={() => swap.executeSwap()}
|
||||
>
|
||||
{!swap.isConnected ? 'Connect wallet' : swap.loading ? 'Swapping…' : 'Swap'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="ui-card p-4">
|
||||
<h3 className="font-semibold mb-3 text-sm ui-accent-text">OMNL Office 24 ledger</h3>
|
||||
{!swap.isConnected ? (
|
||||
<p className="text-sm ui-muted">Connect wallet for crypto balances</p>
|
||||
) : swap.ledger ? (
|
||||
<div className="text-xs space-y-2">
|
||||
<p>
|
||||
<span className="ui-muted">M1 circulating:</span>{' '}
|
||||
{(swap.ledger as { fundLedger?: { M1?: { circulating?: string } } }).fundLedger?.M1?.circulating ?? '—'}
|
||||
</p>
|
||||
<p>
|
||||
<span className="ui-muted">M2 broad:</span>{' '}
|
||||
{(swap.ledger as { fundLedger?: { M2?: { broadMoney?: string } } }).fundLedger?.M2?.broadMoney ?? '—'}
|
||||
</p>
|
||||
{(swap.ledger as { cryptoLedger?: { balances?: Record<string, string> } }).cryptoLedger?.balances && (
|
||||
<div className="mt-2 pt-2 border-t border-[var(--ui-border)]">
|
||||
<p className="ui-muted mb-1">Wallet balances</p>
|
||||
{Object.entries(
|
||||
(swap.ledger as { cryptoLedger: { balances: Record<string, string> } }).cryptoLedger.balances,
|
||||
).map(([sym, bal]) => (
|
||||
<p key={sym}>
|
||||
{sym}: {Number(bal).toFixed(4)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="ui-btn-secondary text-xs w-full" onClick={() => swap.loadLedgers()}>
|
||||
Load ledgers
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ui-card p-4">
|
||||
<h3 className="font-semibold mb-3 text-sm ui-accent-text">M2 token load</h3>
|
||||
<p className="text-xs ui-muted mb-3">Load from M2 fiat (GL 2200) → on-chain · swappable · convertible</p>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="Amount (fiat)"
|
||||
value={swap.loadAmount}
|
||||
onChange={(e) => swap.setLoadAmount(e.target.value)}
|
||||
className="ui-input w-full mb-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-primary text-xs w-full !min-h-[40px]"
|
||||
disabled={swap.loading || !swap.isConnected || !swap.loadAmount}
|
||||
onClick={() => swap.loadM2Token()}
|
||||
>
|
||||
Load {swap.tokenOut.symbol} from M2
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ui-card p-4">
|
||||
<h3 className="font-semibold mb-3 text-sm">Transfer</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Recipient (0x… or address)"
|
||||
value={swap.transferTo}
|
||||
onChange={(e) => swap.setTransferTo(e.target.value)}
|
||||
className="ui-input w-full mb-2 text-sm font-mono"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="Amount"
|
||||
value={swap.transferAmount}
|
||||
onChange={(e) => swap.setTransferAmount(e.target.value)}
|
||||
className="ui-input w-full mb-2 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary text-xs flex-1 !min-h-[36px]"
|
||||
disabled={swap.loading || !swap.isConnected}
|
||||
onClick={() => swap.internalTransfer()}
|
||||
>
|
||||
Internal
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="External IBAN (SWIFT)"
|
||||
value={swap.externalIban}
|
||||
onChange={(e) => swap.setExternalIban(e.target.value)}
|
||||
className="ui-input w-full mb-2 text-sm font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary text-xs w-full !min-h-[36px]"
|
||||
disabled={swap.loading || !swap.isConnected || !swap.externalIban}
|
||||
onClick={() => swap.externalTransfer()}
|
||||
>
|
||||
External (SWIFT)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{swap.lastSettlement && (
|
||||
<div className="ui-card p-4">
|
||||
<h3 className="font-semibold mb-2 text-sm">Last settlement</h3>
|
||||
<p className="text-xs ui-muted break-all">
|
||||
{(swap.lastSettlement as { phase?: string }).phase ?? 'OK'} ·{' '}
|
||||
{(swap.lastSettlement as { settlementId?: string }).settlementId ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ui-card p-4">
|
||||
<h3 className="font-semibold mb-3 text-sm">Swap monitor</h3>
|
||||
{swap.monitor ? (
|
||||
<div className="text-xs space-y-2">
|
||||
<p>
|
||||
Total: {(swap.monitor as { stats?: { total?: number } }).stats?.total ?? 0} · 24h:{' '}
|
||||
{(swap.monitor as { stats?: { last24h?: number } }).stats?.last24h ?? 0}
|
||||
</p>
|
||||
<ul className="max-h-40 overflow-y-auto space-y-1">
|
||||
{((swap.monitor as { swaps?: { tokenIn: string; status: string; createdAt: string }[] }).swaps ?? [])
|
||||
.slice(0, 5)
|
||||
.map((s, i) => (
|
||||
<li key={i} className="ui-muted truncate">
|
||||
{s.status} · {s.createdAt.slice(0, 16)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs ui-muted">Loading…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
297
frontend-dapp/src/features/swap/useDbisSwap.ts
Normal file
297
frontend-dapp/src/features/swap/useDbisSwap.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAccount, useSendTransaction } from 'wagmi';
|
||||
import {
|
||||
DBIS_EXCHANGE_URL,
|
||||
DEFAULT_TOKENS,
|
||||
fetchExchangeTokens,
|
||||
parseAmountToWei,
|
||||
type DexToken,
|
||||
} from '../../config/dex';
|
||||
|
||||
export type SwapPlan = {
|
||||
amountOut?: string;
|
||||
plannerResponse?: {
|
||||
routePlan?: { minAmountOut?: string; legs?: { provider?: string }[] };
|
||||
};
|
||||
execution?: { contractAddress: string; encodedCalldata: string };
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function useDbisSwap() {
|
||||
const { address, isConnected } = useAccount();
|
||||
const { sendTransactionAsync } = useSendTransaction();
|
||||
const [tokens, setTokens] = useState<DexToken[]>(DEFAULT_TOKENS);
|
||||
const [tokenIn, setTokenIn] = useState<DexToken>(DEFAULT_TOKENS[4]);
|
||||
const [tokenOut, setTokenOut] = useState<DexToken>(DEFAULT_TOKENS[5]);
|
||||
const [amountIn, setAmountIn] = useState('');
|
||||
const [slippageBps, setSlippageBps] = useState(50);
|
||||
const [plan, setPlan] = useState<SwapPlan | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [txHash, setTxHash] = useState<string | undefined>();
|
||||
const [ledger, setLedger] = useState<Record<string, unknown> | null>(null);
|
||||
const [monitor, setMonitor] = useState<Record<string, unknown> | null>(null);
|
||||
const [transferTo, setTransferTo] = useState('');
|
||||
const [transferAmount, setTransferAmount] = useState('');
|
||||
const [externalIban, setExternalIban] = useState('');
|
||||
const [loadAmount, setLoadAmount] = useState('');
|
||||
const [lastSettlement, setLastSettlement] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
const baseUrl = DBIS_EXCHANGE_URL.replace(/\/$/, '');
|
||||
|
||||
useEffect(() => {
|
||||
fetchExchangeTokens().then((list) => {
|
||||
setTokens(list);
|
||||
setTokenIn(list.find((t) => t.symbol === 'cUSDT') ?? list[4] ?? list[0]);
|
||||
setTokenOut(list.find((t) => t.symbol === 'cUSDC') ?? list[5] ?? list[1]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadLedgers = useCallback(async () => {
|
||||
if (!address) return;
|
||||
try {
|
||||
const q = new URLSearchParams({ wallet: address });
|
||||
const res = await fetch(`${baseUrl}/api/v1/exchange/ledgers?${q}`);
|
||||
if (res.ok) setLedger(await res.json());
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}, [address, baseUrl]);
|
||||
|
||||
const fetchQuote = useCallback(async () => {
|
||||
if (!amountIn) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const wei = parseAmountToWei(amountIn, tokenIn.decimals);
|
||||
const q = new URLSearchParams({
|
||||
tokenIn: tokenIn.address,
|
||||
tokenOut: tokenOut.address,
|
||||
amountIn: wei,
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/api/v1/exchange/quote?${q}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const quote = await res.json();
|
||||
setPlan({ amountOut: quote.amountOut ?? quote.expectedOutput });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Quote failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [amountIn, tokenIn, tokenOut, baseUrl]);
|
||||
|
||||
const fetchPlan = useCallback(async () => {
|
||||
if (!amountIn || !address) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const wei = parseAmountToWei(amountIn, tokenIn.decimals);
|
||||
const res = await fetch(`${baseUrl}/api/v1/exchange/execute-plan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tokenIn: tokenIn.address,
|
||||
tokenOut: tokenOut.address,
|
||||
amountIn: wei,
|
||||
recipient: address,
|
||||
maxSlippageBps: slippageBps,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Plan failed');
|
||||
setPlan(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Plan failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [amountIn, tokenIn, tokenOut, address, slippageBps, baseUrl]);
|
||||
|
||||
const settleSwap = useCallback(
|
||||
async (hash: string) => {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/exchange/swap/settle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: amountIn,
|
||||
recipientAddress: address,
|
||||
currency: tokenIn.currencyCode ?? 'USD',
|
||||
tokenLineId: tokenOut.omnlLine ?? `${tokenOut.symbol}-M2`,
|
||||
tokenSymbol: tokenOut.symbol,
|
||||
remittanceInfo: `Swap ${tokenIn.symbol}→${tokenOut.symbol} tx ${hash}`,
|
||||
}),
|
||||
});
|
||||
if (res.ok) setLastSettlement(await res.json());
|
||||
} catch {
|
||||
/* optional M2 audit */
|
||||
}
|
||||
},
|
||||
[baseUrl, amountIn, address, tokenIn, tokenOut],
|
||||
);
|
||||
|
||||
const executeSwap = useCallback(async () => {
|
||||
if (!plan?.execution || !address) {
|
||||
await fetchPlan();
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const hash = await sendTransactionAsync({
|
||||
to: plan.execution.contractAddress as `0x${string}`,
|
||||
data: plan.execution.encodedCalldata as `0x${string}`,
|
||||
});
|
||||
setTxHash(hash);
|
||||
await fetch(`${baseUrl}/api/v1/exchange/swap/record`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tokenIn: tokenIn.address,
|
||||
tokenOut: tokenOut.address,
|
||||
amountIn: parseAmountToWei(amountIn, tokenIn.decimals),
|
||||
wallet: address,
|
||||
txHash: hash,
|
||||
status: 'SUBMITTED',
|
||||
routeProviders: plan.plannerResponse?.routePlan?.legs?.map((l) => l.provider).filter(Boolean),
|
||||
}),
|
||||
});
|
||||
await settleSwap(hash);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Swap failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [plan, address, sendTransactionAsync, fetchPlan, tokenIn, tokenOut, amountIn, baseUrl, settleSwap]);
|
||||
|
||||
const loadM2Token = useCallback(async () => {
|
||||
if (!address || !loadAmount) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/exchange/token-load`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
idempotencyKey: `LOAD-${Date.now()}`,
|
||||
tokenAddress: tokenOut.address,
|
||||
symbol: tokenOut.symbol,
|
||||
lineId: tokenOut.omnlLine ?? 'USD-M2',
|
||||
amount: loadAmount,
|
||||
recipientAddress: address,
|
||||
currency: tokenOut.currencyCode ?? 'USD',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || data.errors?.join(', ') || 'Token load failed');
|
||||
setLastSettlement(data);
|
||||
await loadLedgers();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'M2 token load failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [address, loadAmount, tokenOut, baseUrl, loadLedgers]);
|
||||
|
||||
const internalTransfer = useCallback(async () => {
|
||||
if (!address || !transferTo || !transferAmount) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/exchange/transfer/internal`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
idempotencyKey: `XFER-INT-${Date.now()}`,
|
||||
tokenAddress: tokenIn.address,
|
||||
tokenSymbol: tokenIn.symbol,
|
||||
amount: transferAmount,
|
||||
recipientAddress: transferTo,
|
||||
senderAddress: address,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || data.errors?.join(', ') || 'Transfer failed');
|
||||
setLastSettlement(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Internal transfer failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [address, transferTo, transferAmount, tokenIn, baseUrl]);
|
||||
|
||||
const externalTransfer = useCallback(async () => {
|
||||
if (!address || !transferTo || !transferAmount || !externalIban) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/exchange/transfer/external`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
idempotencyKey: `XFER-EXT-${Date.now()}`,
|
||||
tokenAddress: tokenIn.address,
|
||||
tokenSymbol: tokenIn.symbol,
|
||||
amount: transferAmount,
|
||||
recipientAddress: transferTo,
|
||||
creditorIban: externalIban,
|
||||
remittanceInfo: `OMNL M2 external ${tokenIn.symbol}`,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || data.errors?.join(', ') || 'External transfer failed');
|
||||
setLastSettlement(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'External transfer failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [address, transferTo, transferAmount, externalIban, tokenIn, baseUrl]);
|
||||
|
||||
const loadMonitor = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/exchange/swap/monitor?limit=20`);
|
||||
if (res.ok) setMonitor(await res.json());
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}, [baseUrl]);
|
||||
|
||||
return {
|
||||
address,
|
||||
isConnected,
|
||||
tokens,
|
||||
tokenIn,
|
||||
setTokenIn,
|
||||
tokenOut,
|
||||
setTokenOut,
|
||||
amountIn,
|
||||
setAmountIn,
|
||||
slippageBps,
|
||||
setSlippageBps,
|
||||
plan,
|
||||
loading,
|
||||
error,
|
||||
txHash,
|
||||
ledger,
|
||||
monitor,
|
||||
transferTo,
|
||||
setTransferTo,
|
||||
transferAmount,
|
||||
setTransferAmount,
|
||||
externalIban,
|
||||
setExternalIban,
|
||||
loadAmount,
|
||||
setLoadAmount,
|
||||
lastSettlement,
|
||||
fetchQuote,
|
||||
fetchPlan,
|
||||
executeSwap,
|
||||
loadM2Token,
|
||||
internalTransfer,
|
||||
externalTransfer,
|
||||
loadLedgers,
|
||||
loadMonitor,
|
||||
parseAmountToWei,
|
||||
};
|
||||
}
|
||||
325
frontend-dapp/src/features/trade/BinanceTradePanel.tsx
Normal file
325
frontend-dapp/src/features/trade/BinanceTradePanel.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useDbisSwap } from '../swap/useDbisSwap';
|
||||
import { formatAmountFromWei, type DexToken } from '../../config/dex';
|
||||
import TokenIcon from '../../components/ui/TokenIcon';
|
||||
|
||||
type Side = 'buy' | 'sell';
|
||||
|
||||
function MarketRow({
|
||||
token,
|
||||
quote,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
token: DexToken;
|
||||
quote: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`trade-market-row w-full text-left px-3 py-2 flex justify-between items-center text-sm ${
|
||||
active ? 'trade-market-row--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-[#eaecef]">
|
||||
{token.symbol}
|
||||
<span className="text-[#848e9c]">/{quote}</span>
|
||||
</span>
|
||||
<span className="text-[#0ecb81] text-xs">M2</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderBookMock({ price }: { price: string }) {
|
||||
const base = parseFloat(price) || 1;
|
||||
const rows = Array.from({ length: 8 }, (_, i) => {
|
||||
const p = base * (1 + (4 - i) * 0.001);
|
||||
const amt = (Math.random() * 2 + 0.1).toFixed(4);
|
||||
return { price: p.toFixed(4), amount: amt, side: i < 4 ? 'ask' : 'bid' };
|
||||
});
|
||||
return (
|
||||
<div className="trade-orderbook text-xs font-mono">
|
||||
<div className="flex justify-between text-[#848e9c] px-2 py-1 border-b border-[#2b3139]">
|
||||
<span>Price</span>
|
||||
<span>Amount</span>
|
||||
</div>
|
||||
{rows.slice(0, 4).map((r) => (
|
||||
<div key={`a-${r.price}`} className="flex justify-between px-2 py-0.5 text-[#f6465d]">
|
||||
<span>{r.price}</span>
|
||||
<span>{r.amount}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-center py-2 text-lg font-semibold text-[#0ecb81] border-y border-[#2b3139] my-1">
|
||||
{price || '—'}
|
||||
</div>
|
||||
{rows.slice(4).map((r) => (
|
||||
<div key={`b-${r.price}`} className="flex justify-between px-2 py-0.5 text-[#0ecb81]">
|
||||
<span>{r.price}</span>
|
||||
<span>{r.amount}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BinanceTradePanel() {
|
||||
const swap = useDbisSwap();
|
||||
const [side, setSide] = useState<Side>('buy');
|
||||
const [marketSearch, setMarketSearch] = useState('');
|
||||
const [orderType, setOrderType] = useState<'market' | 'limit'>('market');
|
||||
|
||||
useEffect(() => {
|
||||
swap.loadMonitor();
|
||||
}, [swap.loadMonitor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (swap.isConnected) swap.loadLedgers();
|
||||
}, [swap.isConnected, swap.address, swap.loadLedgers]);
|
||||
|
||||
const quoteSymbol = 'cUSDT';
|
||||
const quoteToken = swap.tokens.find((t) => t.symbol === quoteSymbol) ?? swap.tokens.find((t) => t.symbol.includes('USDT'));
|
||||
|
||||
const markets = useMemo(() => {
|
||||
const q = marketSearch.trim().toLowerCase();
|
||||
return swap.tokens.filter(
|
||||
(t) =>
|
||||
!t.native &&
|
||||
t.symbol !== quoteToken?.symbol &&
|
||||
(!q || t.symbol.toLowerCase().includes(q) || t.name.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [swap.tokens, marketSearch, quoteToken]);
|
||||
|
||||
const selectMarket = (base: DexToken) => {
|
||||
if (side === 'buy') {
|
||||
swap.setTokenIn(quoteToken ?? swap.tokenIn);
|
||||
swap.setTokenOut(base);
|
||||
} else {
|
||||
swap.setTokenIn(base);
|
||||
swap.setTokenOut(quoteToken ?? swap.tokenOut);
|
||||
}
|
||||
};
|
||||
|
||||
const outDisplay =
|
||||
swap.plan?.amountOut && swap.tokenOut
|
||||
? formatAmountFromWei(String(swap.plan.amountOut), swap.tokenOut.decimals)
|
||||
: '—';
|
||||
|
||||
const displayPrice =
|
||||
swap.amountIn && outDisplay !== '—'
|
||||
? (parseFloat(outDisplay) / parseFloat(swap.amountIn || '1')).toFixed(6)
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<div className="trade-layout">
|
||||
{/* Markets sidebar */}
|
||||
<aside className="trade-markets hidden lg:flex flex-col border-r border-[#2b3139] bg-[#181a20]">
|
||||
<div className="p-3 border-b border-[#2b3139]">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search markets"
|
||||
value={marketSearch}
|
||||
onChange={(e) => setMarketSearch(e.target.value)}
|
||||
className="w-full bg-[#2b3139] border border-[#474d57] rounded px-3 py-2 text-sm text-[#eaecef] placeholder:text-[#848e9c]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<p className="px-3 py-2 text-xs text-[#848e9c] uppercase">M2 · {markets.length} pairs</p>
|
||||
{markets.map((t) => (
|
||||
<MarketRow
|
||||
key={t.address}
|
||||
token={t}
|
||||
quote={quoteToken?.symbol ?? 'USDT'}
|
||||
active={swap.tokenOut.symbol === t.symbol || swap.tokenIn.symbol === t.symbol}
|
||||
onClick={() => selectMarket(t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Chart + order book */}
|
||||
<section className="trade-center flex flex-col min-w-0">
|
||||
<div className="trade-pair-header flex flex-wrap items-center gap-4 px-4 py-3 border-b border-[#2b3139] bg-[#181a20]">
|
||||
<div className="flex items-center gap-2">
|
||||
<TokenIcon symbol={swap.tokenOut.symbol} size={28} />
|
||||
<h1 className="text-xl font-bold text-[#eaecef]">
|
||||
{swap.tokenOut.symbol}/{quoteToken?.symbol ?? 'USDT'}
|
||||
</h1>
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-[#0ecb81]">{displayPrice}</span>
|
||||
<span className="text-xs px-2 py-1 rounded bg-[#0ecb81]/10 text-[#0ecb81]">Office 24 · Chain 138</span>
|
||||
<span className="text-xs text-[#848e9c]">37 M2 tokens · tradable & swappable</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 xl:grid-cols-[1fr_280px] min-h-[320px]">
|
||||
<div className="trade-chart bg-[#0b0e11] border-b xl:border-b-0 xl:border-r border-[#2b3139] p-4">
|
||||
<div className="h-full min-h-[240px] rounded border border-[#2b3139] bg-[#181a20] flex items-end justify-around px-2 pb-2 gap-1">
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 max-w-[12px] rounded-t opacity-80"
|
||||
style={{
|
||||
height: `${30 + Math.sin(i * 0.5) * 20 + Math.random() * 25}%`,
|
||||
background: i % 3 === 0 ? '#f6465d' : '#0ecb81',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-[#848e9c] mt-2 text-center">Live chart · connect WebSocket feed for production</p>
|
||||
</div>
|
||||
<div className="trade-book hidden xl:block bg-[#181a20] p-2">
|
||||
<p className="text-xs text-[#848e9c] uppercase px-2 py-1">Order book</p>
|
||||
<OrderBookMock price={displayPrice} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="trade-recent border-t border-[#2b3139] bg-[#181a20] p-3 max-h-[140px] overflow-y-auto">
|
||||
<p className="text-xs text-[#848e9c] uppercase mb-2">Recent trades</p>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs font-mono text-[#848e9c]">
|
||||
{((swap.monitor as { swaps?: { status: string; amountIn: string; createdAt: string }[] })?.swaps ?? [])
|
||||
.slice(0, 6)
|
||||
.map((s, i) => (
|
||||
<span key={i} className="col-span-3 flex justify-between">
|
||||
<span className={s.status === 'FAILED' ? 'text-[#f6465d]' : 'text-[#0ecb81]'}>{s.status}</span>
|
||||
<span>{s.amountIn?.slice(0, 12)}</span>
|
||||
<span>{s.createdAt?.slice(11, 19)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Trade panel */}
|
||||
<aside className="trade-panel w-full lg:w-[320px] shrink-0 border-l border-[#2b3139] bg-[#181a20] flex flex-col">
|
||||
<div className="flex border-b border-[#2b3139]">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-3 text-sm font-semibold ${side === 'buy' ? 'text-[#0ecb81] border-b-2 border-[#0ecb81]' : 'text-[#848e9c]'}`}
|
||||
onClick={() => {
|
||||
setSide('buy');
|
||||
if (quoteToken) swap.setTokenIn(quoteToken);
|
||||
}}
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-3 text-sm font-semibold ${side === 'sell' ? 'text-[#f6465d] border-b-2 border-[#f6465d]' : 'text-[#848e9c]'}`}
|
||||
onClick={() => {
|
||||
setSide('sell');
|
||||
if (quoteToken) swap.setTokenOut(quoteToken);
|
||||
}}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex-1 flex flex-col gap-4">
|
||||
<div className="flex gap-2 text-xs">
|
||||
{(['market', 'limit'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setOrderType(t)}
|
||||
className={`px-3 py-1 rounded capitalize ${orderType === t ? 'bg-[#2b3139] text-[#f0b90b]' : 'text-[#848e9c]'}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-[#848e9c]">Pay with</span>
|
||||
<select
|
||||
className="w-full mt-1 bg-[#2b3139] border border-[#474d57] rounded px-3 py-2 text-sm"
|
||||
value={swap.tokenIn.address}
|
||||
onChange={(e) => {
|
||||
const t = swap.tokens.find((x) => x.address === e.target.value);
|
||||
if (t) swap.setTokenIn(t);
|
||||
}}
|
||||
>
|
||||
{swap.tokens.map((t) => (
|
||||
<option key={t.address} value={t.address}>
|
||||
{t.symbol}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-[#848e9c]">Receive</span>
|
||||
<select
|
||||
className="w-full mt-1 bg-[#2b3139] border border-[#474d57] rounded px-3 py-2 text-sm"
|
||||
value={swap.tokenOut.address}
|
||||
onChange={(e) => {
|
||||
const t = swap.tokens.find((x) => x.address === e.target.value);
|
||||
if (t) swap.setTokenOut(t);
|
||||
}}
|
||||
>
|
||||
{swap.tokens.map((t) => (
|
||||
<option key={t.address} value={t.address}>
|
||||
{t.symbol}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-[#848e9c]">Amount</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={swap.amountIn}
|
||||
onChange={(e) => swap.setAmountIn(e.target.value)}
|
||||
className="w-full mt-1 bg-[#2b3139] border border-[#474d57] rounded px-3 py-3 text-lg font-semibold"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="text-sm text-[#848e9c]">
|
||||
Est. receive: <span className="text-[#eaecef] font-medium">{outDisplay} {swap.tokenOut.symbol}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{[25, 50, 100].map((bps) => (
|
||||
<button
|
||||
key={bps}
|
||||
type="button"
|
||||
onClick={() => swap.setSlippageBps(bps)}
|
||||
className={`flex-1 py-1 text-xs rounded ${swap.slippageBps === bps ? 'bg-[#f0b90b] text-[#181a20]' : 'bg-[#2b3139] text-[#848e9c]'}`}
|
||||
>
|
||||
{(bps / 100).toFixed(1)}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{swap.error && <p className="text-xs text-[#f6465d]">{swap.error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={swap.loading || !swap.isConnected || !swap.amountIn}
|
||||
onClick={() => swap.executeSwap()}
|
||||
className={`w-full py-3 rounded font-bold text-[#181a20] ${
|
||||
side === 'buy' ? 'bg-[#0ecb81] hover:opacity-90' : 'bg-[#f6465d] hover:opacity-90'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{!swap.isConnected ? 'Connect wallet' : swap.loading ? 'Processing…' : side === 'buy' ? 'Buy' : 'Sell'}
|
||||
</button>
|
||||
|
||||
{swap.txHash && (
|
||||
<a
|
||||
href={`https://explorer.d-bis.org/tx/${swap.txHash}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-[#f0b90b] break-all hover:underline"
|
||||
>
|
||||
Tx: {swap.txHash.slice(0, 18)}…
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend-dapp/src/pages/CentralBankPage.tsx
Normal file
5
frontend-dapp/src/pages/CentralBankPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import CentralBankDashboard from '../features/central-bank/CentralBankDashboard';
|
||||
|
||||
export default function CentralBankPage() {
|
||||
return <CentralBankDashboard />;
|
||||
}
|
||||
5
frontend-dapp/src/pages/Office24Page.tsx
Normal file
5
frontend-dapp/src/pages/Office24Page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import Office24Dashboard from '../features/office24/Office24Dashboard';
|
||||
|
||||
export default function Office24Page() {
|
||||
return <Office24Dashboard />;
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import PageShell from '../components/ui/PageShell'
|
||||
import DBISSwapPanel from '../features/swap/DBISSwapPanel'
|
||||
|
||||
export default function SwapPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Swap</h1>
|
||||
<p className="text-gray-600">DEX swap interface coming soon...</p>
|
||||
</div>
|
||||
<PageShell
|
||||
title="DBIS Exchange"
|
||||
subtitle="Classic swap UI · use DBIS Trade for Binance-style terminal"
|
||||
>
|
||||
<DBISSwapPanel />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
5
frontend-dapp/src/pages/TradePage.tsx
Normal file
5
frontend-dapp/src/pages/TradePage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import BinanceTradePanel from '../features/trade/BinanceTradePanel';
|
||||
|
||||
export default function TradePage() {
|
||||
return <BinanceTradePanel />;
|
||||
}
|
||||
58
frontend-dapp/src/styles/omnl-apps.css
Normal file
58
frontend-dapp/src/styles/omnl-apps.css
Normal file
@@ -0,0 +1,58 @@
|
||||
/* OMNL product UIs — Central Bank, Office 24, Binance-style trade */
|
||||
|
||||
.cb-page,
|
||||
.o24-page {
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.cb-card,
|
||||
.o24-card {
|
||||
background: #1e2329;
|
||||
border: 1px solid #2b3139;
|
||||
border-radius: 4px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* Binance-style trade terminal */
|
||||
.trade-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
min-height: calc(100vh - 56px);
|
||||
background: #0b0e11;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.trade-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.trade-layout {
|
||||
grid-template-columns: 240px 1fr 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.trade-center {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trade-market-row:hover {
|
||||
background: #2b3139;
|
||||
}
|
||||
|
||||
.trade-market-row--active {
|
||||
background: #2b3139;
|
||||
border-left: 2px solid #f0b90b;
|
||||
}
|
||||
|
||||
.trade-orderbook {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.omnl-app-header {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
Reference in New Issue
Block a user