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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-28 08:44:42 -07:00
parent 2b1a3ba6af
commit 5dd67e2b1d
113 changed files with 13223 additions and 98 deletions

View File

@@ -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

View File

@@ -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>
);

View 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>
);
}

View 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 */

View 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;
}
}

View 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 &amp; transfers via settlement middleware
</p>
</section>
<ChainMatrixPanel />
</div>
</div>
);
}

View 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>
);
}

View 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 };
}

View 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>
);
}

View 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 };
}

View 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>
);
}

View 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,
};
}

View 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 &amp; 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>
);
}

View File

@@ -0,0 +1,5 @@
import CentralBankDashboard from '../features/central-bank/CentralBankDashboard';
export default function CentralBankPage() {
return <CentralBankDashboard />;
}

View File

@@ -0,0 +1,5 @@
import Office24Dashboard from '../features/office24/Office24Dashboard';
export default function Office24Page() {
return <Office24Dashboard />;
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,5 @@
import BinanceTradePanel from '../features/trade/BinanceTradePanel';
export default function TradePage() {
return <BinanceTradePanel />;
}

View 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);
}