feat: OMNL Bank online production - 128 chains, Central Bank UI, settlement stack
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m29s
CI/CD Pipeline / Security Scanning (push) Successful in 3m36s
CI/CD Pipeline / Lint and Format (push) Failing after 47s
CI/CD Pipeline / Terraform Validation (push) Failing after 32s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 32s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 52s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 26s
Validation / validate-genesis (push) Successful in 28s
Validation / validate-terraform (push) Failing after 45s
Validation / validate-kubernetes (push) Failing after 17s
Validation / validate-smart-contracts (push) Failing after 17s
Validation / validate-security (push) Failing after 1m53s
Validation / validate-documentation (push) Failing after 20s
Verify Deployment / Verify Deployment (push) Failing after 1m22s

This commit is contained in:
2026-07-05 07:46:06 -07:00
parent ecd6120c22
commit c2af150753
73 changed files with 6319 additions and 576 deletions

View File

@@ -6,7 +6,7 @@
"primaryChainId": 138,
"mirrorChainId": 651940,
"settlementOfficeId": 24,
"generatedAt": "2026-06-28T15:22:27.822Z",
"generatedAt": "2026-07-05T14:44:58.371Z",
"chains": [
{
"chainId": 138,
@@ -1224,23 +1224,20 @@
},
{
"chainId": 900001,
"name": "Shiva Blockchain (OneX)",
"name": "OMNL Expansion Slot 1",
"type": "evm",
"nativeSymbol": "SHIVA",
"tier": "primary",
"omnlRole": "onex-shiva",
"status": "active",
"nativeSymbol": "ETH",
"tier": "reserved",
"omnlRole": "expansion",
"status": "reserved",
"rpcEnv": "CHAIN_900001_RPC_URL",
"explorerUrl": "https://explorer.shiva.onex.omdnl.org",
"rpcDefault": "https://rpc.shiva.onex.omdnl.org",
"settlement": {
"m2LoadEnabled": true,
"swapEnabled": true,
"bridgeEnabled": true,
"officeId": 29
"m2LoadEnabled": false,
"swapEnabled": false,
"bridgeEnabled": false,
"officeId": 24
},
"complianceEnv": "OMNL_COMPLIANCE_CORE_900001",
"integrationRef": "config/onex-shiva-integration.v1.json"
"complianceEnv": "OMNL_COMPLIANCE_CORE_900001"
},
{
"chainId": 900002,

View File

@@ -46,6 +46,7 @@
"requireApiKey": true,
"allowHybxProduction": true,
"allowChainMintExecute": true,
"allowChainBridgeExecute": true,
"onlineBankMode": true
},
"guosmm": {

View File

@@ -38,6 +38,7 @@
"production": {
"requireApiKey": true,
"allowHybxProduction": false,
"allowChainMintExecute": false
"allowChainMintExecute": false,
"allowChainBridgeExecute": false
}
}

View File

@@ -0,0 +1,74 @@
{
"$schema": "zBank consumer account — Revolut-parity profile, IBAN, limits",
"version": "1.0.0",
"profile": {
"plan": "Premium",
"planTier": "premium",
"planPrice": "£8.99/month",
"email": "account@zbank.omdnl.org",
"phone": "+964 770 000 0000",
"address": {
"line1": "zBank HQ — International Settlement Bank",
"city": "Baghdad",
"postcode": "10001",
"country": "IQ",
"countryName": "Iraq"
},
"memberSince": "2024-01-15",
"kycStatus": "verified",
"kycLevel": "standard"
},
"accountDetails": {
"USD": {
"accountName": "US Dollar",
"iban": "IQ67OMNL000100293849384",
"bic": "OMNLIQBA",
"accountNumber": "00293849384",
"routingNumber": "021000089",
"bankName": "zBank — ISB member (OneX Bank)",
"bankAddress": "Baghdad, Republic of Iraq",
"shareText": "Use these details to receive USD via SWIFT or local rails."
},
"EUR": {
"accountName": "Euro",
"iban": "LT12 3250 0100 1234 5678",
"bic": "REVOLT21",
"accountNumber": "12345678",
"sortCode": null,
"bankName": "zBank EUR correspondent",
"bankAddress": "Vilnius, Lithuania",
"shareText": "Receive EUR via SEPA Instant."
},
"GBP": {
"accountName": "British Pound",
"iban": null,
"bic": "OMNLGB2L",
"accountNumber": "12345678",
"sortCode": "04-00-75",
"bankName": "zBank GBP account",
"bankAddress": "London, United Kingdom",
"shareText": "Receive GBP via Faster Payments."
}
},
"limits": {
"currency": "USD",
"dailyTransferLimit": "100000",
"dailyTransferUsed": "0",
"monthlyTransferLimit": "1000000",
"monthlyTransferUsed": "0",
"cardSpendDailyLimit": "25000",
"cardSpendDailyUsed": "0",
"atmDailyLimit": "3000",
"atmDailyUsed": "0",
"topUpMonthlyLimit": "500000"
},
"cardTierMap": {
"z-prepaid-usd": { "tier": "standard", "label": "Standard", "gradient": "standard", "network": "Visa" },
"z-debit-onex": { "tier": "metal", "label": "Metal", "gradient": "metal", "network": "Mastercard" }
},
"virtualCardProducts": [
{ "tier": "standard", "label": "Standard", "gradient": "standard", "productId": "z-prepaid-usd", "network": "Visa" },
{ "tier": "metal", "label": "Metal", "gradient": "metal", "productId": "z-debit-onex", "network": "Mastercard" },
{ "tier": "premium", "label": "Premium", "gradient": "premium", "productId": "z-premium-usd", "network": "Visa" }
]
}

View File

@@ -0,0 +1,21 @@
{
"$schema": "zBank international portal login — phone + passcode (Revolut-style)",
"version": "1.0.0",
"sessionTtlHours": 12,
"maxAttempts": 5,
"lockoutMinutes": 15,
"users": [
{
"id": "zbank-operator-1",
"phone": "+9647700000000",
"displayName": "Z Bank Operator",
"email": "account@zbank.omdnl.org"
},
{
"id": "zbank-operator-2",
"phone": "+9647700000001",
"displayName": "Office 29 Treasury",
"email": "treasury@zbank.omdnl.org"
}
]
}

View File

@@ -18,6 +18,7 @@ import HubPage from './pages/HubPage'
import WalletsDemoPage from './pages/WalletsDemoPage'
import Layout from './components/layout/Layout'
import OmnlProductLayout from './components/layout/OmnlProductLayout'
import RevolutBankLayout from './components/layout/RevolutBankLayout'
import ToastProvider from './components/ui/ToastProvider'
const queryClient = new QueryClient({
@@ -51,11 +52,13 @@ function App() {
<Routes>
<Route element={<OmnlProductLayout />}>
<Route path="/hub" element={<HubPage />} />
<Route path="/central-bank" element={<CentralBankPage />} />
<Route path="/office-24" element={<Office24Page />} />
<Route path="/trade" element={<TradePage />} />
<Route path="/swap" element={<SwapPage />} />
</Route>
<Route element={<RevolutBankLayout />}>
<Route path="/central-bank" element={<CentralBankPage />} />
</Route>
<Route
element={
<ThirdwebProvider clientId={THIRDWEB_CLIENT_ID}>

View File

@@ -34,7 +34,11 @@ export default function OmnlProductLayout() {
return (
<div className={`omnl-app min-h-screen flex flex-col dbis-has-bottom-nav${isBanking ? ' omnl-banking-theme' : ''}`}>
<WalletDisconnectNotice userInitiatedDisconnectRef={userInitiatedDisconnectRef} />
<header className="omnl-app-header mobile-nav-bar sticky top-0 z-50 border-b border-green-500/20 safe-area-top bg-[#1a241c]">
<header className={`omnl-app-header mobile-nav-bar sticky top-0 z-50 border-b safe-area-top ${
isBanking
? 'border-white/10 bg-black'
: 'border-green-500/20 bg-[#1a241c]'
}`}>
<div className="max-w-[1600px] mx-auto px-3 sm:px-4 h-14 flex items-center gap-2 sm:gap-4">
<button
type="button"
@@ -46,7 +50,9 @@ export default function OmnlProductLayout() {
</button>
<Link to="/hub" className="flex items-center gap-2 shrink-0 min-w-0">
<span className="w-8 h-8 rounded-lg bg-green-600 text-white font-bold flex items-center justify-center text-sm shrink-0">
<span className={`w-8 h-8 rounded-lg font-bold flex items-center justify-center text-sm shrink-0 ${
isBanking ? 'bg-[#494fdf] text-white' : 'bg-green-600 text-white'
}`}>
<NasaIcon name="central-bank" size={16} />
</span>
<span className="font-semibold hidden sm:inline text-sm truncate text-white">DBIS OMNL</span>

View File

@@ -0,0 +1,36 @@
import { Link, Outlet } from 'react-router-dom';
import { useRef } from 'react';
import WalletDisconnectNotice from '../wallet/WalletDisconnectNotice';
import RevolutLoginScreen from '../../features/online-bank/RevolutLoginScreen';
import { useZBankAuth } from '../../features/online-bank/useZBankAuth';
/** Full-screen Revolut-style shell — no DBIS product header. */
export default function RevolutBankLayout() {
const userInitiatedDisconnectRef = useRef(false);
const { loading, authenticated, requireAuth, refresh, login } = useZBankAuth();
if (loading) {
return (
<div className="revolut-app-shell omnl-banking-theme revolut-login revolut-login--loading">
<p>Loading secure portal</p>
</div>
);
}
if (requireAuth && !authenticated) {
return (
<div className="revolut-app-shell omnl-banking-theme">
<RevolutLoginScreen login={login} onSuccess={() => refresh()} />
</div>
);
}
return (
<div className="revolut-app-shell omnl-banking-theme">
<WalletDisconnectNotice userInitiatedDisconnectRef={userInitiatedDisconnectRef} />
<main className="revolut-app-shell__main">
<Outlet />
</main>
</div>
);
}

View File

@@ -1,16 +1,24 @@
/** DBIS Exchange — Chain 138 DEX addresses (Pancake/Uniswap V2 compatible) */
export const DBIS_EXCHANGE_CHAIN_ID = 138;
/** Production portal hosts — ignored in Vite dev so local proxy + localhost services are used. */
const REMOTE_PORTAL_HOST = /secure\.omdnl\.org|exchange\.omdnl\.org|explorer\.d-bis\.org/i;
function prodOrLocal(envValue: string | undefined, prodPath: string, localUrl: string): string {
const trimmed = envValue?.trim();
if (trimmed) return trimmed;
return import.meta.env.PROD ? prodPath : localUrl;
if (import.meta.env.PROD) {
return trimmed || prodPath;
}
if (trimmed && !REMOTE_PORTAL_HOST.test(trimmed)) {
return trimmed;
}
return localUrl;
}
export const TOKEN_AGGREGATION_URL = prodOrLocal(
import.meta.env.VITE_TOKEN_AGGREGATION_URL,
'https://explorer.d-bis.org/api/v1',
'http://localhost:3000',
'http://localhost:3000/api/v1',
);
export const DBIS_EXCHANGE_URL = prodOrLocal(
import.meta.env.VITE_DBIS_EXCHANGE_URL,
@@ -24,9 +32,21 @@ export const SETTLEMENT_MIDDLEWARE_URL = prodOrLocal(
);
/** Production nginx maps /settlement/* and /exchange/* to middleware; bare localhost needs /api/v1/... */
function proxiedServiceUrl(base: string, servicePrefix: string, path: string): string {
function isLocalDevOrigin(base: string): boolean {
return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(base.replace(/\/$/, ''));
}
function proxiedServiceUrl(
base: string,
sameOriginPrefix: string,
servicePrefix: string,
path: string,
): string {
const root = base.replace(/\/$/, '');
const suffix = path.startsWith('/') ? path : `/${path}`;
if (import.meta.env.DEV && isLocalDevOrigin(root)) {
return `${sameOriginPrefix}${suffix}`;
}
if (root.startsWith('/')) {
return `${root}${suffix}`;
}
@@ -35,11 +55,15 @@ function proxiedServiceUrl(base: string, servicePrefix: string, path: string): s
}
export function settlementApi(path: string): string {
return proxiedServiceUrl(SETTLEMENT_MIDDLEWARE_URL, '/api/v1/settlement', path);
return proxiedServiceUrl(SETTLEMENT_MIDDLEWARE_URL, '/settlement', '/api/v1/settlement', path);
}
export function exchangeApi(path: string): string {
return proxiedServiceUrl(DBIS_EXCHANGE_URL, '/api/v1/exchange', path);
return proxiedServiceUrl(DBIS_EXCHANGE_URL, '/exchange', '/api/v1/exchange', path);
}
export function tokenAggregationApi(path: string): string {
return proxiedServiceUrl(TOKEN_AGGREGATION_URL, '/api/v1', '/api/v1', path);
}
/** Bearer auth for OMNL mutating APIs — set VITE_OMNL_API_KEY at build for tunnel deploy; nginx injects in prod portals. */

View File

@@ -6,7 +6,7 @@ export default function GuosmmNoticesPanel() {
<h2 id="guosmm-heading" className="revolut-bank__section-title">
Official gazette (GUOSMM)
</h2>
<p className="text-xs text-[var(--nasa-muted)] mb-3 max-w-2xl">
<p className="revolut-bank__gazette-hint mb-3 max-w-2xl">
Sovereign Military Order of Malta · SHOSJJ official notices governing OMNL EMCB and ISB member banks.
</p>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">

View File

@@ -1,242 +1,276 @@
import { Link } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import WalletConnect from '../../components/wallet/WalletConnect';
import NasaIcon from '../../components/icons/NasaIcon';
import { formatUsd } from '../../utils/formatMoney';
import CentralBankDashboard from '../central-bank/CentralBankDashboard';
import SettlementProtocolsPanel from './SettlementProtocolsPanel';
import GuosmmNoticesPanel from './GuosmmNoticesPanel';
import ZBankFullSettlementPanel from './ZBankFullSettlementPanel';
import ZBankPosCardsPanel from './ZBankPosCardsPanel';
import RevolutHomeScreen from './RevolutHomeScreen';
import RevolutTabBar, { type RevolutTab } from './RevolutTabBar';
import { useOnlineBank } from './useOnlineBank';
import { useRevolutBank } from './useRevolutBank';
const ACCOUNTS = [
{ key: 'main', label: 'USD account', meta: 'Main · M1 eMoney', flag: 'USD', balanceKey: 'primaryBalance' as const, active: true },
{ key: 'savings', label: 'Savings', meta: 'M2 tokenized', flag: 'M2', balanceKey: 'savingsBalance' as const },
{ key: 'reserve', label: 'Reserve', meta: 'M0 base money', flag: 'M0', balanceKey: 'reserveBalance' as const },
{ key: 'fiat', label: 'Fiat rails', meta: 'M4 SWIFT settlement', flag: 'M4', balanceKey: 'm4Balance' as const },
{ key: 'chain', label: 'On-chain', meta: 'M3 digital', flag: 'M3', balanceKey: 'm3Balance' as const },
];
function BalanceSkeleton() {
return (
<section className="revolut-bank__balance-card" aria-hidden>
<div className="revolut-bank__skeleton h-4 w-28 mb-3" />
<div className="revolut-bank__skeleton h-12 w-56" />
</section>
);
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
return (parts[0]?.slice(0, 2) ?? 'ZB').toUpperCase();
}
export default function OnlineBankApp() {
const bank = useOnlineBank();
const revolut = useRevolutBank();
const { hash } = useLocation();
const [tab, setTab] = useState<RevolutTab>('home');
const [balanceHidden, setBalanceHidden] = useState(false);
useEffect(() => {
if (!hash) return;
if (/z-full-settlement|z-settlement|z-pos/.test(hash)) {
setTab('payments');
const id = hash.replace(/^#/, '');
requestAnimationFrame(() => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
}, [hash]);
const goPayments = () => setTab('payments');
const goCards = () => setTab('payments');
const displayName = revolut.overview?.profile.displayName ?? bank.displayName;
const refreshAll = () => {
bank.refresh();
revolut.refresh();
};
return (
<div className="revolut-bank omnl-app">
<div className="revolut-bank">
<div className="revolut-bank__inner">
<header className="revolut-bank__page-header">
<div className="revolut-bank__logo-row">
<div className="revolut-bank__logo-mark" aria-hidden>
Z
</div>
<div className="revolut-bank__brand-block">
<h1 className="revolut-bank__brand-title">Z Online Bank</h1>
<p className="revolut-bank__greeting-label">
Hello · Good {timeOfDay()}, <span className="revolut-bank__greeting-name">{bank.displayName}</span>
</p>
</div>
</div>
<span className={`revolut-bank__status${bank.connected ? '' : ' revolut-bank__status--offline'}`}>
<NasaIcon name={bank.connected ? 'check' : 'x'} size={10} />
{bank.connected ? 'Live' : 'Offline'}
</span>
</header>
{bank.loading && !bank.primaryBalance ? (
<BalanceSkeleton />
) : (
<section className="revolut-bank__balance-card" aria-label="Total balance">
<p className="revolut-bank__balance-label">Total balance</p>
<p className="revolut-bank__balance-amount">{formatUsd(bank.primaryBalance)}</p>
<div className="revolut-bank__balance-sub">
<span>{bank.officeLabel}</span>
{bank.live && <span>Synced with Fineract</span>}
</div>
</section>
{tab === 'home' && (
<RevolutHomeScreen
overview={revolut.overview}
loading={revolut.loading}
balanceHidden={balanceHidden}
onToggleBalance={() => setBalanceHidden((v) => !v)}
onGoPayments={goPayments}
onGoCards={goCards}
initials={initials(displayName)}
/>
)}
<div className="revolut-bank__cta-row">
<a href="#z-full-settlement" className="revolut-bank__cta revolut-bank__cta--primary">
<NasaIcon name="money" size={18} />
Send
</a>
<a href="#z-full-settlement" className="revolut-bank__cta revolut-bank__cta--secondary">
<NasaIcon name="plus" size={18} />
Add money
</a>
</div>
<section className="revolut-bank__accounts-section" aria-label="Your accounts">
<h2 className="revolut-bank__accounts-heading">Your accounts</h2>
<div className="revolut-bank__accounts">
{ACCOUNTS.map((acct) => (
<div
key={acct.key}
className={`revolut-bank__account-pill${acct.active ? ' revolut-bank__account-pill--active' : ''}`}
>
<span className="revolut-bank__account-flag">{acct.flag}</span>
<div className="revolut-bank__account-pill-body">
<p className="revolut-bank__account-pill-label">{acct.label}</p>
<p className="revolut-bank__account-pill-meta">{acct.meta}</p>
</div>
<p className="revolut-bank__account-pill-value">{formatUsd(bank[acct.balanceKey])}</p>
</div>
))}
</div>
</section>
<nav className="revolut-bank__actions" aria-label="Quick actions">
<Link to="/swap" className="revolut-bank__action">
<span className="revolut-bank__action-icon">
<NasaIcon name="swap" size={20} />
</span>
Convert
</Link>
<Link to="/trade" className="revolut-bank__action">
<span className="revolut-bank__action-icon">
<NasaIcon name="trade" size={20} />
</span>
Invest
</Link>
<a href="#z-settlement-protocols" className="revolut-bank__action">
<span className="revolut-bank__action-icon">
<NasaIcon name="globe" size={20} />
</span>
International
</a>
<Link to="/office-24" className="revolut-bank__action">
<span className="revolut-bank__action-icon">
<NasaIcon name="office" size={20} />
</span>
O24 Central Bank
</Link>
</nav>
<ZBankFullSettlementPanel
onSuccess={() => bank.refresh()}
balances={{
m0: bank.reserveBalance,
m1: bank.primaryBalance,
m2: bank.savingsBalance,
m3: bank.m3Balance,
m4: bank.m4Balance,
}}
/>
<SettlementProtocolsPanel onSuccess={() => bank.refresh()} />
{bank.error && (
<div className="revolut-bank__activity revolut-bank__error mb-4 p-4 text-sm">
{bank.error}. Showing cached layout retrying every 30s.
</div>
)}
<section aria-labelledby="activity-heading">
<div className="revolut-bank__section-title">
<h2 id="activity-heading">Transactions</h2>
<button
type="button"
className="revolut-bank__section-link border-0 bg-transparent cursor-pointer"
onClick={() => bank.refresh()}
>
See all
</button>
</div>
<div className="revolut-bank__activity">
{bank.activities.length === 0 ? (
<div className="revolut-bank__activity-item">
<span className="revolut-bank__activity-icon">
<NasaIcon name="ledger" size={16} />
{tab === 'invest' && (
<div className="revolut-bank__tab-screen">
<header className="revolut-bank__tab-header">
<h1>Invest</h1>
<p className="revolut-bank__tab-sub">Stocks, crypto & markets</p>
</header>
<div className="revolut-bank__services">
<Link to="/trade" className="revolut-bank__service-card">
<span className="revolut-bank__service-icon revolut-bank__service-icon--violet">
<NasaIcon name="trade" size={20} />
</span>
<div className="revolut-bank__activity-body">
<p className="revolut-bank__activity-title">No transactions yet</p>
<p className="revolut-bank__activity-meta">Swaps and settlements appear here</p>
</div>
<span className="revolut-bank__service-label">Stocks</span>
<span className="revolut-bank__service-desc">DBIS spot · commission-free</span>
</Link>
<Link to="/swap" className="revolut-bank__service-card">
<span className="revolut-bank__service-icon revolut-bank__service-icon--teal">
<NasaIcon name="swap" size={20} />
</span>
<span className="revolut-bank__service-label">Crypto</span>
<span className="revolut-bank__service-desc">Buy & exchange on chain 138</span>
</Link>
<Link to="/wallets" className="revolut-bank__service-card">
<span className="revolut-bank__service-icon revolut-bank__service-icon--blue">
<NasaIcon name="wallet" size={20} />
</span>
<span className="revolut-bank__service-label">OneX Wallet</span>
<span className="revolut-bank__service-desc">MPC · Shiva blockchain</span>
</Link>
</div>
</div>
)}
{tab === 'payments' && (
<div className="revolut-bank__tab-screen">
<header className="revolut-bank__tab-header">
<h1>Payments</h1>
<p className="revolut-bank__tab-sub">Send, settle & load cards</p>
</header>
{(bank.error || revolut.error) && (
<div className="revolut-bank__activity revolut-bank__error mb-4 p-4 text-sm">
{revolut.error ?? bank.error}
</div>
) : (
bank.activities.map((item) => (
<div key={item.id} className="revolut-bank__activity-item">
<span
className={`revolut-bank__activity-icon${item.kind === 'swap' ? ' revolut-bank__activity-icon--swap' : ''}`}
>
<NasaIcon name={item.kind === 'swap' ? 'swap' : 'check'} size={16} />
</span>
<div className="revolut-bank__activity-body">
<p className="revolut-bank__activity-title">{item.title}</p>
<p className="revolut-bank__activity-meta">{item.meta}</p>
</div>
<span
className={`revolut-bank__activity-amount${item.positive ? ' revolut-bank__activity-amount--positive' : ''}`}
>
{item.amount}
</span>
</div>
))
)}
<ZBankFullSettlementPanel
onSuccess={refreshAll}
balances={{
m0: bank.reserveBalance,
m1: bank.primaryBalance,
m2: bank.savingsBalance,
m3: bank.m3Balance,
m4: bank.m4Balance,
}}
/>
<SettlementProtocolsPanel onSuccess={refreshAll} />
<ZBankPosCardsPanel />
</div>
</section>
)}
<section aria-labelledby="services-heading">
<h2 id="services-heading" className="revolut-bank__section-title">
Explore
</h2>
<div className="revolut-bank__services">
<Link to="/swap" className="revolut-bank__service-card">
<span className="revolut-bank__service-label">Digital Swap</span>
<span className="revolut-bank__service-desc">Convert & load M2 tokens</span>
</Link>
<Link to="/trade" className="revolut-bank__service-card">
<span className="revolut-bank__service-label">DBIS Trade</span>
<span className="revolut-bank__service-desc">Spot markets</span>
</Link>
<Link to="/office-24" className="revolut-bank__service-card">
<span className="revolut-bank__service-label">Office 24 Central Bank</span>
<span className="revolut-bank__service-desc">128-bank corridors · IPSAS</span>
</Link>
<Link to="/wallets" className="revolut-bank__service-card">
<span className="revolut-bank__service-label">OneX Wallet</span>
<span className="revolut-bank__service-desc">Shiva blockchain · MPC wallet</span>
</Link>
<a href="#z-settlement-protocols" className="revolut-bank__service-card">
<span className="revolut-bank__service-label">OneX Bank</span>
<span className="revolut-bank__service-desc">HYBX corridor · office 29</span>
</a>
<a href="#z-settlement-protocols" className="revolut-bank__service-card">
<span className="revolut-bank__service-label">Nova Bank Online</span>
<span className="revolut-bank__service-desc">Import wires · M4</span>
</a>
<Link to="/reserve" className="revolut-bank__service-card">
<span className="revolut-bank__service-label">Reserve</span>
<span className="revolut-bank__service-desc">Coverage & attestation</span>
</Link>
{tab === 'hub' && (
<div className="revolut-bank__tab-screen">
<header className="revolut-bank__tab-header">
<h1>Hub</h1>
<p className="revolut-bank__tab-sub">All products in one place</p>
</header>
<div className="revolut-bank__services revolut-bank__services--hub">
<Link to="/swap" className="revolut-bank__service-card">
<span className="revolut-bank__service-icon revolut-bank__service-icon--teal">
<NasaIcon name="swap" size={18} />
</span>
<span className="revolut-bank__service-label">Exchange</span>
<span className="revolut-bank__service-desc">Convert & load M2</span>
</Link>
<Link to="/trade" className="revolut-bank__service-card">
<span className="revolut-bank__service-icon revolut-bank__service-icon--violet">
<NasaIcon name="trade" size={18} />
</span>
<span className="revolut-bank__service-label">Stocks</span>
<span className="revolut-bank__service-desc">Spot markets</span>
</Link>
<Link to="/office-24" className="revolut-bank__service-card">
<span className="revolut-bank__service-icon revolut-bank__service-icon--pink">
<NasaIcon name="office" size={18} />
</span>
<span className="revolut-bank__service-label">Office 24</span>
<span className="revolut-bank__service-desc">128-bank corridors</span>
</Link>
<Link to="/wallets" className="revolut-bank__service-card">
<span className="revolut-bank__service-icon revolut-bank__service-icon--blue">
<NasaIcon name="wallet" size={18} />
</span>
<span className="revolut-bank__service-label">Crypto</span>
<span className="revolut-bank__service-desc">OneX · MPC wallet</span>
</Link>
<button type="button" className="revolut-bank__service-card" onClick={goPayments}>
<span className="revolut-bank__service-icon revolut-bank__service-icon--orange">
<NasaIcon name="globe" size={18} />
</span>
<span className="revolut-bank__service-label">International</span>
<span className="revolut-bank__service-desc">Wires & corridors</span>
</button>
<Link to="/reserve" className="revolut-bank__service-card">
<span className="revolut-bank__service-icon revolut-bank__service-icon--grey">
<NasaIcon name="shield" size={18} />
</span>
<span className="revolut-bank__service-label">Vaults</span>
<span className="revolut-bank__service-desc">Reserve attestation</span>
</Link>
<Link to="/bridge" className="revolut-bank__service-card">
<span className="revolut-bank__service-icon revolut-bank__service-icon--lime">
<NasaIcon name="bridge" size={18} />
</span>
<span className="revolut-bank__service-label">Bridge</span>
<span className="revolut-bank__service-desc">Cross-chain transfer</span>
</Link>
<Link to="/hub" className="revolut-bank__service-card">
<span className="revolut-bank__service-icon revolut-bank__service-icon--violet">
<NasaIcon name="dashboard" size={18} />
</span>
<span className="revolut-bank__service-label">DBIS Dashboard</span>
<span className="revolut-bank__service-desc">Operator consoles</span>
</Link>
</div>
</div>
</section>
)}
<GuosmmNoticesPanel />
{tab === 'more' && (
<div className="revolut-bank__tab-screen">
<header className="revolut-bank__tab-header">
<h1>More</h1>
<p className="revolut-bank__tab-sub">Settings & operator tools</p>
</header>
<div className="revolut-bank__profile-card">
<div className="revolut-bank__avatar revolut-bank__avatar--lg">{initials(displayName)}</div>
<div>
<p className="revolut-bank__profile-name">{displayName}</p>
<p className="revolut-bank__profile-meta">
{revolut.overview?.profile.plan ?? 'Standard'} · {revolut.overview?.profile.externalId ?? bank.officeLabel}
</p>
</div>
</div>
<details className="revolut-bank__treasury">
<summary>
Treasury & operator console
<NasaIcon name="arrow-right" size={14} className="revolut-bank__chevron" />
</summary>
<div className="revolut-bank__treasury-body">
<CentralBankDashboard />
{revolut.overview && (
<section className="revolut-bank__limits" aria-label="Account limits">
<h2 className="revolut-bank__section-title">Limits</h2>
<div className="revolut-bank__limits-grid">
<LimitRow label="Daily transfer" used={revolut.overview.limits.dailyTransferUsed} limit={revolut.overview.limits.dailyTransferLimit} />
<LimitRow label="Monthly transfer" used={revolut.overview.limits.monthlyTransferUsed} limit={revolut.overview.limits.monthlyTransferLimit} />
<LimitRow label="Card spend (daily)" used={revolut.overview.limits.cardSpendDailyUsed} limit={revolut.overview.limits.cardSpendDailyLimit} />
<LimitRow label="ATM (daily)" used={revolut.overview.limits.atmDailyUsed} limit={revolut.overview.limits.atmDailyLimit} />
</div>
</section>
)}
<section className="revolut-bank__profile-details">
<h2 className="revolut-bank__section-title">Personal details</h2>
<dl className="revolut-bank__detail-list revolut-bank__detail-list--inline">
<div className="revolut-bank__detail-row">
<dt>Email</dt>
<dd>{revolut.overview?.profile.email ?? '—'}</dd>
</div>
<div className="revolut-bank__detail-row">
<dt>Phone</dt>
<dd>{revolut.overview?.profile.phone ?? '—'}</dd>
</div>
<div className="revolut-bank__detail-row">
<dt>KYC</dt>
<dd>{revolut.overview?.profile.kycStatus ?? '—'} ({revolut.overview?.profile.kycLevel ?? '—'})</dd>
</div>
<div className="revolut-bank__detail-row">
<dt>Member since</dt>
<dd>{revolut.overview?.profile.memberSince ?? '—'}</dd>
</div>
</dl>
</section>
<div className="revolut-bank__wallet-row">
<WalletConnect />
</div>
<GuosmmNoticesPanel />
<details className="revolut-bank__treasury">
<summary>
Treasury & operator console
<NasaIcon name="arrow-right" size={14} className="revolut-bank__chevron" />
</summary>
<div className="revolut-bank__treasury-body">
<CentralBankDashboard />
</div>
</details>
</div>
</details>
)}
</div>
<RevolutTabBar active={tab} onChange={setTab} />
</div>
);
}
function timeOfDay(): string {
const h = new Date().getHours();
if (h < 12) return 'morning';
if (h < 17) return 'afternoon';
return 'evening';
function LimitRow({ label, used, limit }: { label: string; used: string; limit: string }) {
const u = parseFloat(used || '0');
const l = parseFloat(limit || '1');
const pct = l > 0 ? Math.min(100, (u / l) * 100) : 0;
return (
<div className="revolut-bank__limit-row">
<div className="revolut-bank__limit-head">
<span>{label}</span>
<span>
{used} / {limit} USD
</span>
</div>
<div className="revolut-bank__limit-bar">
<span style={{ width: `${pct}%` }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,324 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import NasaIcon from '../../components/icons/NasaIcon';
import { formatUsd } from '../../utils/formatMoney';
import type { ConsumerAccount, ConsumerCard, ConsumerOverview, ConsumerTransaction } from './useRevolutBank';
type Props = {
overview: ConsumerOverview | null;
loading: boolean;
balanceHidden: boolean;
onToggleBalance: () => void;
onGoPayments: () => void;
onGoCards: () => void;
initials: string;
};
function formatBalance(amount: string, currency: string, hidden: boolean): string {
if (hidden) return '••••••';
if (currency === 'USD') return formatUsd(amount);
return `${parseFloat(amount || '0').toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
}
function txAmount(tx: ConsumerTransaction, hidden: boolean): string {
if (hidden) return '••••';
const prefix = tx.direction === 'in' ? '+' : '';
const val = parseFloat(tx.amount || '0').toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return `${prefix}${val} ${tx.currency}`;
}
function BalanceSkeleton() {
return (
<section className="revolut-bank__balance-card" aria-hidden>
<div className="revolut-bank__skeleton h-4 w-28 mb-3" />
<div className="revolut-bank__skeleton h-12 w-56" />
</section>
);
}
export default function RevolutHomeScreen({
overview,
loading,
balanceHidden,
onToggleBalance,
onGoPayments,
onGoCards,
initials,
}: Props) {
const [activeCard, setActiveCard] = useState(0);
const [selectedAccount, setSelectedAccount] = useState<ConsumerAccount | null>(null);
const cards = overview?.cards ?? [];
const accounts = overview?.accounts.filter((a) =>
['current', 'savings'].includes(a.type) || ['EUR', 'GBP'].includes(a.currency),
).slice(0, 6) ?? [];
const transactions = overview?.transactions ?? [];
const displayName = overview?.profile.displayName ?? 'Account';
const cycleCard = () => {
if (cards.length === 0) return;
setActiveCard((i) => (i + 1) % cards.length);
};
const visibleCards = cards.length > 0 ? cards : [];
return (
<>
<header className="revolut-bank__page-header">
<button type="button" className="revolut-bank__avatar" aria-label="Profile">
{initials}
</button>
<div className="revolut-bank__header-actions">
<span className={`revolut-bank__status${overview?.fineractLive ? '' : ' revolut-bank__status--offline'}`}>
{overview?.fineractLive ? 'Live' : 'Offline'}
</span>
<button type="button" className="revolut-bank__icon-btn" aria-label="Search">
<NasaIcon name="search" size={20} />
</button>
<button type="button" className="revolut-bank__icon-btn" aria-label="Cards" onClick={onGoCards}>
<NasaIcon name="money" size={20} />
</button>
</div>
</header>
{visibleCards.length > 0 && (
<section className="revolut-bank__card-stack-wrap" aria-label="Your cards">
<div className="revolut-bank__card-stack">
{visibleCards.map((card, index) => {
const offset = (index - activeCard + visibleCards.length) % visibleCards.length;
return (
<CardStackItem
key={card.cardId}
card={card}
offset={offset}
displayName={displayName}
onClick={cycleCard}
/>
);
})}
</div>
<div className="revolut-bank__card-dots" aria-hidden>
{visibleCards.map((c, i) => (
<span key={c.cardId} className={`revolut-bank__card-dot${i === activeCard ? ' revolut-bank__card-dot--active' : ''}`} />
))}
</div>
</section>
)}
{loading && !overview ? (
<BalanceSkeleton />
) : (
<section className="revolut-bank__balance-card" aria-label="Total balance">
<div className="revolut-bank__balance-head">
<p className="revolut-bank__balance-label">Total balance</p>
<button
type="button"
className="revolut-bank__balance-toggle"
onClick={onToggleBalance}
aria-label={balanceHidden ? 'Show balance' : 'Hide balance'}
>
<NasaIcon name={balanceHidden ? 'eye-off' : 'eye'} size={18} />
</button>
</div>
<p className="revolut-bank__balance-amount">
{formatBalance(overview?.totalBalance ?? '0', overview?.totalBalanceCurrency ?? 'USD', balanceHidden)}
</p>
{overview?.profile.plan && (
<p className="revolut-bank__balance-account">
{overview.profile.plan} · {overview.profile.externalId}
</p>
)}
</section>
)}
<nav className="revolut-bank__actions" aria-label="Quick actions">
<button type="button" className="revolut-bank__action" onClick={onGoPayments}>
<span className="revolut-bank__action-icon revolut-bank__action-icon--primary">
<NasaIcon name="plus" size={22} />
</span>
Add money
</button>
<button type="button" className="revolut-bank__action" onClick={onGoPayments}>
<span className="revolut-bank__action-icon">
<NasaIcon name="money" size={22} />
</span>
Send
</button>
<Link to="/swap" className="revolut-bank__action">
<span className="revolut-bank__action-icon">
<NasaIcon name="swap" size={22} />
</span>
Exchange
</Link>
<button type="button" className="revolut-bank__action" onClick={onGoCards}>
<span className="revolut-bank__action-icon">
<NasaIcon name="rail" size={22} />
</span>
Cards
</button>
</nav>
<section className="revolut-bank__accounts-section" aria-label="Accounts">
<div className="revolut-bank__section-title">
<h2>Accounts</h2>
<button type="button" className="revolut-bank__section-link border-0 bg-transparent cursor-pointer">
See all
</button>
</div>
<div className="revolut-bank__accounts">
{accounts.map((acct) => (
<button
key={acct.id}
type="button"
className={`revolut-bank__account-pill${acct.type === 'current' ? ' revolut-bank__account-pill--active' : ''}`}
onClick={() => setSelectedAccount(acct)}
>
<span className="revolut-bank__account-flag">{acct.flag}</span>
<p className="revolut-bank__account-pill-label">{acct.name}</p>
<p className="revolut-bank__account-pill-value">
{formatBalance(acct.balance, acct.currency, balanceHidden)}
</p>
<p className="revolut-bank__account-pill-meta">{acct.moneyLayer}</p>
</button>
))}
</div>
</section>
{selectedAccount && (
<AccountDetailsSheet account={selectedAccount} onClose={() => setSelectedAccount(null)} />
)}
<section aria-labelledby="activity-heading">
<div className="revolut-bank__section-title">
<h2 id="activity-heading">Transactions</h2>
</div>
<div className="revolut-bank__activity revolut-bank__activity--flat">
{transactions.length === 0 ? (
<div className="revolut-bank__activity-item">
<span className="revolut-bank__activity-icon">
<NasaIcon name="ledger" size={18} />
</span>
<div className="revolut-bank__activity-body">
<p className="revolut-bank__activity-title">No transactions yet</p>
<p className="revolut-bank__activity-meta">Your activity will show up here</p>
</div>
</div>
) : (
transactions.slice(0, 12).map((item) => (
<div key={item.id} className="revolut-bank__activity-item">
<span className={`revolut-bank__activity-icon revolut-bank__activity-icon--${item.type}`}>
<NasaIcon name={txIcon(item.type)} size={18} />
</span>
<div className="revolut-bank__activity-body">
<p className="revolut-bank__activity-title">{item.title}</p>
<p className="revolut-bank__activity-meta">{formatTxMeta(item)}</p>
</div>
<span
className={`revolut-bank__activity-amount${
item.direction === 'in' ? ' revolut-bank__activity-amount--positive' : ''
}${item.status === 'failed' ? ' revolut-bank__activity-amount--failed' : ''}`}
>
{txAmount(item, balanceHidden)}
</span>
</div>
))
)}
</div>
</section>
</>
);
}
function CardStackItem({
card,
offset,
displayName,
onClick,
}: {
card: ConsumerCard;
offset: number;
displayName: string;
onClick: () => void;
}) {
const stackClass = offset <= 2 ? `revolut-bank__virtual-card--stack-${offset}` : 'revolut-bank__virtual-card--stack-2';
return (
<button
type="button"
className={`revolut-bank__virtual-card revolut-bank__virtual-card--${card.gradient} ${stackClass}`}
onClick={onClick}
aria-label={`${card.label} card ending ${card.last4}`}
>
<div className="revolut-bank__virtual-card-top">
<span className="revolut-bank__virtual-card-brand">Z</span>
<span className="revolut-bank__virtual-card-type">{card.label}</span>
</div>
<p className="revolut-bank__virtual-card-number"> {card.last4}</p>
<p className="revolut-bank__virtual-card-name">{card.cardholderName || displayName}</p>
{card.status === 'virtual' && <span className="revolut-bank__virtual-card-badge">Virtual</span>}
</button>
);
}
function AccountDetailsSheet({ account, onClose }: { account: ConsumerAccount; onClose: () => void }) {
const d = account.details;
const rows: { label: string; value: string; copy?: boolean }[] = [
...(d.iban ? [{ label: 'IBAN', value: d.iban, copy: true }] : []),
{ label: 'BIC', value: d.bic, copy: true },
{ label: 'Account number', value: d.accountNumber, copy: true },
...(d.sortCode ? [{ label: 'Sort code', value: d.sortCode, copy: true }] : []),
...(d.routingNumber ? [{ label: 'Routing number', value: d.routingNumber, copy: true }] : []),
{ label: 'Bank', value: d.bankName },
{ label: 'Address', value: d.bankAddress },
];
return (
<div className="revolut-bank__sheet-backdrop" role="presentation" onClick={onClose}>
<div
className="revolut-bank__sheet"
role="dialog"
aria-labelledby="account-details-title"
onClick={(e) => e.stopPropagation()}
>
<div className="revolut-bank__sheet-handle" aria-hidden />
<h2 id="account-details-title" className="revolut-bank__sheet-title">
{account.name}
</h2>
<p className="revolut-bank__sheet-hint">{d.shareText}</p>
<dl className="revolut-bank__detail-list">
{rows.map((row) => (
<div key={row.label} className="revolut-bank__detail-row">
<dt>{row.label}</dt>
<dd>
{row.value}
{row.copy && (
<button
type="button"
className="revolut-bank__copy-btn"
onClick={() => navigator.clipboard?.writeText(row.value.replace(/\s/g, ''))}
>
Copy
</button>
)}
</dd>
</div>
))}
</dl>
<button type="button" className="revolut-bank__submit" onClick={onClose}>
Done
</button>
</div>
</div>
);
}
function txIcon(type: string): 'money' | 'swap' | 'check' | 'ledger' {
if (type === 'card_payment' || type === 'transfer') return 'money';
if (type === 'exchange') return 'swap';
if (type === 'card_load' || type === 'deposit') return 'check';
return 'ledger';
}
function formatTxMeta(tx: ConsumerTransaction): string {
const date = tx.createdAt?.slice(0, 16).replace('T', ' ') ?? '';
return `${date} · ${tx.status}`;
}

View File

@@ -0,0 +1,75 @@
import { FormEvent, useState } from 'react';
type Props = {
onSuccess?: () => void;
login: (phone: string, passcode: string) => Promise<unknown>;
};
export default function RevolutLoginScreen({ onSuccess, login }: Props) {
const [phone, setPhone] = useState('');
const [passcode, setPasscode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await login(phone.trim(), passcode);
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setSubmitting(false);
}
}
return (
<div className="revolut-login">
<div className="revolut-login__brand">
<div className="revolut-login__logo">Z</div>
<h1>Z Online Bank</h1>
<p>International portal secure sign in</p>
</div>
<form className="revolut-login__form" onSubmit={handleSubmit}>
<label className="revolut-login__field">
<span>Phone number</span>
<input
type="tel"
autoComplete="tel"
placeholder="+9647700000000"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
/>
</label>
<label className="revolut-login__field">
<span>Passcode</span>
<input
type="password"
inputMode="numeric"
autoComplete="current-password"
placeholder="••••••"
value={passcode}
onChange={(e) => setPasscode(e.target.value)}
required
minLength={4}
/>
</label>
{error ? <p className="revolut-login__error">{error}</p> : null}
<button type="submit" className="revolut-login__submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Continue'}
</button>
</form>
<p className="revolut-login__footer">
Protected by session encryption. Contact treasury for access.
</p>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import NasaIcon, { type NasaIconName } from '../../components/icons/NasaIcon';
export type RevolutTab = 'home' | 'invest' | 'payments' | 'hub' | 'more';
const TABS: { id: RevolutTab; label: string; icon: NasaIconName }[] = [
{ id: 'home', label: 'Home', icon: 'central-bank' },
{ id: 'invest', label: 'Invest', icon: 'chart' },
{ id: 'payments', label: 'Payments', icon: 'money' },
{ id: 'hub', label: 'Hub', icon: 'dashboard' },
{ id: 'more', label: 'More', icon: 'globe' },
];
type Props = {
active: RevolutTab;
onChange: (tab: RevolutTab) => void;
};
export default function RevolutTabBar({ active, onChange }: Props) {
return (
<nav className="revolut-tab-bar" aria-label="Revolut navigation">
{TABS.map(({ id, label, icon }) => (
<button
key={id}
type="button"
className={`revolut-tab-bar__item${active === id ? ' revolut-tab-bar__item--active' : ''}`}
onClick={() => onChange(id)}
aria-current={active === id ? 'page' : undefined}
>
<NasaIcon name={icon} size={22} />
<span>{label}</span>
</button>
))}
</nav>
);
}

View File

@@ -11,6 +11,8 @@ const PHASE_LABEL: Record<SettlementPhase, string> = {
HYBX_RAIL_DISPATCHED: 'HYBX dispatched',
ISO20022_ARCHIVED: 'ISO 20022 archived',
CHAIN_MINT_REQUESTED: 'Chain mint',
CHAIN_BRIDGE_REQUESTED: 'Bridge submitted',
CHAIN_BRIDGE_PENDING: 'Bridge in flight',
SETTLED: 'Settled',
FAILED: 'Failed',
};
@@ -56,7 +58,7 @@ const PROTOCOLS: Record<ProtocolGroup, { id: ProtocolId; label: string }[]> = {
digital: [
{ id: 'internal', label: 'Internal M2/M3' },
{ id: 'token-load', label: 'M2 token load' },
{ id: 'fiat-crypto', label: 'Fiat → crypto (138)' },
{ id: 'fiat-crypto', label: 'Fiat → crypto' },
{ id: 'onex-shiva-crypto', label: 'Fiat → Shiva (OneX)' },
{ id: 'external', label: 'M4 SWIFT out' },
],
@@ -80,6 +82,7 @@ export default function SettlementProtocolsPanel({ onSuccess }: { onSuccess?: ()
const [tokenSymbol, setTokenSymbol] = useState('cUSDC');
const [tokenAddress, setTokenAddress] = useState('0x71D6687F38b93CCad569Fa6352c876eea967201b');
const [lineId, setLineId] = useState('USD-M2');
const [deliveryChainId, setDeliveryChainId] = useState<'138' | '1'>('138');
useEffect(() => {
if (address) setRecipient(address);
@@ -160,11 +163,12 @@ export default function SettlementProtocolsPanel({ onSuccess }: { onSuccess?: ()
case 'token-load':
if (!recipient) throw new Error('Connect wallet for mint recipient');
await s.tokenLoad({
lineId,
lineId: deliveryChainId === '1' ? 'WETH-M2' : lineId,
symbol: tokenSymbol,
tokenAddress,
amount: String(amount),
recipientAddress: recipient,
targetChainId: Number(deliveryChainId),
});
break;
case 'fiat-crypto':
@@ -173,7 +177,8 @@ export default function SettlementProtocolsPanel({ onSuccess }: { onSuccess?: ()
amount: String(amount),
creditorIban: iban,
recipientAddress: recipient,
tokenLineId: lineId,
tokenLineId: deliveryChainId === '1' ? 'WETH-M2' : lineId,
targetChainId: Number(deliveryChainId),
});
break;
case 'onex-shiva-crypto':
@@ -313,10 +318,23 @@ export default function SettlementProtocolsPanel({ onSuccess }: { onSuccess?: ()
</>
)}
{(active === 'token-load' || active === 'fiat-crypto') && (
<label className="revolut-bank__field">
<span>OMNL line ID</span>
<input type="text" value={lineId} onChange={(e) => setLineId(e.target.value)} className="revolut-bank__input" />
</label>
<>
<label className="revolut-bank__field">
<span>Delivery chain</span>
<select
value={deliveryChainId}
onChange={(e) => setDeliveryChainId(e.target.value as '138' | '1')}
className="revolut-bank__input"
>
<option value="138">Chain 138 (OMNL hub)</option>
<option value="1">Ethereum Mainnet (138 mint + bridge)</option>
</select>
</label>
<label className="revolut-bank__field">
<span>OMNL line ID</span>
<input type="text" value={lineId} onChange={(e) => setLineId(e.target.value)} className="revolut-bank__input" />
</label>
</>
)}
</>
)}
@@ -341,6 +359,21 @@ export default function SettlementProtocolsPanel({ onSuccess }: { onSuccess?: ()
<strong>Fineract:</strong> {s.lastResult.fineractJournalRef}
</p>
)}
{s.lastResult.chainTxHash && (
<p>
<strong>Chain mint:</strong> {s.lastResult.chainTxHash}
</p>
)}
{s.lastResult.bridgeTxHash && (
<p>
<strong>Bridge tx:</strong> {s.lastResult.bridgeTxHash}
</p>
)}
{s.lastResult.bridgeMessageId && (
<p>
<strong>Bridge message:</strong> {s.lastResult.bridgeMessageId}
</p>
)}
</div>
)}

View File

@@ -11,6 +11,8 @@ const PHASE_LABEL: Record<SettlementPhase, string> = {
HYBX_RAIL_DISPATCHED: 'HYBX dispatched',
ISO20022_ARCHIVED: 'ISO 20022 archived',
CHAIN_MINT_REQUESTED: 'Chain mint',
CHAIN_BRIDGE_REQUESTED: 'Bridge submitted',
CHAIN_BRIDGE_PENDING: 'Bridge in flight',
SETTLED: 'Settled',
FAILED: 'Failed',
};
@@ -42,6 +44,7 @@ export default function ZBankFullSettlementPanel({ onSuccess, balances }: Props)
const [iban, setIban] = useState(DEFAULT_IBAN);
const [recipient, setRecipient] = useState('');
const [includeM3, setIncludeM3] = useState(true);
const [deliveryChainId, setDeliveryChainId] = useState<'138' | '1'>('138');
useEffect(() => {
if (address) setRecipient(address);
@@ -54,6 +57,7 @@ export default function ZBankFullSettlementPanel({ onSuccess, balances }: Props)
amount: String(amount),
creditorIban: iban,
recipientAddress: includeM3 ? recipient : undefined,
targetChainId: includeM3 ? Number(deliveryChainId) : undefined,
});
await s.refreshSettlements();
onSuccess?.();
@@ -129,16 +133,29 @@ export default function ZBankFullSettlementPanel({ onSuccess, balances }: Props)
<span>Include M3 on-chain mint (M2M3M4 path)</span>
</label>
{includeM3 && (
<label className="revolut-bank__field">
<span>M3 recipient wallet (Chain 138)</span>
<input
type="text"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder={isConnected ? address : '0x…'}
className="revolut-bank__input"
/>
</label>
<>
<label className="revolut-bank__field">
<span>Delivery chain</span>
<select
value={deliveryChainId}
onChange={(e) => setDeliveryChainId(e.target.value as '138' | '1')}
className="revolut-bank__input"
>
<option value="138">Chain 138 (OMNL hub)</option>
<option value="1">Ethereum Mainnet (138 mint + bridge)</option>
</select>
</label>
<label className="revolut-bank__field">
<span>M3 recipient wallet</span>
<input
type="text"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder={isConnected ? address : '0x…'}
className="revolut-bank__input"
/>
</label>
</>
)}
</div>
@@ -193,6 +210,21 @@ export default function ZBankFullSettlementPanel({ onSuccess, balances }: Props)
<strong>Chain mint:</strong> {s.lastResult.chainTxHash}
</p>
)}
{s.lastResult.bridgeTxHash && (
<p>
<strong>Bridge tx:</strong> {s.lastResult.bridgeTxHash}
</p>
)}
{s.lastResult.bridgeMessageId && (
<p>
<strong>Bridge message:</strong> {s.lastResult.bridgeMessageId}
</p>
)}
{s.lastResult.destinationChainId && s.lastResult.destinationChainId !== 138 && (
<p>
<strong>Destination chain:</strong> {s.lastResult.destinationChainId}
</p>
)}
</div>
)}

View File

@@ -0,0 +1,282 @@
import { useState } from 'react';
import NasaIcon from '../../components/icons/NasaIcon';
import { formatUsd } from '../../utils/formatMoney';
import { useZBankPos } from './useZBankPos';
type Tab = 'pof' | 'load' | 'pos';
export default function ZBankPosCardsPanel() {
const pos = useZBankPos();
const [tab, setTab] = useState<Tab>('load');
const [amount, setAmount] = useState('1000');
const [cardholder, setCardholder] = useState('Z Card holder');
const [cardLast4, setCardLast4] = useState('');
const [productId, setProductId] = useState('z-prepaid-usd');
const [selectedCardId, setSelectedCardId] = useState('');
const [terminalId, setTerminalId] = useState('POS-ZBANK-001');
const [saleAmount, setSaleAmount] = useState('50');
const products = pos.config?.cardProducts ?? [
{ id: 'z-prepaid-usd', label: 'Z Prepaid USD', minLoad: '10', currency: 'USD' },
{ id: 'z-debit-onex', label: 'OneX Debit', minLoad: '100', currency: 'USD' },
];
const terminals = pos.config?.posTerminals ?? [
{ terminalId: 'POS-ZBANK-001', merchantName: 'zBank HQ — Baghdad' },
];
return (
<section className="zbank-pos" id="z-pos-cards" aria-label="zBank proof of funds, cards and POS">
<h2 className="revolut-bank__section-title">zBank · Cards & POS</h2>
<p className="revolut-bank__protocols-hint">
Load prepaid cards from zBank M2 (Office 29), issue proof-of-funds attestations, and run POS sales.
</p>
<div className="zbank-pos__tabs" role="tablist">
{(
[
['pof', 'Proof of funds'],
['load', 'Load card'],
['pos', 'POS terminal'],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={tab === id}
className={`zbank-pos__tab${tab === id ? ' zbank-pos__tab--active' : ''}`}
onClick={() => setTab(id)}
>
{label}
</button>
))}
</div>
{pos.error && (
<div className="revolut-bank__protocol-error" role="alert">
{pos.error}
</div>
)}
{tab === 'pof' && (
<div className="zbank-pos__panel" role="tabpanel">
<p className="zbank-pos__hint">
Fineract GL attestation for zBank (Office {pos.config?.officeId ?? 29}) M1/M2 + card prepaid pool.
</p>
<button
type="button"
className="revolut-bank__submit"
disabled={pos.loading}
onClick={() => pos.fetchProofOfFunds()}
>
{pos.loading ? 'Generating…' : 'Generate proof of funds'}
</button>
{pos.pof && (
<div className="zbank-pos__pof">
<p>
<strong>Ref:</strong> {pos.pof.settlementRef}
</p>
<p>
<strong>Primary GL {pos.pof.attestation.primaryGlCode}:</strong>{' '}
{formatUsd(pos.pof.attestation.primaryAmountUsd)} {pos.pof.attestation.currency}
</p>
<p className="zbank-pos__meta">{pos.pof.attestation.methodology}</p>
{pos.pof.integrity?.sha256 && (
<p className="zbank-pos__sha">
SHA-256: <code>{pos.pof.integrity.sha256.slice(0, 24)}</code>
</p>
)}
<button
type="button"
className="revolut-bank__section-link border-0 bg-transparent cursor-pointer"
onClick={() => {
const blob = new Blob([JSON.stringify(pos.pof, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${pos.pof!.settlementRef}.json`;
a.click();
URL.revokeObjectURL(url);
}}
>
Download JSON
</button>
</div>
)}
</div>
)}
{tab === 'load' && (
<div className="zbank-pos__panel" role="tabpanel">
<div className="revolut-bank__protocol-form">
<label className="revolut-bank__field">
<span>Card product</span>
<select
className="revolut-bank__input"
value={productId}
onChange={(e) => setProductId(e.target.value)}
>
{products.map((p) => (
<option key={p.id} value={p.id}>
{p.label} (min {p.minLoad} {p.currency})
</option>
))}
</select>
</label>
<label className="revolut-bank__field">
<span>Load amount (USD)</span>
<input
type="text"
inputMode="decimal"
className="revolut-bank__input"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</label>
<label className="revolut-bank__field">
<span>Cardholder name</span>
<input
type="text"
className="revolut-bank__input"
value={cardholder}
onChange={(e) => setCardholder(e.target.value)}
/>
</label>
<label className="revolut-bank__field">
<span>Last 4 digits (optional)</span>
<input
type="text"
maxLength={4}
className="revolut-bank__input"
placeholder="Auto"
value={cardLast4}
onChange={(e) => setCardLast4(e.target.value.replace(/\D/g, '').slice(0, 4))}
/>
</label>
</div>
<button
type="button"
className="revolut-bank__submit"
disabled={pos.loading}
onClick={() =>
pos.loadCard({
cardProductId: productId,
amount,
cardholderName: cardholder,
cardLast4: cardLast4 || undefined,
})
}
>
{pos.loading ? 'Loading card…' : `Load ${formatUsd(amount)} from zBank M2`}
</button>
{pos.lastLoad?.card && (
<div className="revolut-bank__protocol-result">
<p>
<strong>Card:</strong> {(pos.lastLoad.card as { cardLast4: string }).cardLast4} ·{' '}
{(pos.lastLoad.card as { cardId: string }).cardId}
</p>
<p>
<strong>Balance:</strong> {formatUsd((pos.lastLoad.card as { balance: string }).balance)}
</p>
</div>
)}
</div>
)}
{tab === 'pos' && (
<div className="zbank-pos__panel" role="tabpanel">
<div className="revolut-bank__protocol-form">
<label className="revolut-bank__field">
<span>Z Card</span>
<select
className="revolut-bank__input"
value={selectedCardId}
onChange={(e) => setSelectedCardId(e.target.value)}
>
<option value="">Select card</option>
{pos.cards.map((c) => (
<option key={c.cardId} value={c.cardId}>
{c.cardLast4} {formatUsd(c.balance)} ({c.productLabel})
</option>
))}
</select>
</label>
<label className="revolut-bank__field">
<span>POS terminal</span>
<select
className="revolut-bank__input"
value={terminalId}
onChange={(e) => setTerminalId(e.target.value)}
>
{terminals.map((t) => (
<option key={t.terminalId} value={t.terminalId}>
{t.merchantName}
</option>
))}
</select>
</label>
<label className="revolut-bank__field">
<span>Sale amount (USD)</span>
<input
type="text"
inputMode="decimal"
className="revolut-bank__input"
value={saleAmount}
onChange={(e) => setSaleAmount(e.target.value)}
/>
</label>
</div>
<button
type="button"
className="revolut-bank__submit zbank-pos__tap"
disabled={pos.loading || !selectedCardId}
onClick={() =>
pos.posSale({
cardId: selectedCardId,
terminalId,
amount: saleAmount,
})
}
>
<NasaIcon name="money" size={18} />
{pos.loading ? 'Processing…' : `Tap to pay ${formatUsd(saleAmount)}`}
</button>
{pos.lastSale && (
<div className="revolut-bank__protocol-result">
<p>
<strong>Status:</strong> {String(pos.lastSale.status)}
</p>
{pos.lastSale.merchantName && (
<p>
<strong>Merchant:</strong> {String(pos.lastSale.merchantName)}
</p>
)}
</div>
)}
</div>
)}
{pos.cards.length > 0 && (
<div className="zbank-pos__cards">
<h3 className="zbank-pos__cards-title">Loaded cards</h3>
<ul>
{pos.cards.map((c) => (
<li key={c.cardId} className="zbank-pos__card-row">
<span className="zbank-pos__card-chip" aria-hidden>
Z
</span>
<div>
<p className="zbank-pos__card-label">
{c.productLabel} · {c.cardLast4}
</p>
<p className="zbank-pos__card-meta">{c.cardholderName}</p>
</div>
<span className="zbank-pos__card-balance">{formatUsd(c.balance)}</span>
</li>
))}
</ul>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,126 @@
import { useCallback, useEffect, useState } from 'react';
import { settlementApi } from '../../config/dex';
import { zBankAuthHeaders } from './useZBankAuth';
export type ConsumerAccount = {
id: string;
currency: string;
name: string;
flag: string;
balance: string;
availableBalance: string;
type: string;
moneyLayer: string;
details: {
iban: string | null;
bic: string;
accountNumber: string;
sortCode?: string | null;
routingNumber?: string;
bankName: string;
bankAddress: string;
shareText: string;
};
};
export type ConsumerCard = {
cardId: string;
tier: string;
label: string;
gradient: string;
network: string;
last4: string;
cardholderName: string;
currency: string;
balance: string;
status: string;
productId: string;
expiry?: string;
};
export type ConsumerTransaction = {
id: string;
type: string;
title: string;
subtitle: string;
amount: string;
currency: string;
direction: 'in' | 'out';
status: string;
createdAt: string;
reference?: string;
};
export type ConsumerOverview = {
asOf: string;
profile: {
displayName: string;
plan: string;
planTier: string;
planPrice: string;
email: string;
phone: string;
address: {
line1: string;
city: string;
postcode: string;
country: string;
countryName: string;
};
memberSince: string;
kycStatus: string;
kycLevel: string;
officeId: number;
externalId: string;
};
totalBalance: string;
totalBalanceCurrency: string;
accounts: ConsumerAccount[];
cards: ConsumerCard[];
transactions: ConsumerTransaction[];
limits: {
currency: string;
dailyTransferLimit: string;
dailyTransferUsed: string;
monthlyTransferLimit: string;
monthlyTransferUsed: string;
cardSpendDailyLimit: string;
cardSpendDailyUsed: string;
atmDailyLimit: string;
atmDailyUsed: string;
topUpMonthlyLimit: string;
};
fineractConnected: boolean;
fineractLive: boolean;
};
export function useRevolutBank() {
const [overview, setOverview] = useState<ConsumerOverview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(settlementApi('/z-bank/consumer/overview'), {
headers: zBankAuthHeaders(),
});
const data = (await res.json()) as ConsumerOverview & { error?: string };
if (!res.ok) throw new Error(data.error || `Consumer API ${res.status}`);
setOverview(data);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load account');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
const t = setInterval(refresh, 30_000);
return () => clearInterval(t);
}, [refresh]);
return { overview, loading, error, refresh };
}

View File

@@ -0,0 +1,96 @@
import { useCallback, useEffect, useState } from 'react';
import { settlementApi } from '../../config/dex';
const SESSION_KEY = 'zbank_session_token';
export type PortalSession = {
userId: string;
displayName: string;
phone: string;
email?: string;
};
export function getZBankSessionToken(): string | null {
return localStorage.getItem(SESSION_KEY);
}
export function zBankAuthHeaders(): HeadersInit {
const token = getZBankSessionToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
export function useZBankAuth() {
const [requireAuth, setRequireAuth] = useState(false);
const [session, setSession] = useState<PortalSession | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const bootstrap = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(settlementApi('/z-bank/auth/bootstrap'));
const data = (await res.json()) as { requireAuth?: boolean; error?: string };
if (!res.ok) throw new Error(data.error || `Bootstrap ${res.status}`);
setRequireAuth(Boolean(data.requireAuth));
const token = getZBankSessionToken();
if (!data.requireAuth) {
setSession(null);
return;
}
if (!token) {
setSession(null);
return;
}
const sRes = await fetch(settlementApi('/z-bank/auth/session'), {
headers: { Authorization: `Bearer ${token}` },
});
if (!sRes.ok) {
localStorage.removeItem(SESSION_KEY);
setSession(null);
return;
}
const sData = (await sRes.json()) as { session: PortalSession };
setSession(sData.session);
} catch (e) {
setError(e instanceof Error ? e.message : 'Auth bootstrap failed');
} finally {
setLoading(false);
}
}, []);
const login = useCallback(async (phone: string, passcode: string) => {
setError(null);
const res = await fetch(settlementApi('/z-bank/auth/login'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, passcode }),
});
const data = (await res.json()) as { token?: string; session?: PortalSession; error?: string };
if (!res.ok) throw new Error(data.error || 'Login failed');
if (data.token) localStorage.setItem(SESSION_KEY, data.token);
setSession(data.session ?? null);
return data.session!;
}, []);
const logout = useCallback(async () => {
const token = getZBankSessionToken();
if (token) {
await fetch(settlementApi('/z-bank/auth/logout'), {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => undefined);
}
localStorage.removeItem(SESSION_KEY);
setSession(null);
}, []);
useEffect(() => {
bootstrap();
}, [bootstrap]);
const authenticated = !requireAuth || session !== null;
return { requireAuth, session, loading, error, authenticated, login, logout, refresh: bootstrap };
}

View File

@@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from 'react';
import { omnlApiHeaders, settlementApi } from '../../config/dex';
export type ZBankPosConfig = {
officeId: number;
issuer: { legalName: string; brand: string; guosmmNotice: string };
cardProducts: Array<{ id: string; label: string; currency: string; network: string; maxBalance: string; minLoad: string }>;
posTerminals: Array<{ terminalId: string; merchantName: string; mcc: string }>;
};
export type ZBankCard = {
cardId: string;
productId: string;
productLabel: string;
cardholderName: string;
cardLast4: string;
currency: string;
balance: string;
status: string;
};
export type ProofOfFundsDoc = {
documentType: string;
generatedAtUtc: string;
settlementRef: string;
attestation: { primaryGlCode: string; primaryAmountUsd: string; currency: string; methodology: string };
issuer: { legalName: string; brand: string; officeId: number };
integrity?: { sha256: string };
};
function idem(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
export function useZBankPos() {
const [config, setConfig] = useState<ZBankPosConfig | null>(null);
const [cards, setCards] = useState<ZBankCard[]>([]);
const [pof, setPof] = useState<ProofOfFundsDoc | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastSale, setLastSale] = useState<Record<string, unknown> | null>(null);
const [lastLoad, setLastLoad] = useState<Record<string, unknown> | null>(null);
const refreshConfig = useCallback(async () => {
try {
const res = await fetch(settlementApi('/z-bank/pos/config'));
if (res.ok) setConfig((await res.json()) as ZBankPosConfig);
} catch {
/* optional */
}
}, []);
const refreshCards = useCallback(async () => {
try {
const res = await fetch(settlementApi('/z-bank/consumer/cards'));
if (!res.ok) {
const authRes = await fetch(settlementApi('/z-bank/pos/cards'), { headers: omnlApiHeaders(false) });
if (authRes.ok) {
const data = (await authRes.json()) as { cards: ZBankCard[] };
setCards(data.cards ?? []);
}
return;
}
const data = (await res.json()) as { cards: ZBankCard[] };
setCards(
(data.cards ?? []).map((c) => ({
cardId: c.cardId,
productId: c.productId,
productLabel: c.label,
cardholderName: c.cardholderName,
cardLast4: c.last4,
currency: c.currency,
balance: c.balance,
status: c.status,
})),
);
} catch {
/* optional */
}
}, []);
const fetchProofOfFunds = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(settlementApi('/z-bank/proof-of-funds'));
const data = (await res.json()) as ProofOfFundsDoc & { error?: string };
if (!res.ok) throw new Error(data.error || `POF failed (${res.status})`);
setPof(data);
return data;
} catch (e) {
const msg = e instanceof Error ? e.message : 'Proof of funds failed';
setError(msg);
throw e;
} finally {
setLoading(false);
}
}, []);
const loadCard = useCallback(
async (body: {
cardProductId: string;
amount: string;
cardholderName?: string;
cardLast4?: string;
}) => {
setLoading(true);
setError(null);
try {
const res = await fetch(settlementApi('/z-bank/pos/card-load'), {
method: 'POST',
headers: omnlApiHeaders(),
body: JSON.stringify({
idempotencyKey: idem('ZBANK-LOAD'),
...body,
}),
});
const data = (await res.json()) as { settlement?: Record<string, unknown>; card?: ZBankCard; error?: string };
if (!res.ok) throw new Error(data.error || `Card load failed (${res.status})`);
setLastLoad(data);
await refreshCards();
return data;
} catch (e) {
const msg = e instanceof Error ? e.message : 'Card load failed';
setError(msg);
throw e;
} finally {
setLoading(false);
}
},
[refreshCards],
);
const posSale = useCallback(
async (body: { cardId: string; terminalId: string; amount: string; merchantReference?: string }) => {
setLoading(true);
setError(null);
try {
const res = await fetch(settlementApi('/z-bank/pos/sale'), {
method: 'POST',
headers: omnlApiHeaders(),
body: JSON.stringify({
idempotencyKey: idem('ZBANK-POS'),
...body,
}),
});
const data = (await res.json()) as Record<string, unknown> & { error?: string; declineReason?: string };
if (!res.ok) throw new Error(String(data.declineReason || data.error || `POS declined (${res.status})`));
setLastSale(data);
await refreshCards();
return data;
} catch (e) {
const msg = e instanceof Error ? e.message : 'POS sale failed';
setError(msg);
throw e;
} finally {
setLoading(false);
}
},
[refreshCards],
);
useEffect(() => {
refreshConfig();
refreshCards();
}, [refreshConfig, refreshCards]);
return {
config,
cards,
pof,
loading,
error,
lastSale,
lastLoad,
fetchProofOfFunds,
loadCard,
posSale,
refreshCards,
clearError: () => setError(null),
};
}

View File

@@ -10,6 +10,8 @@ export type SettlementPhase =
| 'HYBX_RAIL_DISPATCHED'
| 'ISO20022_ARCHIVED'
| 'CHAIN_MINT_REQUESTED'
| 'CHAIN_BRIDGE_REQUESTED'
| 'CHAIN_BRIDGE_PENDING'
| 'SETTLED'
| 'FAILED';
@@ -20,6 +22,11 @@ export type SettlementResult = {
fineractJournalRef?: string;
hybxPaymentId?: string;
chainTxHash?: string;
mintChainId?: number;
destinationChainId?: number;
bridgeTxHash?: string;
bridgeMessageId?: string;
mainnetTxHash?: string;
error?: string;
layerSteps?: {
from: string;
@@ -240,11 +247,14 @@ export function useZBankSettlement() {
tokenAddress: string;
amount: string;
recipientAddress: string;
targetChainId?: number;
}) =>
run('M2 token load', async () => {
const r = await postJson<SettlementResult>(exchangeApi('/token-load'), {
const r = await postJson<SettlementResult>(settlementApi('/token-load'), {
idempotencyKey: idem('Z-LOAD'),
officeId: 24,
currency: 'USD',
targetChainId: body.targetChainId ?? 138,
...body,
});
return { ok: r.ok, status: r.status, data: r.data };
@@ -290,8 +300,15 @@ export function useZBankSettlement() {
);
const zBankSettleM0ToM4 = useCallback(
(body: { amount: string; creditorIban: string; recipientAddress?: string; tokenLineId?: string }) =>
(body: {
amount: string;
creditorIban: string;
recipientAddress?: string;
tokenLineId?: string;
targetChainId?: number;
}) =>
run('Z Online Bank M0→M4 settlement', async () => {
const targetChainId = body.targetChainId ?? 138;
const payload = {
idempotencyKey: idem('Z-M0M4'),
officeId: 24,
@@ -301,7 +318,8 @@ export function useZBankSettlement() {
beneficiaryName: 'Z Online Bank',
remittanceInfo: 'Z Online Bank full-stack settlement M0→M1→M2→M3→M4',
recipientAddress: body.recipientAddress,
tokenLineId: body.tokenLineId ?? 'USD-M2',
tokenLineId: body.tokenLineId ?? (targetChainId === 1 ? 'WETH-M2' : 'USD-M2'),
targetChainId,
};
let r = await postJson<SettlementResult>(settlementApi('/z-bank/settle-m0-m4'), payload);
if (r.status === 404 || r.status === 405) {
@@ -313,8 +331,8 @@ export function useZBankSettlement() {
convertToCrypto: body.recipientAddress
? {
enabled: true,
targetChainId: 138,
tokenLineId: body.tokenLineId ?? 'USD-M2',
targetChainId,
tokenLineId: body.tokenLineId ?? (targetChainId === 1 ? 'WETH-M2' : 'USD-M2'),
recipientAddress: body.recipientAddress,
}
: undefined,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
import type { SettlementBridgeRail } from './types';
/** CCIP WETH9 bridge destinations supported from chain 138 (Phase 1). */
export declare const CCIP_WETH9_DESTINATION_CHAIN_IDS: readonly [1];
/** Token line IDs eligible for CCIP WETH9 bridge to mainnet (Phase 1). */
export declare const CCIP_WETH9_TOKEN_LINES: Set<string>;
export declare function resolveBridgeRail(targetChainId: number, tokenLineId: string, explicit?: SettlementBridgeRail): SettlementBridgeRail | null;
export declare function isCcipWeth9TokenLine(tokenLineId: string): boolean;
export declare function assertBridgeDestinationAllowed(sourceChainId: number, destChainId: number, tokenLineId: string, bridgeRail?: SettlementBridgeRail): {
bridgeRail: SettlementBridgeRail;
mintChainId: number;
};

View File

@@ -0,0 +1,67 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CCIP_WETH9_TOKEN_LINES = exports.CCIP_WETH9_DESTINATION_CHAIN_IDS = void 0;
exports.resolveBridgeRail = resolveBridgeRail;
exports.isCcipWeth9TokenLine = isCcipWeth9TokenLine;
exports.assertBridgeDestinationAllowed = assertBridgeDestinationAllowed;
const chain_registry_1 = require("./chain-registry");
/** CCIP WETH9 bridge destinations supported from chain 138 (Phase 1). */
exports.CCIP_WETH9_DESTINATION_CHAIN_IDS = [1];
/** Token line IDs eligible for CCIP WETH9 bridge to mainnet (Phase 1). */
exports.CCIP_WETH9_TOKEN_LINES = new Set(['ETH-M2', 'WETH-M2']);
const WETH_LINE_SYMBOLS = new Set(['ETH', 'WETH', 'WETH9', 'WETH10']);
function resolveBridgeRail(targetChainId, tokenLineId, explicit) {
if (targetChainId === 138)
return null;
if (explicit)
return explicit;
if (targetChainId === 1 && isCcipWeth9TokenLine(tokenLineId)) {
return 'ccip-weth9';
}
if (targetChainId === 1) {
return 'trustless-lockbox';
}
return null;
}
function isCcipWeth9TokenLine(tokenLineId) {
const normalized = tokenLineId.toUpperCase();
if (exports.CCIP_WETH9_TOKEN_LINES.has(normalized))
return true;
const base = normalized.split('-')[0];
return WETH_LINE_SYMBOLS.has(base);
}
function assertBridgeDestinationAllowed(sourceChainId, destChainId, tokenLineId, bridgeRail) {
const registry = (0, chain_registry_1.loadOmnlChainRegistry)();
if (sourceChainId !== registry.primaryChainId) {
throw new Error(`Bridge source must be primary hub chain ${registry.primaryChainId}, got ${sourceChainId}`);
}
const dest = (0, chain_registry_1.getChainById)(destChainId);
if (!dest || dest.status !== 'active') {
throw new Error(`Bridge destination chain ${destChainId} is not active in OMNL registry`);
}
const hub = (0, chain_registry_1.getChainById)(sourceChainId);
if (hub?.settlement.bridgeDestinations && !hub.settlement.bridgeDestinations.includes(destChainId)) {
throw new Error(`Chain ${destChainId} is not in hub ${sourceChainId} bridgeDestinations`);
}
if (destChainId === sourceChainId) {
throw new Error('Bridge destination must differ from mint chain');
}
const rail = resolveBridgeRail(destChainId, tokenLineId, bridgeRail);
if (!rail) {
throw new Error(`No settlement bridge rail configured for chain ${destChainId} and line ${tokenLineId}`);
}
if (rail === 'ccip-weth9') {
if (!exports.CCIP_WETH9_DESTINATION_CHAIN_IDS.includes(destChainId)) {
throw new Error(`CCIP WETH9 bridge does not support destination chain ${destChainId}`);
}
if (!isCcipWeth9TokenLine(tokenLineId)) {
throw new Error(`Token line ${tokenLineId} is not eligible for CCIP WETH9 bridge (Phase 1); use trustless-lockbox (Phase 2)`);
}
}
if (rail === 'trustless-lockbox') {
if (destChainId !== 1) {
throw new Error(`Trustless lockbox bridge only supports destination chain 1, got ${destChainId}`);
}
}
return { bridgeRail: rail, mintChainId: sourceChainId };
}

View File

@@ -13,6 +13,7 @@ export type OmnlChainEntry = {
m2LoadEnabled: boolean;
swapEnabled: boolean;
bridgeEnabled: boolean;
bridgeDestinations?: number[];
officeId: number;
};
complianceEnv?: string;

View File

@@ -5,3 +5,4 @@ export * from './verbiage';
export * from './state-machine';
export * from './m2-token-registry';
export * from './chain-registry';
export * from './bridge-validation';

View File

@@ -21,3 +21,4 @@ __exportStar(require("./verbiage"), exports);
__exportStar(require("./state-machine"), exports);
__exportStar(require("./m2-token-registry"), exports);
__exportStar(require("./chain-registry"), exports);
__exportStar(require("./bridge-validation"), exports);

View File

@@ -10,6 +10,8 @@ const ORDER = [
'HYBX_RAIL_DISPATCHED',
'ISO20022_ARCHIVED',
'CHAIN_MINT_REQUESTED',
'CHAIN_BRIDGE_REQUESTED',
'CHAIN_BRIDGE_PENDING',
'SETTLED',
];
function nextPhase(current) {

View File

@@ -3,7 +3,8 @@ export type MoneyLayer = 'M0' | 'M1' | 'M2' | 'M3' | 'M4' | 'M00';
export type PaymentRail = 'SWIFT' | 'SEPA' | 'TARGET2' | 'RTGS' | 'ACH' | 'FEDWIRE' | 'INTERNAL' | 'CHAIN138';
export type TradeFinanceInstrument = 'SBLC' | 'DLC' | 'BG' | 'LS';
export type SwiftMessageKind = 'MT103' | 'MT102' | 'MT202' | 'MT910';
export type SettlementPhase = 'RECEIVED' | 'VALIDATED' | 'VERBIAGE_ROLLED' | 'FINERACT_POSTED' | 'HYBX_RAIL_DISPATCHED' | 'ISO20022_ARCHIVED' | 'CHAIN_MINT_REQUESTED' | 'SETTLED' | 'FAILED';
export type SettlementBridgeRail = 'ccip-weth9' | 'trustless-lockbox';
export type SettlementPhase = 'RECEIVED' | 'VALIDATED' | 'VERBIAGE_ROLLED' | 'FINERACT_POSTED' | 'HYBX_RAIL_DISPATCHED' | 'ISO20022_ARCHIVED' | 'CHAIN_MINT_REQUESTED' | 'CHAIN_BRIDGE_REQUESTED' | 'CHAIN_BRIDGE_PENDING' | 'SETTLED' | 'FAILED';
export type ExternalTransferRequest = {
idempotencyKey: string;
officeId: number;
@@ -34,6 +35,7 @@ export type ExternalTransferRequest = {
targetChainId: number;
tokenLineId: string;
recipientAddress: string;
bridgeRail?: SettlementBridgeRail;
};
};
export type TokenLoadRequest = {
@@ -45,6 +47,7 @@ export type TokenLoadRequest = {
amount: string;
recipientAddress: string;
currency?: string;
targetChainId?: number;
};
export type TransferRequest = {
idempotencyKey: string;
@@ -83,6 +86,7 @@ export type ZBankM0M4Request = {
/** When set, M2→M3 hop posts GL + requests Chain 138 mint */
recipientAddress?: string;
tokenLineId?: string;
targetChainId?: number;
};
export type SettlementRecord = {
settlementId: string;
@@ -93,6 +97,11 @@ export type SettlementRecord = {
hybxPaymentId?: string;
iso20022MessageId?: string;
chainTxHash?: string;
mintChainId?: number;
destinationChainId?: number;
bridgeTxHash?: string;
bridgeMessageId?: string;
mainnetTxHash?: string;
verbiageDocument?: string;
layerSteps?: ZBankLayerStep[];
errors: string[];
@@ -132,3 +141,50 @@ export type MoneySupplySnapshot = {
inFlightSwift: string;
};
};
export type ZBankCardLoadRequest = {
idempotencyKey: string;
officeId?: number;
cardProductId: string;
amount: string;
currency?: string;
cardholderName?: string;
/** Last 4 digits for display; full PAN not stored */
cardLast4?: string;
};
export type ZBankPosSaleRequest = {
idempotencyKey: string;
officeId?: number;
cardId: string;
terminalId: string;
amount: string;
currency?: string;
merchantReference?: string;
};
export type ZBankPrepaidCard = {
cardId: string;
productId: string;
productLabel: string;
officeId: number;
cardholderName: string;
cardLast4: string;
currency: string;
balance: string;
status: 'active' | 'blocked';
createdAt: string;
updatedAt: string;
lastLoadAt?: string;
lastSaleAt?: string;
};
export type ZBankPosSaleRecord = {
saleId: string;
idempotencyKey: string;
cardId: string;
terminalId: string;
merchantName: string;
amount: string;
currency: string;
fineractJournalRef?: string;
status: 'approved' | 'declined';
declineReason?: string;
createdAt: string;
};

View File

@@ -33,6 +33,10 @@ function rollExternalTransferVerbiage(req) {
}
if (req.convertToCrypto?.enabled) {
lines.push(`Post-settlement crypto conversion: chain ${req.convertToCrypto.targetChainId}${req.convertToCrypto.recipientAddress}`);
if (req.convertToCrypto.targetChainId !== 138) {
const rail = req.convertToCrypto.bridgeRail ?? 'ccip-weth9';
lines.push(`Cross-chain bridge: mint on chain 138 → bridge operator custody → ${rail} → chain ${req.convertToCrypto.targetChainId}`);
}
}
lines.push('', 'DISCLAIMER: Internal OMNL operator verbiage — not SWIFT NET transmission.', 'Fineract SoR + ISO 20022 archive bind settlement finality.');
return lines.join('\n');

39
packages/settlement-core/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,39 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^20.11.0
version: 20.19.43
typescript:
specifier: ^5.4.0
version: 5.9.3
packages:
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@types/node@20.19.43':
dependencies:
undici-types: 6.21.0
typescript@5.9.3: {}
undici-types@6.21.0: {}

View File

@@ -0,0 +1,83 @@
import type { SettlementBridgeRail } from './types';
import { getChainById, loadOmnlChainRegistry } from './chain-registry';
/** CCIP WETH9 bridge destinations supported from chain 138 (Phase 1). */
export const CCIP_WETH9_DESTINATION_CHAIN_IDS = [1] as const;
/** Token line IDs eligible for CCIP WETH9 bridge to mainnet (Phase 1). */
export const CCIP_WETH9_TOKEN_LINES = new Set(['ETH-M2', 'WETH-M2']);
const WETH_LINE_SYMBOLS = new Set(['ETH', 'WETH', 'WETH9', 'WETH10']);
export function resolveBridgeRail(
targetChainId: number,
tokenLineId: string,
explicit?: SettlementBridgeRail,
): SettlementBridgeRail | null {
if (targetChainId === 138) return null;
if (explicit) return explicit;
if (targetChainId === 1 && isCcipWeth9TokenLine(tokenLineId)) {
return 'ccip-weth9';
}
if (targetChainId === 1) {
return 'trustless-lockbox';
}
return null;
}
export function isCcipWeth9TokenLine(tokenLineId: string): boolean {
const normalized = tokenLineId.toUpperCase();
if (CCIP_WETH9_TOKEN_LINES.has(normalized)) return true;
const base = normalized.split('-')[0];
return WETH_LINE_SYMBOLS.has(base);
}
export function assertBridgeDestinationAllowed(
sourceChainId: number,
destChainId: number,
tokenLineId: string,
bridgeRail?: SettlementBridgeRail,
): { bridgeRail: SettlementBridgeRail; mintChainId: number } {
const registry = loadOmnlChainRegistry();
if (sourceChainId !== registry.primaryChainId) {
throw new Error(`Bridge source must be primary hub chain ${registry.primaryChainId}, got ${sourceChainId}`);
}
const dest = getChainById(destChainId);
if (!dest || dest.status !== 'active') {
throw new Error(`Bridge destination chain ${destChainId} is not active in OMNL registry`);
}
const hub = getChainById(sourceChainId);
if (hub?.settlement.bridgeDestinations && !hub.settlement.bridgeDestinations.includes(destChainId)) {
throw new Error(`Chain ${destChainId} is not in hub ${sourceChainId} bridgeDestinations`);
}
if (destChainId === sourceChainId) {
throw new Error('Bridge destination must differ from mint chain');
}
const rail = resolveBridgeRail(destChainId, tokenLineId, bridgeRail);
if (!rail) {
throw new Error(`No settlement bridge rail configured for chain ${destChainId} and line ${tokenLineId}`);
}
if (rail === 'ccip-weth9') {
if (!(CCIP_WETH9_DESTINATION_CHAIN_IDS as readonly number[]).includes(destChainId)) {
throw new Error(`CCIP WETH9 bridge does not support destination chain ${destChainId}`);
}
if (!isCcipWeth9TokenLine(tokenLineId)) {
throw new Error(
`Token line ${tokenLineId} is not eligible for CCIP WETH9 bridge (Phase 1); use trustless-lockbox (Phase 2)`,
);
}
}
if (rail === 'trustless-lockbox') {
if (destChainId !== 1) {
throw new Error(`Trustless lockbox bridge only supports destination chain 1, got ${destChainId}`);
}
}
return { bridgeRail: rail, mintChainId: sourceChainId };
}

View File

@@ -16,6 +16,7 @@ export type OmnlChainEntry = {
m2LoadEnabled: boolean;
swapEnabled: boolean;
bridgeEnabled: boolean;
bridgeDestinations?: number[];
officeId: number;
};
complianceEnv?: string;

View File

@@ -5,3 +5,4 @@ export * from './verbiage';
export * from './state-machine';
export * from './m2-token-registry';
export * from './chain-registry';
export * from './bridge-validation';

View File

@@ -8,6 +8,8 @@ const ORDER: SettlementPhase[] = [
'HYBX_RAIL_DISPATCHED',
'ISO20022_ARCHIVED',
'CHAIN_MINT_REQUESTED',
'CHAIN_BRIDGE_REQUESTED',
'CHAIN_BRIDGE_PENDING',
'SETTLED',
];

View File

@@ -16,6 +16,8 @@ export type TradeFinanceInstrument = 'SBLC' | 'DLC' | 'BG' | 'LS';
export type SwiftMessageKind = 'MT103' | 'MT102' | 'MT202' | 'MT910';
export type SettlementBridgeRail = 'ccip-weth9' | 'trustless-lockbox';
export type SettlementPhase =
| 'RECEIVED'
| 'VALIDATED'
@@ -24,6 +26,8 @@ export type SettlementPhase =
| 'HYBX_RAIL_DISPATCHED'
| 'ISO20022_ARCHIVED'
| 'CHAIN_MINT_REQUESTED'
| 'CHAIN_BRIDGE_REQUESTED'
| 'CHAIN_BRIDGE_PENDING'
| 'SETTLED'
| 'FAILED';
@@ -57,6 +61,7 @@ export type ExternalTransferRequest = {
targetChainId: number;
tokenLineId: string;
recipientAddress: string;
bridgeRail?: SettlementBridgeRail;
};
};
@@ -69,6 +74,7 @@ export type TokenLoadRequest = {
amount: string;
recipientAddress: string;
currency?: string;
targetChainId?: number;
};
export type TransferRequest = {
@@ -110,6 +116,7 @@ export type ZBankM0M4Request = {
/** When set, M2→M3 hop posts GL + requests Chain 138 mint */
recipientAddress?: string;
tokenLineId?: string;
targetChainId?: number;
};
export type SettlementRecord = {
@@ -121,6 +128,11 @@ export type SettlementRecord = {
hybxPaymentId?: string;
iso20022MessageId?: string;
chainTxHash?: string;
mintChainId?: number;
destinationChainId?: number;
bridgeTxHash?: string;
bridgeMessageId?: string;
mainnetTxHash?: string;
verbiageDocument?: string;
layerSteps?: ZBankLayerStep[];
errors: string[];
@@ -138,3 +150,54 @@ export type MoneySupplySnapshot = {
M4: { nearMoney: string; gl1000: string; glContingent: string; inFlightSwift: string };
metaFiatRealRail: { pendingSettlement: string; inFlightSwift: string };
};
export type ZBankCardLoadRequest = {
idempotencyKey: string;
officeId?: number;
cardProductId: string;
amount: string;
currency?: string;
cardholderName?: string;
/** Last 4 digits for display; full PAN not stored */
cardLast4?: string;
};
export type ZBankPosSaleRequest = {
idempotencyKey: string;
officeId?: number;
cardId: string;
terminalId: string;
amount: string;
currency?: string;
merchantReference?: string;
};
export type ZBankPrepaidCard = {
cardId: string;
productId: string;
productLabel: string;
officeId: number;
cardholderName: string;
cardLast4: string;
currency: string;
balance: string;
status: 'active' | 'blocked';
createdAt: string;
updatedAt: string;
lastLoadAt?: string;
lastSaleAt?: string;
};
export type ZBankPosSaleRecord = {
saleId: string;
idempotencyKey: string;
cardId: string;
terminalId: string;
merchantName: string;
amount: string;
currency: string;
fineractJournalRef?: string;
status: 'approved' | 'declined';
declineReason?: string;
createdAt: string;
};

View File

@@ -31,6 +31,12 @@ export function rollExternalTransferVerbiage(req: ExternalTransferRequest): stri
lines.push(
`Post-settlement crypto conversion: chain ${req.convertToCrypto.targetChainId}${req.convertToCrypto.recipientAddress}`,
);
if (req.convertToCrypto.targetChainId !== 138) {
const rail = req.convertToCrypto.bridgeRail ?? 'ccip-weth9';
lines.push(
`Cross-chain bridge: mint on chain 138 → bridge operator custody → ${rail} → chain ${req.convertToCrypto.targetChainId}`,
);
}
}
lines.push(
'',

View File

@@ -18,7 +18,7 @@ source "$ENV_FILE"
set -u
set +a
grep -E '^(OMNL_FINERACT_|OMNL_PUBLIC_MONEY_SUPPLY=|OMNL_REQUIRE_API_KEY=|OMNL_CUSTOMER_SECURITY_PRODUCTION=|OMNL_CUSTOMER_API_KEYS=|OFFICE24_OPENING_M1_USD=|OMNL_PORTAL_INTERNAL_SECRET=|JWT_SECRET=|DATABASE_URL=|RPC_URL_138=|CHAIN_138_RPC_URL=|SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=|SETTLEMENT_ALLOW_HYBX_PRODUCTION=|OMNL_ALLOW_CHAIN_MINT_EXECUTE=|OMNL_MINT_OPERATOR_PRIVATE_KEY=|TOKEN_AGGREGATION_URL=|SWIFT_LISTENER_URL=|HYBX_MIFOS_FINERACT_SIDECAR_URL=|INFURA_PROJECT_ID=|INFURA_PROJECT_SECRET=)' "$ENV_FILE" || true
grep -E '^(OMNL_FINERACT_|OMNL_PUBLIC_MONEY_SUPPLY=|OMNL_REQUIRE_API_KEY=|OMNL_CUSTOMER_SECURITY_PRODUCTION=|OMNL_CUSTOMER_API_KEYS=|OFFICE24_OPENING_M1_USD=|OMNL_PORTAL_INTERNAL_SECRET=|JWT_SECRET=|DATABASE_URL=|RPC_URL_138=|CHAIN_138_RPC_URL=|SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE=|SETTLEMENT_ALLOW_HYBX_PRODUCTION=|OMNL_ALLOW_CHAIN_MINT_EXECUTE=|OMNL_MINT_OPERATOR_PRIVATE_KEY=|TOKEN_AGGREGATION_URL=|SWIFT_LISTENER_URL=|HYBX_MIFOS_FINERACT_SIDECAR_URL=|INFURA_PROJECT_ID=|INFURA_PROJECT_SECRET=|ZBANK_PORTAL_AUTH_CONFIG=|ZBANK_PORTAL_SESSION_SECRET=|ZBANK_PORTAL_PASSCODE=|ZBANK_PORTAL_REQUIRE_AUTH=|ZBANK_CONSUMER_CONFIG=)' "$ENV_FILE" || true
for key in OMNL_SUPER_ADMIN_OFFICE24_KEY OMNL_SUPER_ADMIN_EXCHANGE_KEY OMNL_SUPER_ADMIN_SECURE_KEY OMNL_SUPER_ADMIN_FOREX_KEY; do
val="${!key:-}"

View File

@@ -0,0 +1,41 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.requestChainBridge = requestChainBridge;
const axios_1 = __importDefault(require("axios"));
async function requestChainBridge(payload) {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const key = process.env.OMNL_API_KEY || '';
const headers = { 'Content-Type': 'application/json' };
if (key)
headers.Authorization = `Bearer ${key}`;
try {
const { data } = await axios_1.default.post(`${base}/api/v1/omnl/settlement/bridge-to-chain`, {
settlementRef: payload.settlementRef,
sourceChainId: 138,
destinationChainId: payload.destinationChainId,
tokenAddress: payload.tokenAddress,
amount: payload.amount,
recipient: payload.recipient,
bridgeRail: payload.bridgeRail,
dryRun: payload.dryRun,
decimals: payload.decimals,
wrapNative: payload.wrapNative,
}, { headers, timeout: 180000 });
return {
bridgeTxHash: data.bridgeTxHash,
messageId: data.messageId,
status: String(data.status ?? (payload.dryRun ? 'DRY_RUN' : 'CHAIN_BRIDGE_PENDING')),
};
}
catch (err) {
if (axios_1.default.isAxiosError(err) && err.response?.status === 404) {
return {
status: payload.dryRun ? 'DRY_RUN' : 'PENDING_BRIDGE_ENDPOINT',
};
}
throw err;
}
}

View File

@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.forwardSwiftToListener = forwardSwiftToListener;
exports.buildMt103Stub = buildMt103Stub;
exports.buildMt102Stub = buildMt102Stub;
exports.buildMt202Stub = buildMt202Stub;
exports.buildMt910Stub = buildMt910Stub;
const axios_1 = __importDefault(require("axios"));
async function forwardSwiftToListener(raw) {
const url = (process.env.SWIFT_LISTENER_URL || 'http://192.168.11.114:8788').replace(/\/$/, '');
@@ -40,3 +42,25 @@ ${fields.beneficiary}
:70:${fields.remittance}
-}`;
}
/** MT202 — interbank cover / institution transfer (SBLC correspondent leg). */
function buildMt202Stub(fields) {
const ord = fields.orderingInstitution ? `:52A:${fields.orderingInstitution}\n` : '';
const acct = fields.accountWithInstitution ? `:57A:${fields.accountWithInstitution}\n` : '';
const ben = fields.beneficiaryInstitution ? `:58A:${fields.beneficiaryInstitution}\n` : '';
return `{1:F01OMNLUS33XXXX0000000000}{2:I202BANKUS33XXXXN}{4:
:20:${fields.senderRef}
:21:${fields.relatedRef}
:32A:${fields.valueDate.replace(/-/g, '').slice(2)}${fields.currency}${fields.amount.replace('.', ',')}
${ord}${acct}${ben}:70:${fields.remittance}
-}`;
}
/** MT910 — confirmation of credit (advice to beneficiary bank). */
function buildMt910Stub(fields) {
const acct = fields.accountWithInstitution ? `:57A:${fields.accountWithInstitution}\n` : '';
return `{1:F01OMNLUS33XXXX0000000000}{2:I910BANKUS33XXXXN}{4:
:20:${fields.transactionRef}
:21:${fields.relatedRef}
:32A:${fields.valueDate.replace(/-/g, '').slice(2)}${fields.currency}${fields.amount.replace('.', ',')}
${acct}:72:${fields.remittance}
-}`;
}

View File

@@ -11,8 +11,37 @@ const office_scope_1 = require("../../workflows/office-scope");
const settlement_store_1 = require("../../store/settlement-store");
const swift_inbound_1 = require("../../workflows/swift-inbound");
const z_bank_m0_m4_1 = require("../../workflows/z-bank-m0-m4");
const zbank_proof_of_funds_1 = require("../../workflows/zbank-proof-of-funds");
const zbank_card_load_1 = require("../../workflows/zbank-card-load");
const zbank_pos_sale_1 = require("../../workflows/zbank-pos-sale");
const zbank_pos_config_1 = require("../../services/zbank-pos-config");
const zbank_card_store_1 = require("../../store/zbank-card-store");
const fineract_1 = require("../../adapters/fineract");
const zbank_consumer_banking_1 = require("../../services/zbank-consumer-banking");
const zbank_consumer_config_1 = require("../../services/zbank-consumer-config");
const zbank_portal_auth_1 = require("../../services/zbank-portal-auth");
const auth_1 = require("../../middleware/auth");
function onlineBankEnabled() {
const cfg = (0, config_1.loadConfig)();
return cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
}
function allowOnlineBankPublic(req, res) {
if (onlineBankEnabled())
return true;
return (0, auth_1.requireApiKey)(req, res);
}
function allowZBankConsumer(req, res) {
if (!onlineBankEnabled())
return (0, auth_1.requireApiKey)(req, res);
if (!(0, zbank_portal_auth_1.portalAuthRequired)())
return true;
const session = (0, zbank_portal_auth_1.verifySession)((0, zbank_portal_auth_1.extractSessionToken)(req.headers.authorization));
if (!session) {
res.status(401).json({ error: 'Portal login required' });
return false;
}
return true;
}
async function respondPublicMoneySupply(res, officeId) {
const cfg = (0, config_1.loadConfig)();
const allow = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
@@ -233,6 +262,7 @@ function createSettlementRouter() {
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
// convertToCrypto.targetChainId: 138 (default) or 1 (mint on 138 + CCIP/trustless bridge to Ethereum)
const body = req.body;
const merged = (0, office_scope_1.bindOffice24)({
...body,
@@ -351,5 +381,209 @@ function createSettlementRouter() {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/auth/bootstrap', (req, res) => {
if (!allowOnlineBankPublic(req, res))
return;
try {
const cfg = (0, zbank_portal_auth_1.loadZBankPortalAuthConfig)();
res.json({
requireAuth: (0, zbank_portal_auth_1.portalAuthRequired)(),
users: cfg.users.map((u) => ({ phone: u.phone, displayName: u.displayName })),
});
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.post('/z-bank/auth/login', (req, res) => {
if (!allowOnlineBankPublic(req, res))
return;
const phone = String(req.body?.phone ?? '').trim();
const passcode = String(req.body?.passcode ?? '');
if (!phone || !passcode) {
res.status(400).json({ error: 'Phone and passcode are required' });
return;
}
try {
const { token, session } = (0, zbank_portal_auth_1.loginWithPasscode)(phone, passcode);
res.json({
token,
session: {
userId: session.userId,
displayName: session.displayName,
phone: session.phone,
email: session.email,
},
});
}
catch (e) {
res.status(401).json({ error: e instanceof Error ? e.message : 'Login failed' });
}
});
router.get('/z-bank/auth/session', (req, res) => {
if (!allowOnlineBankPublic(req, res))
return;
const session = (0, zbank_portal_auth_1.verifySession)((0, zbank_portal_auth_1.extractSessionToken)(req.headers.authorization));
if (!session) {
res.status(401).json({ error: 'Invalid or expired session' });
return;
}
res.json({
session: {
userId: session.userId,
displayName: session.displayName,
phone: session.phone,
email: session.email,
},
});
});
router.post('/z-bank/auth/logout', (req, res) => {
if (!allowOnlineBankPublic(req, res))
return;
res.json({ ok: true });
});
router.get('/z-bank/consumer/overview', async (req, res) => {
if (!allowZBankConsumer(req, res))
return;
try {
res.json(await (0, zbank_consumer_banking_1.buildConsumerOverview)());
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/consumer/profile', async (req, res) => {
if (!allowZBankConsumer(req, res))
return;
try {
const overview = await (0, zbank_consumer_banking_1.buildConsumerOverview)();
res.json({ profile: overview.profile, limits: overview.limits });
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/consumer/accounts', async (req, res) => {
if (!allowZBankConsumer(req, res))
return;
try {
const posCfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
const { accounts, totalBalance, fineractConnected, fineractLive } = await (0, zbank_consumer_banking_1.buildConsumerAccounts)(posCfg.officeId);
res.json({ accounts, totalBalance, totalBalanceCurrency: 'USD', fineractConnected, fineractLive });
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/consumer/cards', (req, res) => {
if (!allowZBankConsumer(req, res))
return;
(0, zbank_consumer_banking_1.buildConsumerOverview)()
.then((o) => res.json({ cards: o.cards }))
.catch((e) => res.status(500).json({ error: e instanceof Error ? e.message : String(e) }));
});
router.get('/z-bank/consumer/transactions', (req, res) => {
if (!allowZBankConsumer(req, res))
return;
const limit = Number(req.query.limit ?? 50);
res.json({ transactions: (0, zbank_consumer_banking_1.buildConsumerTransactions)(limit) });
});
router.get('/z-bank/consumer/limits', (req, res) => {
if (!allowZBankConsumer(req, res))
return;
res.json((0, zbank_consumer_config_1.loadZBankConsumerConfig)().limits);
});
router.get('/z-bank/consumer/accounts/:accountId', async (req, res) => {
if (!allowZBankConsumer(req, res))
return;
try {
const posCfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
const { accounts } = await (0, zbank_consumer_banking_1.buildConsumerAccounts)(posCfg.officeId);
const account = accounts.find((a) => a.id === req.params.accountId);
if (!account) {
res.status(404).json({ error: 'Account not found' });
return;
}
res.json(account);
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/proof-of-funds', async (req, res) => {
const cfg = (0, config_1.loadConfig)();
const allowPublic = cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
if (!allowPublic && !(0, auth_1.requireApiKey)(req, res))
return;
try {
const officeRaw = String(req.query.officeId ?? '').trim();
const officeId = officeRaw ? parseInt(officeRaw, 10) : (0, zbank_pos_config_1.loadZBankPosConfig)().officeId;
res.json(await (0, zbank_proof_of_funds_1.buildZBankProofOfFunds)(officeId));
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/pos/config', (_req, res) => {
try {
const cfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
res.json({
officeId: cfg.officeId,
issuer: cfg.issuer,
cardProducts: cfg.cardProducts,
posTerminals: cfg.posTerminals,
});
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/pos/cards', (req, res) => {
if (!allowZBankConsumer(req, res))
return;
res.json({ cards: zbank_card_store_1.zbankCardStore.listCards(Number(req.query.limit ?? 50)) });
});
router.get('/z-bank/pos/cards/:cardId', (req, res) => {
if (!(0, auth_1.requireApiKey)(req, res))
return;
const card = zbank_card_store_1.zbankCardStore.getCard(req.params.cardId);
if (!card) {
res.status(404).json({ error: 'Card not found' });
return;
}
res.json(card);
});
router.post('/z-bank/pos/card-load', async (req, res) => {
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const body = req.body;
if (!body.idempotencyKey || !body.cardProductId || !body.amount) {
res.status(400).json({ error: 'idempotencyKey, cardProductId, amount required' });
return;
}
const out = await (0, zbank_card_load_1.processZBankCardLoad)(body);
res.status(out.settlement.phase === 'FAILED' ? 422 : 200).json(out);
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.post('/z-bank/pos/sale', async (req, res) => {
if (!(0, auth_1.requireApiKey)(req, res))
return;
try {
const body = req.body;
if (!body.idempotencyKey || !body.cardId || !body.terminalId || !body.amount) {
res.status(400).json({ error: 'idempotencyKey, cardId, terminalId, amount required' });
return;
}
const sale = await (0, zbank_pos_sale_1.processZBankPosSale)(body);
res.status(sale?.status === 'declined' ? 422 : 200).json(sale);
}
catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
return router;
}

View File

@@ -21,6 +21,15 @@ function loadConfig() {
const configPath = process.env.SETTLEMENT_MIDDLEWARE_CONFIG ||
repoConfigPath('config/settlement-middleware.v1.json');
cached = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
if (cached.production.allowChainBridgeExecute === undefined) {
cached.production.allowChainBridgeExecute = false;
}
if (process.env.SETTLEMENT_ALLOW_CHAIN_BRIDGE_EXECUTE === '1') {
cached.production.allowChainBridgeExecute = true;
}
if (process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE === '1') {
cached.production.allowChainMintExecute = true;
}
return cached;
}
function loadOfficeProfile() {

View File

@@ -40,8 +40,19 @@ const dotenv = __importStar(require("dotenv"));
const path_1 = __importDefault(require("path"));
const fs_1 = require("fs");
const server_1 = require("./server");
const rootEnv = path_1.default.resolve(__dirname, '../../../.env');
if ((0, fs_1.existsSync)(rootEnv))
dotenv.config({ path: rootEnv });
const envCandidates = [
path_1.default.resolve(__dirname, '../../../.env'),
path_1.default.resolve(__dirname, '../../.env'),
path_1.default.resolve(__dirname, '../.env'),
];
for (const envPath of envCandidates) {
if ((0, fs_1.existsSync)(envPath)) {
dotenv.config({ path: envPath });
}
}
const serviceEnv = path_1.default.resolve(__dirname, '../.env');
if ((0, fs_1.existsSync)(serviceEnv)) {
dotenv.config({ path: serviceEnv, override: true });
}
dotenv.config();
(0, server_1.startServer)();

View File

@@ -0,0 +1,263 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildConsumerTransactions = buildConsumerTransactions;
exports.buildConsumerAccounts = buildConsumerAccounts;
exports.buildConsumerOverview = buildConsumerOverview;
const settlement_core_1 = require("@dbis/settlement-core");
const fineract_1 = require("../adapters/fineract");
const config_1 = require("../config");
const settlement_store_1 = require("../store/settlement-store");
const zbank_card_store_1 = require("../store/zbank-card-store");
const zbank_pos_config_1 = require("./zbank-pos-config");
const zbank_consumer_config_1 = require("./zbank-consumer-config");
const ACCOUNT_FLAGS = {
USD: '🇺🇸',
EUR: '🇪🇺',
GBP: '🇬🇧',
SAVINGS: '💰',
RESERVE: '🏦',
CRYPTO: '⛓',
};
function parseTs(iso) {
return iso ? Date.parse(iso) || 0 : 0;
}
function settlementToTransaction(r) {
const req = r.request;
const amount = req.amount ?? '0';
const currency = req.currency ?? 'USD';
const failed = r.phase === 'FAILED';
const pending = !failed && r.phase !== 'SETTLED';
const remittance = req.remittanceInfo ?? req.beneficiaryName ?? r.settlementId;
const outbound = Boolean(req.creditorIban || req.beneficiaryName);
let type = 'settlement';
if (remittance.toLowerCase().includes('card load'))
type = 'card_load';
else if (req.rail?.includes('SWIFT') || req.creditorIban)
type = 'transfer';
else if (remittance.toLowerCase().includes('swap') || remittance.toLowerCase().includes('exchange')) {
type = 'exchange';
}
return {
id: r.settlementId,
type,
title: remittance.slice(0, 64) || 'Settlement',
subtitle: `${req.rail ?? 'OMNL'} · ${r.phase}`,
amount,
currency,
direction: type === 'card_load' ? 'in' : outbound ? 'out' : 'in',
status: failed ? 'failed' : pending ? 'pending' : 'completed',
createdAt: r.updatedAt ?? r.createdAt,
reference: r.fineractJournalRef ?? r.hybxPaymentId,
};
}
function saleToTransaction(s) {
return {
id: s.saleId,
type: 'card_payment',
title: s.merchantName,
subtitle: `Card •••• ${s.cardId.slice(-4)} · ${s.terminalId}`,
amount: s.amount,
currency: s.currency,
direction: 'out',
status: s.status === 'approved' ? 'completed' : 'failed',
createdAt: s.createdAt,
reference: s.fineractJournalRef,
};
}
function cardToDisplay(card, cfg) {
const tierMeta = cfg.cardTierMap[card.productId] ?? {
tier: 'standard',
label: card.productLabel,
gradient: 'standard',
network: 'Visa',
};
return {
cardId: card.cardId,
tier: tierMeta.tier,
label: tierMeta.label,
gradient: tierMeta.gradient,
network: tierMeta.network,
last4: card.cardLast4,
cardholderName: card.cardholderName,
currency: card.currency,
balance: card.balance,
status: card.status === 'blocked' ? 'frozen' : 'active',
productId: card.productId,
expiry: '12/28',
};
}
function virtualCards(cfg, holder) {
return cfg.virtualCardProducts.map((p, i) => ({
cardId: `virtual-${p.productId}`,
tier: p.tier,
label: p.label,
gradient: p.gradient,
network: p.network,
last4: String(9000 + i * 111).slice(-4),
cardholderName: holder,
currency: 'USD',
balance: '0.00',
status: 'virtual',
productId: p.productId,
expiry: '12/28',
}));
}
function buildConsumerTransactions(limit = 50) {
const settlements = settlement_store_1.settlementStore
.list(200)
.map(settlementToTransaction)
.filter(Boolean);
const sales = zbank_card_store_1.zbankCardStore.listSales(100).map(saleToTransaction);
return [...settlements, ...sales]
.sort((a, b) => parseTs(b.createdAt) - parseTs(a.createdAt))
.slice(0, limit);
}
async function buildConsumerAccounts(officeId) {
const cfg = (0, zbank_consumer_config_1.loadZBankConsumerConfig)();
const posCfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
const fineractConnected = await (0, fineract_1.fineractOfficeReachable)(officeId);
const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0);
const fineractLive = fineractConnected && hasActivity;
const m1 = snap.M1.circulating ?? snap.M1.gl2100 ?? '0';
const m2 = snap.M2.broadMoney ?? snap.M2.gl2200 ?? '0';
const m0 = snap.M0.gl1050 ?? '0';
const m3 = snap.M3.tokenLiabilities ?? snap.M3.gl2300 ?? '0';
const m4 = snap.M4.nearMoney ?? snap.M4.gl1000 ?? '0';
const cardPool = balances[posCfg.gl.cardPrepaidLiability] ?? '0';
const usdDetails = cfg.accountDetails.USD;
const eurDetails = cfg.accountDetails.EUR;
const gbpDetails = cfg.accountDetails.GBP;
const accounts = [
{
id: 'usd-main',
currency: 'USD',
name: 'Main · USD',
flag: ACCOUNT_FLAGS.USD,
balance: m1,
availableBalance: m1,
type: 'current',
moneyLayer: 'M1',
details: usdDetails,
},
{
id: 'usd-savings',
currency: 'USD',
name: 'Savings',
flag: ACCOUNT_FLAGS.SAVINGS,
balance: m2,
availableBalance: m2,
type: 'savings',
moneyLayer: 'M2',
details: { ...usdDetails, accountName: 'USD Savings (M2 tokenized)' },
},
{
id: 'eur',
currency: 'EUR',
name: 'Euro',
flag: ACCOUNT_FLAGS.EUR,
balance: '0.00',
availableBalance: '0.00',
type: 'current',
moneyLayer: 'M1',
details: eurDetails,
},
{
id: 'gbp',
currency: 'GBP',
name: 'British Pound',
flag: ACCOUNT_FLAGS.GBP,
balance: '0.00',
availableBalance: '0.00',
type: 'current',
moneyLayer: 'M1',
details: gbpDetails,
},
{
id: 'usd-cards',
currency: 'USD',
name: 'Card balance',
flag: '💳',
balance: cardPool,
availableBalance: cardPool,
type: 'current',
moneyLayer: 'M2',
details: { ...usdDetails, accountName: 'Prepaid card pool (GL 2210)' },
},
{
id: 'reserve',
currency: 'USD',
name: 'Reserve',
flag: ACCOUNT_FLAGS.RESERVE,
balance: m0,
availableBalance: m0,
type: 'reserve',
moneyLayer: 'M0',
details: { ...usdDetails, accountName: 'M0 base money reserve' },
},
{
id: 'crypto',
currency: 'USD',
name: 'On-chain',
flag: ACCOUNT_FLAGS.CRYPTO,
balance: m3,
availableBalance: m3,
type: 'crypto',
moneyLayer: 'M3',
details: { ...usdDetails, accountName: 'M3 digital / on-chain' },
},
{
id: 'swift',
currency: 'USD',
name: 'SWIFT rails',
flag: '🌐',
balance: m4,
availableBalance: m4,
type: 'fiat_rail',
moneyLayer: 'M4',
details: { ...usdDetails, accountName: 'M4 SWIFT settlement' },
},
];
const total = (parseFloat(m1 || '0') +
parseFloat(m2 || '0') +
parseFloat(cardPool || '0')).toFixed(2);
return { accounts, totalBalance: total, fineractConnected, fineractLive };
}
async function buildConsumerOverview() {
const cfg = (0, zbank_consumer_config_1.loadZBankConsumerConfig)();
const posCfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
const office = (0, config_1.loadOfficeProfile)();
const holder = office.name?.split('—')[0]?.trim() ?? 'Z Bank Customer';
const { accounts, totalBalance, fineractConnected, fineractLive } = await buildConsumerAccounts(posCfg.officeId);
const storedCards = zbank_card_store_1.zbankCardStore.listCards(20);
const cards = storedCards.length > 0
? storedCards.map((c) => cardToDisplay(c, cfg))
: virtualCards(cfg, holder);
const transactions = buildConsumerTransactions(30);
return {
asOf: new Date().toISOString(),
profile: {
displayName: holder,
plan: cfg.profile.plan,
planTier: cfg.profile.planTier,
planPrice: cfg.profile.planPrice,
email: cfg.profile.email,
phone: cfg.profile.phone,
address: cfg.profile.address,
memberSince: cfg.profile.memberSince,
kycStatus: cfg.profile.kycStatus,
kycLevel: cfg.profile.kycLevel,
officeId: posCfg.officeId,
externalId: posCfg.externalId,
},
totalBalance,
totalBalanceCurrency: 'USD',
accounts,
cards,
transactions,
limits: cfg.limits,
fineractConnected,
fineractLive,
};
}

View File

@@ -0,0 +1,20 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadZBankConsumerConfig = loadZBankConsumerConfig;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
let cached = null;
function repoConfigPath(relative) {
return path_1.default.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
}
function loadZBankConsumerConfig() {
if (cached)
return cached;
const configPath = process.env.ZBANK_CONSUMER_CONFIG?.trim() ||
repoConfigPath('config/zbank-consumer-account.v1.json');
cached = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
return cached;
}

View File

@@ -0,0 +1,145 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadZBankPortalAuthConfig = loadZBankPortalAuthConfig;
exports.issueSession = issueSession;
exports.verifySession = verifySession;
exports.extractSessionToken = extractSessionToken;
exports.loginWithPasscode = loginWithPasscode;
exports.portalAuthRequired = portalAuthRequired;
exports.generateSessionSecret = generateSessionSecret;
const crypto_1 = require("crypto");
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const attemptLog = new Map();
let cachedConfig = null;
function repoConfigPath(relative) {
return path_1.default.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
}
function loadZBankPortalAuthConfig() {
if (cachedConfig)
return cachedConfig;
const configPath = process.env.ZBANK_PORTAL_AUTH_CONFIG?.trim() ||
repoConfigPath('config/zbank-portal-auth.v1.json');
cachedConfig = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
return cachedConfig;
}
function sessionSecret() {
const secret = process.env.ZBANK_PORTAL_SESSION_SECRET?.trim();
if (!secret || secret.length < 16) {
throw new Error('ZBANK_PORTAL_SESSION_SECRET must be set (min 16 chars) for portal login');
}
return secret;
}
function configuredPasscode() {
const code = process.env.ZBANK_PORTAL_PASSCODE?.trim();
if (!code)
throw new Error('ZBANK_PORTAL_PASSCODE is not configured');
return code;
}
function normalizePhone(input) {
return input.replace(/[\s-]/g, '');
}
function signPayload(payload) {
return (0, crypto_1.createHmac)('sha256', sessionSecret()).update(payload).digest('base64url');
}
function issueSession(user) {
const cfg = loadZBankPortalAuthConfig();
const now = Math.floor(Date.now() / 1000);
const body = {
userId: user.id,
displayName: user.displayName,
phone: user.phone,
email: user.email,
iat: now,
exp: now + cfg.sessionTtlHours * 3600,
};
const payload = Buffer.from(JSON.stringify(body)).toString('base64url');
return `${payload}.${signPayload(payload)}`;
}
function verifySession(token) {
if (!token?.includes('.'))
return null;
const [payload, sig] = token.split('.', 2);
if (!payload || !sig)
return null;
const expected = signPayload(payload);
try {
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !(0, crypto_1.timingSafeEqual)(a, b))
return null;
}
catch {
return null;
}
try {
const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
if (!session.exp || session.exp < Math.floor(Date.now() / 1000))
return null;
return session;
}
catch {
return null;
}
}
function extractSessionToken(authHeader) {
if (!authHeader)
return undefined;
const m = authHeader.match(/^Bearer\s+(.+)$/i);
return m?.[1]?.trim();
}
function secureCompare(a, b) {
const ha = (0, crypto_1.createHash)('sha256').update(a).digest();
const hb = (0, crypto_1.createHash)('sha256').update(b).digest();
return (0, crypto_1.timingSafeEqual)(ha, hb);
}
function checkLockout(key) {
const entry = attemptLog.get(key);
if (entry?.lockedUntil && entry.lockedUntil > Date.now()) {
const mins = Math.ceil((entry.lockedUntil - Date.now()) / 60000);
return `Too many attempts. Try again in ${mins} minute(s).`;
}
return null;
}
function recordFailedAttempt(key) {
const cfg = loadZBankPortalAuthConfig();
const entry = attemptLog.get(key) ?? { count: 0 };
entry.count += 1;
if (entry.count >= cfg.maxAttempts) {
entry.lockedUntil = Date.now() + cfg.lockoutMinutes * 60_000;
entry.count = 0;
}
attemptLog.set(key, entry);
}
function loginWithPasscode(phone, passcode) {
const cfg = loadZBankPortalAuthConfig();
const normalized = normalizePhone(phone);
const lockKey = (0, crypto_1.createHash)('sha256').update(normalized).digest('hex').slice(0, 16);
const locked = checkLockout(lockKey);
if (locked)
throw new Error(locked);
const user = cfg.users.find((u) => normalizePhone(u.phone) === normalized);
if (!user) {
recordFailedAttempt(lockKey);
throw new Error('Invalid phone number or passcode');
}
if (!secureCompare(passcode, configuredPasscode())) {
recordFailedAttempt(lockKey);
throw new Error('Invalid phone number or passcode');
}
attemptLog.delete(lockKey);
const token = issueSession(user);
const session = verifySession(token);
if (!session)
throw new Error('Failed to create session');
return { token, session };
}
function portalAuthRequired() {
return process.env.ZBANK_PORTAL_REQUIRE_AUTH === '1' || process.env.ZBANK_PORTAL_REQUIRE_AUTH === 'true';
}
function generateSessionSecret() {
return (0, crypto_1.randomBytes)(32).toString('hex');
}

View File

@@ -0,0 +1,27 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadZBankPosConfig = loadZBankPosConfig;
exports.findCardProduct = findCardProduct;
exports.findPosTerminal = findPosTerminal;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
let cached = null;
function repoConfigPath(relative) {
return path_1.default.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
}
function loadZBankPosConfig() {
if (cached)
return cached;
const configPath = process.env.ZBANK_POS_CONFIG?.trim() || repoConfigPath('config/zbank-pos-cards.v1.json');
cached = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
return cached;
}
function findCardProduct(cfg, productId) {
return cfg.cardProducts.find((p) => p.id === productId);
}
function findPosTerminal(cfg, terminalId) {
return cfg.posTerminals.find((t) => t.terminalId === terminalId);
}

View File

@@ -0,0 +1,81 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.zbankCardStore = exports.ZBankCardStore = void 0;
exports.newCardId = newCardId;
exports.newSaleId = newSaleId;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const crypto_1 = require("crypto");
const zbank_pos_config_1 = require("../services/zbank-pos-config");
const baseDir = process.env.ZBANK_CARD_STORE_DIR ||
path_1.default.resolve(__dirname, '../../../data/zbank-card-store');
function storeDir() {
const officeId = (0, zbank_pos_config_1.loadZBankPosConfig)().officeId;
const dir = path_1.default.join(baseDir, `office-${officeId}`);
if (!fs_1.default.existsSync(dir))
fs_1.default.mkdirSync(dir, { recursive: true });
return dir;
}
function cardPath(cardId) {
return path_1.default.join(storeDir(), `card-${cardId}.json`);
}
function salePath(saleId) {
return path_1.default.join(storeDir(), `sale-${saleId}.json`);
}
class ZBankCardStore {
saveCard(card) {
fs_1.default.writeFileSync(cardPath(card.cardId), JSON.stringify(card, null, 2));
}
getCard(cardId) {
const p = cardPath(cardId);
if (!fs_1.default.existsSync(p))
return null;
return JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
}
listCards(limit = 50) {
const dir = storeDir();
if (!fs_1.default.existsSync(dir))
return [];
return fs_1.default
.readdirSync(dir)
.filter((f) => f.startsWith('card-') && f.endsWith('.json'))
.slice(0, limit)
.map((f) => JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, f), 'utf8')));
}
saveSale(sale) {
fs_1.default.writeFileSync(salePath(sale.saleId), JSON.stringify(sale, null, 2));
fs_1.default.writeFileSync(path_1.default.join(storeDir(), `sale-key-${sale.idempotencyKey}.json`), sale.saleId);
}
getSaleByIdempotency(key) {
const ref = path_1.default.join(storeDir(), `sale-key-${key}.json`);
if (!fs_1.default.existsSync(ref))
return null;
const id = fs_1.default.readFileSync(ref, 'utf8').trim();
const p = salePath(id);
if (!fs_1.default.existsSync(p))
return null;
return JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
}
listSales(limit = 50) {
const dir = storeDir();
if (!fs_1.default.existsSync(dir))
return [];
return fs_1.default
.readdirSync(dir)
.filter((f) => f.startsWith('sale-') && f.endsWith('.json') && !f.includes('sale-key-'))
.slice(0, limit)
.map((f) => JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, f), 'utf8')))
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
}
}
exports.ZBankCardStore = ZBankCardStore;
exports.zbankCardStore = new ZBankCardStore();
function newCardId() {
return `ZCARD-${(0, crypto_1.randomUUID)().slice(0, 8).toUpperCase()}`;
}
function newSaleId() {
return `ZSALE-${(0, crypto_1.randomUUID)().slice(0, 8).toUpperCase()}`;
}

View File

@@ -11,6 +11,7 @@ const hybx_production_1 = require("../adapters/hybx-production");
const omnl_1 = require("../adapters/omnl");
const swift_1 = require("../adapters/swift");
const settlement_store_1 = require("../store/settlement-store");
const settlement_bridge_1 = require("./settlement-bridge");
async function processExternalTransfer(input) {
const cfg = (0, config_1.loadConfig)();
const profile = (0, config_1.loadOfficeProfile)();
@@ -112,6 +113,29 @@ async function processExternalTransfer(input) {
});
if (cfg.rails.swift.enabled) {
await (0, swift_1.forwardSwiftToListener)(swiftRaw);
if (req.tradeFinance?.instrument === 'SBLC') {
const cover = (0, swift_1.buildMt202Stub)({
senderRef: `${req.idempotencyKey}-COV`,
relatedRef: req.idempotencyKey,
currency: req.currency,
amount: req.amount,
valueDate: req.valueDate,
orderingInstitution: 'OMNLUS33',
beneficiaryInstitution: req.creditorBic ?? 'BANKUS33',
remittance: `SBLC cover ${req.tradeFinance.reference ?? req.idempotencyKey}`,
});
await (0, swift_1.forwardSwiftToListener)(cover);
}
const advice = (0, swift_1.buildMt910Stub)({
transactionRef: `${req.idempotencyKey}-ADV`,
relatedRef: req.idempotencyKey,
currency: req.currency,
amount: req.amount,
valueDate: req.valueDate,
accountWithInstitution: req.creditorBic,
remittance: `Credit confirmation ${req.beneficiaryName}`,
});
await (0, swift_1.forwardSwiftToListener)(advice);
}
const iso = await (0, omnl_1.archiveIso20022)({
messageType: swiftKind === 'MT102' ? 'pacs.008' : 'pacs.008',
@@ -130,14 +154,15 @@ async function processExternalTransfer(input) {
advance('FAILED');
return record;
}
const mint = await (0, omnl_1.requestTokenMint)({
record = await (0, settlement_bridge_1.executeSettlementMintAndBridge)(record, {
lineId: req.convertToCrypto.tokenLineId,
amount: m3Check.loadAmount,
recipient: req.convertToCrypto.recipientAddress,
settlementRef: record.settlementId,
dryRun: !cfg.production.allowChainMintExecute,
});
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
tokenAddress: (0, settlement_bridge_1.resolveM2TokenAddress)(req.convertToCrypto.tokenLineId),
dryRunMint: !cfg.production.allowChainMintExecute,
dryRunBridge: !cfg.production.allowChainBridgeExecute,
}, advance);
if (record.phase === 'FAILED')
return record;
}
else {
advance('CHAIN_MINT_REQUESTED');

View File

@@ -0,0 +1,118 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveM2TokenAddress = resolveM2TokenAddress;
exports.requiresCrossChainBridge = requiresCrossChainBridge;
exports.bridgeAlreadyCompleted = bridgeAlreadyCompleted;
exports.executeSettlementMintAndBridge = executeSettlementMintAndBridge;
exports.primaryChainId = primaryChainId;
const settlement_core_1 = require("@dbis/settlement-core");
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const bridge_1 = require("../adapters/bridge");
const omnl_1 = require("../adapters/omnl");
const PRIMARY_CHAIN_ID = 138;
function loadTokenRegistry() {
const registryPath = process.env.OMNL_M2_TOKEN_REGISTRY ||
path_1.default.resolve(__dirname, '../../../config/omnl-m2-token-registry.v1.json');
return JSON.parse(fs_1.default.readFileSync(registryPath, 'utf8'));
}
function resolveM2TokenAddress(lineId, symbol) {
const registry = loadTokenRegistry();
const byLine = (0, settlement_core_1.findM2TokenByLineId)(registry, lineId);
if (byLine?.address.startsWith('0x') && byLine.address !== '0x0000000000000000000000000000000000000000') {
return byLine.address;
}
if (symbol) {
const bySym = registry.tokens.find((t) => t.symbol.toLowerCase() === symbol.toLowerCase());
if (bySym?.address.startsWith('0x') && bySym.address !== '0x0000000000000000000000000000000000000000') {
return bySym.address;
}
}
return undefined;
}
function requiresCrossChainBridge(req) {
return Boolean(req.convertToCrypto?.enabled &&
req.convertToCrypto.targetChainId !== PRIMARY_CHAIN_ID);
}
function bridgeAlreadyCompleted(record) {
return Boolean(record.bridgeTxHash ||
record.phase === 'CHAIN_BRIDGE_REQUESTED' ||
record.phase === 'CHAIN_BRIDGE_PENDING' ||
(record.phase === 'SETTLED' && record.destinationChainId && record.destinationChainId !== PRIMARY_CHAIN_ID));
}
async function executeSettlementMintAndBridge(record, params, advance) {
const req = record.request;
const crypto = req.convertToCrypto;
if (!crypto?.enabled) {
advance('CHAIN_MINT_REQUESTED');
advance('SETTLED');
return record;
}
const targetChainId = crypto.targetChainId;
const mintChainId = PRIMARY_CHAIN_ID;
if (targetChainId === mintChainId) {
const mint = await (0, omnl_1.requestTokenMint)({
lineId: crypto.tokenLineId,
amount: params.amount,
recipient: crypto.recipientAddress,
settlementRef: record.settlementId,
dryRun: params.dryRunMint,
});
advance('CHAIN_MINT_REQUESTED', {
chainTxHash: mint.txHash,
mintChainId,
destinationChainId: targetChainId,
});
advance('SETTLED');
return record;
}
const { bridgeRail } = (0, settlement_core_1.assertBridgeDestinationAllowed)(mintChainId, targetChainId, crypto.tokenLineId, crypto.bridgeRail);
if (bridgeAlreadyCompleted(record)) {
advance('SETTLED');
return record;
}
const tokenAddress = params.tokenAddress ?? resolveM2TokenAddress(crypto.tokenLineId);
const mint = await (0, omnl_1.requestTokenMint)({
lineId: crypto.tokenLineId,
amount: params.amount,
recipient: crypto.recipientAddress,
settlementRef: record.settlementId,
dryRun: params.dryRunMint,
mintToBridgeOperator: true,
targetChainId,
});
advance('CHAIN_MINT_REQUESTED', {
chainTxHash: mint.txHash,
mintChainId,
destinationChainId: targetChainId,
});
if (bridgeRail === 'trustless-lockbox' && !tokenAddress?.startsWith('0x')) {
record.errors.push(`tokenAddress required for trustless bridge (line ${crypto.tokenLineId})`);
advance('FAILED');
return record;
}
const bridge = await (0, bridge_1.requestChainBridge)({
settlementRef: record.settlementId,
destinationChainId: targetChainId,
tokenAddress,
amount: params.amount,
recipient: crypto.recipientAddress,
bridgeRail,
dryRun: params.dryRunBridge,
});
advance('CHAIN_BRIDGE_REQUESTED', {
bridgeTxHash: bridge.bridgeTxHash,
bridgeMessageId: bridge.messageId,
});
if (bridge.status === 'CHAIN_BRIDGE_PENDING' || bridge.status === 'DRY_RUN') {
advance('CHAIN_BRIDGE_PENDING');
}
advance('SETTLED');
return record;
}
function primaryChainId() {
return (0, settlement_core_1.loadOmnlChainRegistry)().primaryChainId || PRIMARY_CHAIN_ID;
}

View File

@@ -10,8 +10,8 @@ const path_1 = __importDefault(require("path"));
const settlement_core_1 = require("@dbis/settlement-core");
const config_1 = require("../config");
const fineract_1 = require("../adapters/fineract");
const omnl_1 = require("../adapters/omnl");
const settlement_store_1 = require("../store/settlement-store");
const settlement_bridge_1 = require("./settlement-bridge");
function loadTokenRegistry() {
const registryPath = process.env.OMNL_M2_TOKEN_REGISTRY ||
path_1.default.resolve(__dirname, '../../../config/omnl-m2-token-registry.v1.json');
@@ -38,6 +38,7 @@ async function processTokenLoad(input) {
const profile = (0, config_1.loadOfficeProfile)();
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
const req = { ...input, officeId };
const targetChainId = req.targetChainId ?? 138;
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
if (existing?.phase === 'SETTLED')
return existing;
@@ -59,7 +60,7 @@ async function processTokenLoad(input) {
rail: 'INTERNAL',
convertToCrypto: {
enabled: true,
targetChainId: 138,
targetChainId,
tokenLineId: req.lineId,
recipientAddress: req.recipientAddress,
},
@@ -112,17 +113,15 @@ async function processTokenLoad(input) {
}
advance('HYBX_RAIL_DISPATCHED');
advance('ISO20022_ARCHIVED');
const mint = await (0, omnl_1.requestTokenMint)({
record = await (0, settlement_bridge_1.executeSettlementMintAndBridge)(record, {
lineId: req.lineId,
amount: loadAmount,
recipient: req.recipientAddress,
settlementRef: record.settlementId,
dryRun: !cfg.production.allowChainMintExecute,
symbol: req.symbol,
tokenAddress: resolveTokenAddress(req),
});
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
advance('SETTLED');
tokenAddress: resolveTokenAddress(req) ?? (0, settlement_bridge_1.resolveM2TokenAddress)(req.lineId, req.symbol),
dryRunMint: !cfg.production.allowChainMintExecute,
dryRunBridge: !cfg.production.allowChainBridgeExecute,
}, advance);
if (record.phase === 'FAILED')
return record;
return record;
}
catch (err) {

View File

@@ -0,0 +1,205 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.processZBankM0ToM4 = processZBankM0ToM4;
const uuid_1 = require("uuid");
const settlement_core_1 = require("@dbis/settlement-core");
const config_1 = require("../config");
const fineract_1 = require("../adapters/fineract");
const hybx_production_1 = require("../adapters/hybx-production");
const omnl_1 = require("../adapters/omnl");
const swift_1 = require("../adapters/swift");
const settlement_store_1 = require("../store/settlement-store");
const settlement_bridge_1 = require("./settlement-bridge");
function toExternalRequest(input, officeId) {
return {
idempotencyKey: input.idempotencyKey,
officeId,
valueDate: input.valueDate ?? new Date().toISOString().slice(0, 10),
currency: input.currency ?? 'USD',
amount: input.amount,
creditorIban: input.creditorIban,
creditorBic: input.creditorBic,
beneficiaryName: input.beneficiaryName ?? 'Z Online Bank',
orderingName: 'Z Online Bank — OMNL Office 24',
remittanceInfo: input.remittanceInfo ?? 'Z Online Bank full settlement M0→M4',
moneyLayers: settlement_core_1.ALL_MONEY_LAYERS,
rail: 'SWIFT',
swiftMessageKind: 'MT103',
convertToCrypto: input.recipientAddress
? {
enabled: true,
targetChainId: input.targetChainId ?? 138,
tokenLineId: input.tokenLineId ?? 'USD-M2',
recipientAddress: input.recipientAddress,
}
: undefined,
};
}
async function processZBankM0ToM4(input) {
const cfg = (0, config_1.loadConfig)();
const profile = (0, config_1.loadOfficeProfile)();
const officeId = (0, config_1.settlementOfficeId)(input.officeId);
const includeM3 = Boolean(input.recipientAddress?.trim());
const req = toExternalRequest(input, officeId);
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(req.idempotencyKey);
if (existing?.phase === 'SETTLED')
return existing;
const now = new Date().toISOString();
let record = existing ?? {
settlementId: (0, uuid_1.v4)(),
idempotencyKey: req.idempotencyKey,
phase: 'RECEIVED',
request: req,
layerSteps: [],
errors: [],
createdAt: now,
updatedAt: now,
};
const advance = (phase, patch = {}) => {
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
settlement_store_1.settlementStore.save(record);
};
const glProfile = {
glM0: profile.settlement.glM0 ?? cfg.moneySupply.glM0 ?? '1050',
glM1: profile.settlement.glM1 ?? cfg.moneySupply.glM1 ?? '2100',
glM2: profile.settlement.glM2 ?? cfg.moneySupply.glM2 ?? '2200',
glM3: profile.settlement.glM3 ?? cfg.moneySupply.glM3 ?? '2300',
glM4NearMoney: profile.settlement.glM4NearMoney ?? cfg.moneySupply.glM4NearMoney ?? cfg.moneySupply.glSettlement ?? '1000',
};
try {
if (!(0, settlement_core_1.isIbanValid)(req.creditorIban)) {
throw new Error(`Invalid creditor IBAN: ${req.creditorIban}`);
}
advance('VALIDATED');
const verbiage = (0, settlement_core_1.rollExternalTransferVerbiage)(req);
advance('VERBIAGE_ROLLED', { verbiageDocument: verbiage });
const amount = parseFloat(req.amount) || 0;
const hops = (0, settlement_core_1.zBankM0ToM4Hops)(includeM3);
const steps = [];
let lastJournalRef;
if (amount > 0) {
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
for (const hop of hops) {
const pair = (0, settlement_core_1.resolveZBankLayerJournal)(hop.from, hop.to, glProfile);
if (!pair) {
steps.push({
from: hop.from,
to: hop.to,
debitGl: '',
creditGl: '',
status: 'skipped',
});
continue;
}
if (hop.to === 'M3') {
const { fullyBacked } = (0, settlement_core_1.m2FiatToTokenLoadAmount)(req.amount, snap.M2.broadMoney);
const m3Check = (0, settlement_core_1.m3TokenLoadAmount)(req.amount, snap.M2.broadMoney, snap.M3.tokenLiabilities);
if (!fullyBacked || !m3Check.fullyBacked) {
steps.push({
from: hop.from,
to: hop.to,
debitGl: pair.debitGl,
creditGl: pair.creditGl,
status: 'failed',
});
throw new Error(`M2/M3 backing insufficient (${m3Check.loadAmount}/${req.amount} M3 headroom)`);
}
}
const [debitGlId, creditGlId] = await Promise.all([
(0, fineract_1.resolveGlAccountId)(pair.debitGl),
(0, fineract_1.resolveGlAccountId)(pair.creditGl),
]);
const ref = `${req.idempotencyKey}-${hop.from}${hop.to}`;
const je = await (0, fineract_1.postJournalEntry)({
officeId,
transactionDate: req.valueDate,
referenceNumber: ref,
comments: `${pair.narrative} · ${verbiage.slice(0, 200)}`,
debits: [{ glAccountId: debitGlId, amount }],
credits: [{ glAccountId: creditGlId, amount }],
});
lastJournalRef = String(je.resourceId);
steps.push({
from: hop.from,
to: hop.to,
debitGl: pair.debitGl,
creditGl: pair.creditGl,
journalRef: lastJournalRef,
status: 'posted',
});
}
}
advance('FINERACT_POSTED', { fineractJournalRef: lastJournalRef, layerSteps: steps });
if (cfg.rails.hybx.enabled) {
const hybx = new hybx_production_1.HybxProductionRail();
const pay = await hybx.dispatchPayment({
idempotencyKey: req.idempotencyKey,
amount: req.amount,
currency: req.currency,
creditorIban: req.creditorIban,
creditorBic: req.creditorBic,
beneficiaryName: req.beneficiaryName,
remittanceInfo: req.remittanceInfo,
rail: req.rail,
});
advance('HYBX_RAIL_DISPATCHED', { hybxPaymentId: pay.paymentId });
}
else {
advance('HYBX_RAIL_DISPATCHED');
}
const swiftRaw = (0, swift_1.buildMt103Stub)({
senderRef: req.idempotencyKey,
currency: req.currency,
amount: req.amount,
valueDate: req.valueDate,
orderingCustomer: req.orderingName ?? cfg.omnlLegalName,
beneficiary: req.beneficiaryName,
iban: req.creditorIban,
bic: req.creditorBic,
remittance: req.remittanceInfo,
});
if (cfg.rails.swift.enabled) {
await (0, swift_1.forwardSwiftToListener)(swiftRaw);
}
const iso = await (0, omnl_1.archiveIso20022)({
messageType: 'pacs.008',
direction: 'OUTBOUND',
raw: swiftRaw,
settlementRef: record.settlementId,
});
advance('ISO20022_ARCHIVED', { iso20022MessageId: iso.messageId });
if (req.convertToCrypto?.enabled && req.convertToCrypto.recipientAddress) {
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
const { loadAmount, fullyBacked } = (0, settlement_core_1.m2FiatToTokenLoadAmount)(req.amount, snap.M2.broadMoney);
const m3Check = (0, settlement_core_1.m3TokenLoadAmount)(loadAmount, snap.M2.broadMoney, snap.M3.tokenLiabilities);
if (fullyBacked && m3Check.fullyBacked) {
record = await (0, settlement_bridge_1.executeSettlementMintAndBridge)(record, {
lineId: req.convertToCrypto.tokenLineId,
amount: m3Check.loadAmount,
tokenAddress: (0, settlement_bridge_1.resolveM2TokenAddress)(req.convertToCrypto.tokenLineId),
dryRunMint: !cfg.production.allowChainMintExecute,
dryRunBridge: !cfg.production.allowChainBridgeExecute,
}, advance);
if (record.phase === 'FAILED')
return record;
}
else {
record.errors.push('M3 mint skipped — insufficient M2/M3 backing');
advance('CHAIN_MINT_REQUESTED');
}
}
else {
advance('CHAIN_MINT_REQUESTED');
}
advance('SETTLED');
return record;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
record.errors.push(msg);
advance('FAILED');
return record;
}
}

View File

@@ -0,0 +1,130 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.processZBankCardLoad = processZBankCardLoad;
const uuid_1 = require("uuid");
const settlement_core_1 = require("@dbis/settlement-core");
const fineract_1 = require("../adapters/fineract");
const settlement_store_1 = require("../store/settlement-store");
const zbank_pos_config_1 = require("../services/zbank-pos-config");
const zbank_card_store_1 = require("../store/zbank-card-store");
async function processZBankCardLoad(input) {
const cfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
const officeId = input.officeId ?? cfg.officeId;
const product = (0, zbank_pos_config_1.findCardProduct)(cfg, input.cardProductId);
if (!product) {
throw new Error(`Unknown card product: ${input.cardProductId}`);
}
const amount = parseFloat(input.amount);
const minLoad = parseFloat(product.minLoad);
const maxBalance = parseFloat(product.maxBalance);
if (!Number.isFinite(amount) || amount < minLoad) {
throw new Error(`Minimum load is ${product.minLoad} ${product.currency}`);
}
const existing = settlement_store_1.settlementStore.getByIdempotencyKey(input.idempotencyKey);
if (existing?.phase === 'SETTLED') {
const cardId = existing.request.remittanceInfo?.match(/card (ZCARD-[A-Z0-9]+)/)?.[1];
return { settlement: existing, card: cardId ? zbank_card_store_1.zbankCardStore.getCard(cardId) : null };
}
const now = new Date().toISOString();
let record = existing ?? {
settlementId: (0, uuid_1.v4)(),
idempotencyKey: input.idempotencyKey,
phase: 'RECEIVED',
request: {
idempotencyKey: input.idempotencyKey,
officeId,
valueDate: now.slice(0, 10),
currency: input.currency ?? product.currency,
amount: input.amount,
creditorIban: 'ZBANK-CARD-LOAD',
beneficiaryName: input.cardholderName ?? 'Z Card holder',
remittanceInfo: `zBank card load · ${product.label}`,
moneyLayers: ['M2'],
rail: 'INTERNAL',
},
errors: [],
createdAt: now,
updatedAt: now,
};
const advance = (phase, patch = {}) => {
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
settlement_store_1.settlementStore.save(record);
};
try {
advance('VALIDATED');
const balances = await (0, fineract_1.fetchGlBalances)(officeId);
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(officeId, balances);
const m2Available = parseFloat(snap.M2.broadMoney) || 0;
if (amount > m2Available) {
record.errors.push(`Insufficient M2 broad money (${snap.M2.broadMoney} available)`);
advance('FAILED');
return { settlement: record, card: null };
}
advance('VERBIAGE_ROLLED', {
verbiageDocument: [
`zBank prepaid card load`,
`Office ${officeId} · product ${product.id}`,
`Amount ${input.amount} ${product.currency}`,
`GL ${cfg.gl.loadSource}${cfg.gl.cardPrepaidLiability}`,
].join('\n'),
});
const [m2GlId, cardGlId] = await Promise.all([
(0, fineract_1.resolveGlAccountId)(cfg.gl.loadSource),
(0, fineract_1.resolveGlAccountId)(cfg.gl.cardPrepaidLiability),
]);
const je = await (0, fineract_1.postJournalEntry)({
officeId,
transactionDate: now.slice(0, 10),
referenceNumber: input.idempotencyKey,
comments: `zBank card load ${product.label}`,
debits: [{ glAccountId: m2GlId, amount }],
credits: [{ glAccountId: cardGlId, amount }],
});
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
advance('HYBX_RAIL_DISPATCHED');
advance('ISO20022_ARCHIVED');
const cardLast4 = (input.cardLast4 ?? String(Math.floor(1000 + Math.random() * 9000))).slice(-4);
const cardId = (0, zbank_card_store_1.newCardId)();
const existingCard = zbank_card_store_1.zbankCardStore.listCards(500).find((c) => c.cardLast4 === cardLast4 && c.productId === product.id);
let card = existingCard;
if (card) {
const newBal = (parseFloat(card.balance) + amount).toFixed(2);
if (parseFloat(newBal) > maxBalance) {
record.errors.push(`Card max balance ${product.maxBalance} exceeded`);
advance('FAILED');
return { settlement: record, card };
}
card = {
...card,
balance: newBal,
updatedAt: now,
lastLoadAt: now,
};
}
else {
card = {
cardId,
productId: product.id,
productLabel: product.label,
officeId,
cardholderName: input.cardholderName ?? 'Z Card holder',
cardLast4,
currency: product.currency,
balance: amount.toFixed(2),
status: 'active',
createdAt: now,
updatedAt: now,
lastLoadAt: now,
};
}
zbank_card_store_1.zbankCardStore.saveCard(card);
record.request.remittanceInfo = `zBank card load · card ${card.cardId} ·••• ${cardLast4}`;
advance('SETTLED', { chainTxHash: card.cardId });
return { settlement: record, card };
}
catch (err) {
record.errors.push(err instanceof Error ? err.message : String(err));
advance('FAILED');
return { settlement: record, card: null };
}
}

View File

@@ -0,0 +1,86 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.processZBankPosSale = processZBankPosSale;
const fineract_1 = require("../adapters/fineract");
const zbank_pos_config_1 = require("../services/zbank-pos-config");
const zbank_card_store_1 = require("../store/zbank-card-store");
async function processZBankPosSale(input) {
const cfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
const officeId = input.officeId ?? cfg.officeId;
const terminal = (0, zbank_pos_config_1.findPosTerminal)(cfg, input.terminalId);
if (!terminal) {
throw new Error(`Unknown POS terminal: ${input.terminalId}`);
}
const dup = zbank_card_store_1.zbankCardStore.getSaleByIdempotency(input.idempotencyKey);
if (dup)
return dup;
const card = zbank_card_store_1.zbankCardStore.getCard(input.cardId);
const now = new Date().toISOString();
const amount = parseFloat(input.amount);
if (!card || card.status !== 'active') {
const declined = {
saleId: (0, zbank_card_store_1.newSaleId)(),
idempotencyKey: input.idempotencyKey,
cardId: input.cardId,
terminalId: input.terminalId,
merchantName: terminal.merchantName,
amount: input.amount,
currency: input.currency ?? 'USD',
status: 'declined',
declineReason: 'Card not found or blocked',
createdAt: now,
};
zbank_card_store_1.zbankCardStore.saveSale(declined);
return declined;
}
const balance = parseFloat(card.balance);
if (!Number.isFinite(amount) || amount <= 0 || amount > balance) {
const declined = {
saleId: (0, zbank_card_store_1.newSaleId)(),
idempotencyKey: input.idempotencyKey,
cardId: input.cardId,
terminalId: input.terminalId,
merchantName: terminal.merchantName,
amount: input.amount,
currency: input.currency ?? card.currency,
status: 'declined',
declineReason: amount > balance ? 'Insufficient card balance' : 'Invalid amount',
createdAt: now,
};
zbank_card_store_1.zbankCardStore.saveSale(declined);
return declined;
}
const [debitGlId, creditGlId] = await Promise.all([
(0, fineract_1.resolveGlAccountId)(cfg.gl.cardPrepaidLiability),
(0, fineract_1.resolveGlAccountId)(cfg.gl.posClearing),
]);
const je = await (0, fineract_1.postJournalEntry)({
officeId,
transactionDate: now.slice(0, 10),
referenceNumber: input.idempotencyKey,
comments: `POS ${terminal.terminalId} · card •••${card.cardLast4}`,
debits: [{ glAccountId: debitGlId, amount }],
credits: [{ glAccountId: creditGlId, amount }],
});
const newBalance = (balance - amount).toFixed(2);
zbank_card_store_1.zbankCardStore.saveCard({
...card,
balance: newBalance,
updatedAt: now,
lastSaleAt: now,
});
const approved = {
saleId: (0, zbank_card_store_1.newSaleId)(),
idempotencyKey: input.idempotencyKey,
cardId: input.cardId,
terminalId: input.terminalId,
merchantName: terminal.merchantName,
amount: input.amount,
currency: input.currency ?? card.currency,
fineractJournalRef: String(je.resourceId),
status: 'approved',
createdAt: now,
};
zbank_card_store_1.zbankCardStore.saveSale(approved);
return approved;
}

View File

@@ -0,0 +1,52 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildZBankProofOfFunds = buildZBankProofOfFunds;
const crypto_1 = require("crypto");
const settlement_core_1 = require("@dbis/settlement-core");
const fineract_1 = require("../adapters/fineract");
const zbank_pos_config_1 = require("../services/zbank-pos-config");
const config_1 = require("../config");
async function buildZBankProofOfFunds(officeId) {
const cfg = (0, zbank_pos_config_1.loadZBankPosConfig)();
const oid = officeId ?? cfg.officeId;
const middleware = (0, config_1.loadConfig)();
const balances = await (0, fineract_1.fetchGlBalances)(oid);
const snap = (0, settlement_core_1.buildMoneySupplySnapshot)(oid, balances);
const glCodes = [cfg.proofOfFunds.primaryGlCode, ...cfg.proofOfFunds.secondaryGlCodes];
const glBalances = glCodes.map((code) => ({
glCode: code,
amount: balances[code] ?? '0',
}));
const primaryAmount = balances[cfg.proofOfFunds.primaryGlCode] ?? snap.M1.gl2100 ?? '0';
const generatedAt = new Date().toISOString();
const body = {
documentType: 'proof-of-funds',
generatedAtUtc: generatedAt,
settlementRef: `ZBANK-POF-${generatedAt.slice(0, 10).replace(/-/g, '')}`,
issuer: {
legalName: cfg.issuer.legalName,
brand: cfg.issuer.brand,
officeId: oid,
externalId: cfg.externalId,
guosmmNotice: cfg.issuer.guosmmNotice,
},
attestation: {
primaryGlCode: cfg.proofOfFunds.primaryGlCode,
primaryAmountUsd: primaryAmount,
currency: 'USD',
methodology: cfg.proofOfFunds.methodology,
},
moneySupply: snap,
glBalances,
cardProgram: {
prepaidGl: cfg.gl.cardPrepaidLiability,
prepaidPool: balances[cfg.gl.cardPrepaidLiability] ?? '0',
loadSourceGl: cfg.gl.loadSource,
posClearingGl: cfg.gl.posClearing,
},
omnlLegalName: middleware.omnlLegalName,
disclaimer: 'Accounting attestation from Fineract GL balances — not a bank guarantee letter. For correspondent banking use official SWIFT MT799/POF where required.',
};
const sha256 = (0, crypto_1.createHash)('sha256').update(JSON.stringify(body)).digest('hex');
return { ...body, integrity: { sha256 } };
}

View File

@@ -0,0 +1,53 @@
import axios from 'axios';
export async function requestChainBridge(payload: {
settlementRef: string;
destinationChainId: number;
tokenAddress?: string;
amount: string;
recipient: string;
bridgeRail: 'ccip-weth9' | 'trustless-lockbox';
dryRun: boolean;
decimals?: number;
wrapNative?: boolean;
}): Promise<{
bridgeTxHash?: string;
messageId?: string;
status: string;
}> {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const key = process.env.OMNL_API_KEY || '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (key) headers.Authorization = `Bearer ${key}`;
try {
const { data } = await axios.post(
`${base}/api/v1/omnl/settlement/bridge-to-chain`,
{
settlementRef: payload.settlementRef,
sourceChainId: 138,
destinationChainId: payload.destinationChainId,
tokenAddress: payload.tokenAddress,
amount: payload.amount,
recipient: payload.recipient,
bridgeRail: payload.bridgeRail,
dryRun: payload.dryRun,
decimals: payload.decimals,
wrapNative: payload.wrapNative,
},
{ headers, timeout: 180000 },
);
return {
bridgeTxHash: data.bridgeTxHash,
messageId: data.messageId,
status: String(data.status ?? (payload.dryRun ? 'DRY_RUN' : 'CHAIN_BRIDGE_PENDING')),
};
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 404) {
return {
status: payload.dryRun ? 'DRY_RUN' : 'PENDING_BRIDGE_ENDPOINT',
};
}
throw err;
}
}

View File

@@ -32,6 +32,8 @@ export async function requestTokenMint(payload: {
dryRun: boolean;
symbol?: string;
tokenAddress?: string;
mintToBridgeOperator?: boolean;
targetChainId?: number;
}): Promise<{ txHash?: string; status: string }> {
const base = (process.env.TOKEN_AGGREGATION_URL || 'http://localhost:3000').replace(/\/$/, '');
const key = process.env.OMNL_API_KEY || '';

View File

@@ -1,5 +1,5 @@
import { Router, type Request, type Response } from 'express';
import type { ExternalTransferRequest, TradeFinanceInstrument, TokenLoadRequest, TransferRequest, ZBankM0M4Request } from '@dbis/settlement-core';
import type { ExternalTransferRequest, TradeFinanceInstrument, TokenLoadRequest, TransferRequest, ZBankM0M4Request, ZBankCardLoadRequest, ZBankPosSaleRequest } from '@dbis/settlement-core';
import { buildMoneySupplySnapshot, loadOmnlChainRegistry, getActiveChains } from '@dbis/settlement-core';
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../../config';
import {
@@ -13,9 +13,48 @@ import { bindOffice24 } from '../../workflows/office-scope';
import { settlementStore } from '../../store/settlement-store';
import { processSwiftInbound } from '../../workflows/swift-inbound';
import { processZBankM0ToM4 } from '../../workflows/z-bank-m0-m4';
import { buildZBankProofOfFunds } from '../../workflows/zbank-proof-of-funds';
import { processZBankCardLoad } from '../../workflows/zbank-card-load';
import { processZBankPosSale } from '../../workflows/zbank-pos-sale';
import { loadZBankPosConfig } from '../../services/zbank-pos-config';
import { zbankCardStore } from '../../store/zbank-card-store';
import { fetchGlBalances, fineractOfficeReachable } from '../../adapters/fineract';
import {
buildConsumerAccounts,
buildConsumerOverview,
buildConsumerTransactions,
} from '../../services/zbank-consumer-banking';
import { loadZBankConsumerConfig } from '../../services/zbank-consumer-config';
import {
extractSessionToken,
loadZBankPortalAuthConfig,
loginWithPasscode,
portalAuthRequired,
verifySession,
} from '../../services/zbank-portal-auth';
import { requireApiKey } from '../../middleware/auth';
function onlineBankEnabled(): boolean {
const cfg = loadConfig();
return cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
}
function allowOnlineBankPublic(req: Request, res: Response): boolean {
if (onlineBankEnabled()) return true;
return requireApiKey(req, res);
}
function allowZBankConsumer(req: Request, res: Response): boolean {
if (!onlineBankEnabled()) return requireApiKey(req, res);
if (!portalAuthRequired()) return true;
const session = verifySession(extractSessionToken(req.headers.authorization));
if (!session) {
res.status(401).json({ error: 'Portal login required' });
return false;
}
return true;
}
async function respondPublicMoneySupply(res: Response, officeId: number): Promise<void> {
const cfg = loadConfig();
const allow =
@@ -236,6 +275,7 @@ export function createSettlementRouter(): Router {
router.post('/convert/fiat-to-crypto', async (req, res) => {
if (!requireApiKey(req, res)) return;
try {
// convertToCrypto.targetChainId: 138 (default) or 1 (mint on 138 + CCIP/trustless bridge to Ethereum)
const body = req.body as ExternalTransferRequest;
const merged = bindOffice24({
...body,
@@ -352,5 +392,203 @@ export function createSettlementRouter(): Router {
}
});
router.get('/z-bank/auth/bootstrap', (req, res) => {
if (!allowOnlineBankPublic(req, res)) return;
try {
const cfg = loadZBankPortalAuthConfig();
res.json({
requireAuth: portalAuthRequired(),
users: cfg.users.map((u) => ({ phone: u.phone, displayName: u.displayName })),
});
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.post('/z-bank/auth/login', (req, res) => {
if (!allowOnlineBankPublic(req, res)) return;
const phone = String(req.body?.phone ?? '').trim();
const passcode = String(req.body?.passcode ?? '');
if (!phone || !passcode) {
res.status(400).json({ error: 'Phone and passcode are required' });
return;
}
try {
const { token, session } = loginWithPasscode(phone, passcode);
res.json({
token,
session: {
userId: session.userId,
displayName: session.displayName,
phone: session.phone,
email: session.email,
},
});
} catch (e) {
res.status(401).json({ error: e instanceof Error ? e.message : 'Login failed' });
}
});
router.get('/z-bank/auth/session', (req, res) => {
if (!allowOnlineBankPublic(req, res)) return;
const session = verifySession(extractSessionToken(req.headers.authorization));
if (!session) {
res.status(401).json({ error: 'Invalid or expired session' });
return;
}
res.json({
session: {
userId: session.userId,
displayName: session.displayName,
phone: session.phone,
email: session.email,
},
});
});
router.post('/z-bank/auth/logout', (req, res) => {
if (!allowOnlineBankPublic(req, res)) return;
res.json({ ok: true });
});
router.get('/z-bank/consumer/overview', async (req, res) => {
if (!allowZBankConsumer(req, res)) return;
try {
res.json(await buildConsumerOverview());
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/consumer/profile', async (req, res) => {
if (!allowZBankConsumer(req, res)) return;
try {
const overview = await buildConsumerOverview();
res.json({ profile: overview.profile, limits: overview.limits });
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/consumer/accounts', async (req, res) => {
if (!allowZBankConsumer(req, res)) return;
try {
const posCfg = loadZBankPosConfig();
const { accounts, totalBalance, fineractConnected, fineractLive } = await buildConsumerAccounts(
posCfg.officeId,
);
res.json({ accounts, totalBalance, totalBalanceCurrency: 'USD', fineractConnected, fineractLive });
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/consumer/cards', (req, res) => {
if (!allowZBankConsumer(req, res)) return;
buildConsumerOverview()
.then((o) => res.json({ cards: o.cards }))
.catch((e) => res.status(500).json({ error: e instanceof Error ? e.message : String(e) }));
});
router.get('/z-bank/consumer/transactions', (req, res) => {
if (!allowZBankConsumer(req, res)) return;
const limit = Number(req.query.limit ?? 50);
res.json({ transactions: buildConsumerTransactions(limit) });
});
router.get('/z-bank/consumer/limits', (req, res) => {
if (!allowZBankConsumer(req, res)) return;
res.json(loadZBankConsumerConfig().limits);
});
router.get('/z-bank/consumer/accounts/:accountId', async (req, res) => {
if (!allowZBankConsumer(req, res)) return;
try {
const posCfg = loadZBankPosConfig();
const { accounts } = await buildConsumerAccounts(posCfg.officeId);
const account = accounts.find((a) => a.id === req.params.accountId);
if (!account) {
res.status(404).json({ error: 'Account not found' });
return;
}
res.json(account);
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/proof-of-funds', async (req, res) => {
const cfg = loadConfig();
const allowPublic =
cfg.production.onlineBankMode || process.env.OMNL_PUBLIC_MONEY_SUPPLY === '1';
if (!allowPublic && !requireApiKey(req, res)) return;
try {
const officeRaw = String(req.query.officeId ?? '').trim();
const officeId = officeRaw ? parseInt(officeRaw, 10) : loadZBankPosConfig().officeId;
res.json(await buildZBankProofOfFunds(officeId));
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/pos/config', (_req, res) => {
try {
const cfg = loadZBankPosConfig();
res.json({
officeId: cfg.officeId,
issuer: cfg.issuer,
cardProducts: cfg.cardProducts,
posTerminals: cfg.posTerminals,
});
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.get('/z-bank/pos/cards', (req, res) => {
if (!allowZBankConsumer(req, res)) return;
res.json({ cards: zbankCardStore.listCards(Number(req.query.limit ?? 50)) });
});
router.get('/z-bank/pos/cards/:cardId', (req, res) => {
if (!requireApiKey(req, res)) return;
const card = zbankCardStore.getCard(req.params.cardId);
if (!card) {
res.status(404).json({ error: 'Card not found' });
return;
}
res.json(card);
});
router.post('/z-bank/pos/card-load', async (req, res) => {
if (!requireApiKey(req, res)) return;
try {
const body = req.body as ZBankCardLoadRequest;
if (!body.idempotencyKey || !body.cardProductId || !body.amount) {
res.status(400).json({ error: 'idempotencyKey, cardProductId, amount required' });
return;
}
const out = await processZBankCardLoad(body);
res.status(out.settlement.phase === 'FAILED' ? 422 : 200).json(out);
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
router.post('/z-bank/pos/sale', async (req, res) => {
if (!requireApiKey(req, res)) return;
try {
const body = req.body as ZBankPosSaleRequest;
if (!body.idempotencyKey || !body.cardId || !body.terminalId || !body.amount) {
res.status(400).json({ error: 'idempotencyKey, cardId, terminalId, amount required' });
return;
}
const sale = await processZBankPosSale(body);
res.status(sale?.status === 'declined' ? 422 : 200).json(sale);
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
}
});
return router;
}

View File

@@ -63,6 +63,7 @@ export type MiddlewareConfig = {
requireApiKey: boolean;
allowHybxProduction: boolean;
allowChainMintExecute: boolean;
allowChainBridgeExecute: boolean;
onlineBankMode?: boolean;
};
};
@@ -80,6 +81,15 @@ export function loadConfig(): MiddlewareConfig {
process.env.SETTLEMENT_MIDDLEWARE_CONFIG ||
repoConfigPath('config/settlement-middleware.v1.json');
cached = JSON.parse(fs.readFileSync(configPath, 'utf8')) as MiddlewareConfig;
if (cached.production.allowChainBridgeExecute === undefined) {
cached.production.allowChainBridgeExecute = false;
}
if (process.env.SETTLEMENT_ALLOW_CHAIN_BRIDGE_EXECUTE === '1') {
cached.production.allowChainBridgeExecute = true;
}
if (process.env.SETTLEMENT_ALLOW_CHAIN_MINT_EXECUTE === '1') {
cached.production.allowChainMintExecute = true;
}
return cached;
}

View File

@@ -3,8 +3,20 @@ import path from 'path';
import { existsSync } from 'fs';
import { startServer } from './server';
const rootEnv = path.resolve(__dirname, '../../../.env');
if (existsSync(rootEnv)) dotenv.config({ path: rootEnv });
const envCandidates = [
path.resolve(__dirname, '../../../.env'),
path.resolve(__dirname, '../../.env'),
path.resolve(__dirname, '../.env'),
];
for (const envPath of envCandidates) {
if (existsSync(envPath)) {
dotenv.config({ path: envPath });
}
}
const serviceEnv = path.resolve(__dirname, '../.env');
if (existsSync(serviceEnv)) {
dotenv.config({ path: serviceEnv, override: true });
}
dotenv.config();
startServer();
startServer();

View File

@@ -0,0 +1,360 @@
import type { SettlementRecord, ZBankPosSaleRecord, ZBankPrepaidCard } from '@dbis/settlement-core';
import { buildMoneySupplySnapshot } from '@dbis/settlement-core';
import { fetchGlBalances, fineractOfficeReachable } from '../adapters/fineract';
import { loadOfficeProfile } from '../config';
import { settlementStore } from '../store/settlement-store';
import { zbankCardStore } from '../store/zbank-card-store';
import { loadZBankPosConfig } from './zbank-pos-config';
import { loadZBankConsumerConfig, type ZBankConsumerConfig } from './zbank-consumer-config';
export type ConsumerAccount = {
id: string;
currency: string;
name: string;
flag: string;
balance: string;
availableBalance: string;
type: 'current' | 'savings' | 'reserve' | 'crypto' | 'fiat_rail';
moneyLayer: string;
details: {
iban: string | null;
bic: string;
accountNumber: string;
sortCode?: string | null;
routingNumber?: string;
bankName: string;
bankAddress: string;
shareText: string;
accountName?: string;
};
};
export type ConsumerCard = {
cardId: string;
tier: string;
label: string;
gradient: string;
network: string;
last4: string;
cardholderName: string;
currency: string;
balance: string;
status: 'active' | 'frozen' | 'virtual';
productId: string;
expiry?: string;
};
export type ConsumerTransaction = {
id: string;
type: 'transfer' | 'exchange' | 'card_payment' | 'card_load' | 'deposit' | 'settlement' | 'fee';
title: string;
subtitle: string;
amount: string;
currency: string;
direction: 'in' | 'out';
status: 'completed' | 'pending' | 'failed';
createdAt: string;
reference?: string;
};
export type ConsumerOverview = {
asOf: string;
profile: {
displayName: string;
plan: string;
planTier: string;
planPrice: string;
email: string;
phone: string;
address: ZBankConsumerConfig['profile']['address'];
memberSince: string;
kycStatus: string;
kycLevel: string;
officeId: number;
externalId: string;
};
totalBalance: string;
totalBalanceCurrency: string;
accounts: ConsumerAccount[];
cards: ConsumerCard[];
transactions: ConsumerTransaction[];
limits: ZBankConsumerConfig['limits'];
fineractConnected: boolean;
fineractLive: boolean;
};
const ACCOUNT_FLAGS: Record<string, string> = {
USD: '🇺🇸',
EUR: '🇪🇺',
GBP: '🇬🇧',
SAVINGS: '💰',
RESERVE: '🏦',
CRYPTO: '⛓',
};
function parseTs(iso?: string): number {
return iso ? Date.parse(iso) || 0 : 0;
}
function settlementToTransaction(r: SettlementRecord): ConsumerTransaction | null {
const req = r.request;
const amount = req.amount ?? '0';
const currency = req.currency ?? 'USD';
const failed = r.phase === 'FAILED';
const pending = !failed && r.phase !== 'SETTLED';
const remittance = req.remittanceInfo ?? req.beneficiaryName ?? r.settlementId;
const outbound = Boolean(req.creditorIban || req.beneficiaryName);
let type: ConsumerTransaction['type'] = 'settlement';
if (remittance.toLowerCase().includes('card load')) type = 'card_load';
else if (req.rail?.includes('SWIFT') || req.creditorIban) type = 'transfer';
else if (remittance.toLowerCase().includes('swap') || remittance.toLowerCase().includes('exchange')) {
type = 'exchange';
}
return {
id: r.settlementId,
type,
title: remittance.slice(0, 64) || 'Settlement',
subtitle: `${req.rail ?? 'OMNL'} · ${r.phase}`,
amount,
currency,
direction: type === 'card_load' ? 'in' : outbound ? 'out' : 'in',
status: failed ? 'failed' : pending ? 'pending' : 'completed',
createdAt: r.updatedAt ?? r.createdAt,
reference: r.fineractJournalRef ?? r.hybxPaymentId,
};
}
function saleToTransaction(s: ZBankPosSaleRecord): ConsumerTransaction {
return {
id: s.saleId,
type: 'card_payment',
title: s.merchantName,
subtitle: `Card •••• ${s.cardId.slice(-4)} · ${s.terminalId}`,
amount: s.amount,
currency: s.currency,
direction: 'out',
status: s.status === 'approved' ? 'completed' : 'failed',
createdAt: s.createdAt,
reference: s.fineractJournalRef,
};
}
function cardToDisplay(card: ZBankPrepaidCard, cfg: ReturnType<typeof loadZBankConsumerConfig>): ConsumerCard {
const tierMeta = cfg.cardTierMap[card.productId] ?? {
tier: 'standard',
label: card.productLabel,
gradient: 'standard',
network: 'Visa',
};
return {
cardId: card.cardId,
tier: tierMeta.tier,
label: tierMeta.label,
gradient: tierMeta.gradient,
network: tierMeta.network,
last4: card.cardLast4,
cardholderName: card.cardholderName,
currency: card.currency,
balance: card.balance,
status: card.status === 'blocked' ? 'frozen' : 'active',
productId: card.productId,
expiry: '12/28',
};
}
function virtualCards(cfg: ReturnType<typeof loadZBankConsumerConfig>, holder: string): ConsumerCard[] {
return cfg.virtualCardProducts.map((p, i) => ({
cardId: `virtual-${p.productId}`,
tier: p.tier,
label: p.label,
gradient: p.gradient,
network: p.network,
last4: String(9000 + i * 111).slice(-4),
cardholderName: holder,
currency: 'USD',
balance: '0.00',
status: 'virtual' as const,
productId: p.productId,
expiry: '12/28',
}));
}
export function buildConsumerTransactions(limit = 50): ConsumerTransaction[] {
const settlements = settlementStore
.list(200)
.map(settlementToTransaction)
.filter(Boolean) as ConsumerTransaction[];
const sales = zbankCardStore.listSales(100).map(saleToTransaction);
return [...settlements, ...sales]
.sort((a, b) => parseTs(b.createdAt) - parseTs(a.createdAt))
.slice(0, limit);
}
export async function buildConsumerAccounts(
officeId: number,
): Promise<{ accounts: ConsumerAccount[]; totalBalance: string; fineractConnected: boolean; fineractLive: boolean }> {
const cfg = loadZBankConsumerConfig();
const posCfg = loadZBankPosConfig();
const balances = await fetchGlBalances(officeId);
const snap = buildMoneySupplySnapshot(officeId, balances);
const fineractConnected = await fineractOfficeReachable(officeId);
const hasActivity = Object.values(balances).some((v) => Math.abs(parseFloat(v) || 0) > 0);
const fineractLive = fineractConnected && hasActivity;
const m1 = snap.M1.circulating ?? snap.M1.gl2100 ?? '0';
const m2 = snap.M2.broadMoney ?? snap.M2.gl2200 ?? '0';
const m0 = snap.M0.gl1050 ?? '0';
const m3 = snap.M3.tokenLiabilities ?? snap.M3.gl2300 ?? '0';
const m4 = snap.M4.nearMoney ?? snap.M4.gl1000 ?? '0';
const cardPool = balances[posCfg.gl.cardPrepaidLiability] ?? '0';
const usdDetails = cfg.accountDetails.USD;
const eurDetails = cfg.accountDetails.EUR;
const gbpDetails = cfg.accountDetails.GBP;
const accounts: ConsumerAccount[] = [
{
id: 'usd-main',
currency: 'USD',
name: 'Main · USD',
flag: ACCOUNT_FLAGS.USD,
balance: m1,
availableBalance: m1,
type: 'current',
moneyLayer: 'M1',
details: usdDetails,
},
{
id: 'usd-savings',
currency: 'USD',
name: 'Savings',
flag: ACCOUNT_FLAGS.SAVINGS,
balance: m2,
availableBalance: m2,
type: 'savings',
moneyLayer: 'M2',
details: { ...usdDetails, accountName: 'USD Savings (M2 tokenized)' },
},
{
id: 'eur',
currency: 'EUR',
name: 'Euro',
flag: ACCOUNT_FLAGS.EUR,
balance: '0.00',
availableBalance: '0.00',
type: 'current',
moneyLayer: 'M1',
details: eurDetails,
},
{
id: 'gbp',
currency: 'GBP',
name: 'British Pound',
flag: ACCOUNT_FLAGS.GBP,
balance: '0.00',
availableBalance: '0.00',
type: 'current',
moneyLayer: 'M1',
details: gbpDetails,
},
{
id: 'usd-cards',
currency: 'USD',
name: 'Card balance',
flag: '💳',
balance: cardPool,
availableBalance: cardPool,
type: 'current',
moneyLayer: 'M2',
details: { ...usdDetails, accountName: 'Prepaid card pool (GL 2210)' },
},
{
id: 'reserve',
currency: 'USD',
name: 'Reserve',
flag: ACCOUNT_FLAGS.RESERVE,
balance: m0,
availableBalance: m0,
type: 'reserve',
moneyLayer: 'M0',
details: { ...usdDetails, accountName: 'M0 base money reserve' },
},
{
id: 'crypto',
currency: 'USD',
name: 'On-chain',
flag: ACCOUNT_FLAGS.CRYPTO,
balance: m3,
availableBalance: m3,
type: 'crypto',
moneyLayer: 'M3',
details: { ...usdDetails, accountName: 'M3 digital / on-chain' },
},
{
id: 'swift',
currency: 'USD',
name: 'SWIFT rails',
flag: '🌐',
balance: m4,
availableBalance: m4,
type: 'fiat_rail',
moneyLayer: 'M4',
details: { ...usdDetails, accountName: 'M4 SWIFT settlement' },
},
];
const total = (
parseFloat(m1 || '0') +
parseFloat(m2 || '0') +
parseFloat(cardPool || '0')
).toFixed(2);
return { accounts, totalBalance: total, fineractConnected, fineractLive };
}
export async function buildConsumerOverview(): Promise<ConsumerOverview> {
const cfg = loadZBankConsumerConfig();
const posCfg = loadZBankPosConfig();
const office = loadOfficeProfile();
const holder = office.name?.split('—')[0]?.trim() ?? 'Z Bank Customer';
const { accounts, totalBalance, fineractConnected, fineractLive } = await buildConsumerAccounts(posCfg.officeId);
const storedCards = zbankCardStore.listCards(20);
const cards =
storedCards.length > 0
? storedCards.map((c) => cardToDisplay(c, cfg))
: virtualCards(cfg, holder);
const transactions = buildConsumerTransactions(30);
return {
asOf: new Date().toISOString(),
profile: {
displayName: holder,
plan: cfg.profile.plan,
planTier: cfg.profile.planTier,
planPrice: cfg.profile.planPrice,
email: cfg.profile.email,
phone: cfg.profile.phone,
address: cfg.profile.address,
memberSince: cfg.profile.memberSince,
kycStatus: cfg.profile.kycStatus,
kycLevel: cfg.profile.kycLevel,
officeId: posCfg.officeId,
externalId: posCfg.externalId,
},
totalBalance,
totalBalanceCurrency: 'USD',
accounts,
cards,
transactions,
limits: cfg.limits,
fineractConnected,
fineractLive,
};
}

View File

@@ -0,0 +1,72 @@
import fs from 'fs';
import path from 'path';
export type ZBankConsumerConfig = {
version: string;
profile: {
plan: string;
planTier: string;
planPrice: string;
email: string;
phone: string;
address: {
line1: string;
city: string;
postcode: string;
country: string;
countryName: string;
};
memberSince: string;
kycStatus: string;
kycLevel: string;
};
accountDetails: Record<
string,
{
accountName: string;
iban: string | null;
bic: string;
accountNumber: string;
sortCode?: string | null;
routingNumber?: string;
bankName: string;
bankAddress: string;
shareText: string;
}
>;
limits: {
currency: string;
dailyTransferLimit: string;
dailyTransferUsed: string;
monthlyTransferLimit: string;
monthlyTransferUsed: string;
cardSpendDailyLimit: string;
cardSpendDailyUsed: string;
atmDailyLimit: string;
atmDailyUsed: string;
topUpMonthlyLimit: string;
};
cardTierMap: Record<string, { tier: string; label: string; gradient: string; network: string }>;
virtualCardProducts: Array<{
tier: string;
label: string;
gradient: string;
productId: string;
network: string;
}>;
};
let cached: ZBankConsumerConfig | null = null;
function repoConfigPath(relative: string): string {
return path.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
}
export function loadZBankConsumerConfig(): ZBankConsumerConfig {
if (cached) return cached;
const configPath =
process.env.ZBANK_CONSUMER_CONFIG?.trim() ||
repoConfigPath('config/zbank-consumer-account.v1.json');
cached = JSON.parse(fs.readFileSync(configPath, 'utf8')) as ZBankConsumerConfig;
return cached;
}

View File

@@ -0,0 +1,169 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'crypto';
import fs from 'fs';
import path from 'path';
export type ZBankPortalUser = {
id: string;
phone: string;
displayName: string;
email?: string;
};
export type ZBankPortalAuthConfig = {
version: string;
sessionTtlHours: number;
maxAttempts: number;
lockoutMinutes: number;
users: ZBankPortalUser[];
};
export type PortalSession = {
userId: string;
displayName: string;
phone: string;
email?: string;
iat: number;
exp: number;
};
const attemptLog = new Map<string, { count: number; lockedUntil?: number }>();
let cachedConfig: ZBankPortalAuthConfig | null = null;
function repoConfigPath(relative: string): string {
return path.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
}
export function loadZBankPortalAuthConfig(): ZBankPortalAuthConfig {
if (cachedConfig) return cachedConfig;
const configPath =
process.env.ZBANK_PORTAL_AUTH_CONFIG?.trim() ||
repoConfigPath('config/zbank-portal-auth.v1.json');
cachedConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')) as ZBankPortalAuthConfig;
return cachedConfig;
}
function sessionSecret(): string {
const secret = process.env.ZBANK_PORTAL_SESSION_SECRET?.trim();
if (!secret || secret.length < 16) {
throw new Error('ZBANK_PORTAL_SESSION_SECRET must be set (min 16 chars) for portal login');
}
return secret;
}
function configuredPasscode(): string {
const code = process.env.ZBANK_PORTAL_PASSCODE?.trim();
if (!code) throw new Error('ZBANK_PORTAL_PASSCODE is not configured');
return code;
}
function normalizePhone(input: string): string {
return input.replace(/[\s-]/g, '');
}
function signPayload(payload: string): string {
return createHmac('sha256', sessionSecret()).update(payload).digest('base64url');
}
export function issueSession(user: ZBankPortalUser): string {
const cfg = loadZBankPortalAuthConfig();
const now = Math.floor(Date.now() / 1000);
const body: PortalSession = {
userId: user.id,
displayName: user.displayName,
phone: user.phone,
email: user.email,
iat: now,
exp: now + cfg.sessionTtlHours * 3600,
};
const payload = Buffer.from(JSON.stringify(body)).toString('base64url');
return `${payload}.${signPayload(payload)}`;
}
export function verifySession(token: string | undefined): PortalSession | null {
if (!token?.includes('.')) return null;
const [payload, sig] = token.split('.', 2);
if (!payload || !sig) return null;
const expected = signPayload(payload);
try {
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
} catch {
return null;
}
try {
const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as PortalSession;
if (!session.exp || session.exp < Math.floor(Date.now() / 1000)) return null;
return session;
} catch {
return null;
}
}
export function extractSessionToken(authHeader: string | undefined): string | undefined {
if (!authHeader) return undefined;
const m = authHeader.match(/^Bearer\s+(.+)$/i);
return m?.[1]?.trim();
}
function secureCompare(a: string, b: string): boolean {
const ha = createHash('sha256').update(a).digest();
const hb = createHash('sha256').update(b).digest();
return timingSafeEqual(ha, hb);
}
function checkLockout(key: string): string | null {
const entry = attemptLog.get(key);
if (entry?.lockedUntil && entry.lockedUntil > Date.now()) {
const mins = Math.ceil((entry.lockedUntil - Date.now()) / 60000);
return `Too many attempts. Try again in ${mins} minute(s).`;
}
return null;
}
function recordFailedAttempt(key: string): void {
const cfg = loadZBankPortalAuthConfig();
const entry = attemptLog.get(key) ?? { count: 0 };
entry.count += 1;
if (entry.count >= cfg.maxAttempts) {
entry.lockedUntil = Date.now() + cfg.lockoutMinutes * 60_000;
entry.count = 0;
}
attemptLog.set(key, entry);
}
export function loginWithPasscode(
phone: string,
passcode: string,
): { token: string; session: PortalSession } {
const cfg = loadZBankPortalAuthConfig();
const normalized = normalizePhone(phone);
const lockKey = createHash('sha256').update(normalized).digest('hex').slice(0, 16);
const locked = checkLockout(lockKey);
if (locked) throw new Error(locked);
const user = cfg.users.find((u) => normalizePhone(u.phone) === normalized);
if (!user) {
recordFailedAttempt(lockKey);
throw new Error('Invalid phone number or passcode');
}
if (!secureCompare(passcode, configuredPasscode())) {
recordFailedAttempt(lockKey);
throw new Error('Invalid phone number or passcode');
}
attemptLog.delete(lockKey);
const token = issueSession(user);
const session = verifySession(token);
if (!session) throw new Error('Failed to create session');
return { token, session };
}
export function portalAuthRequired(): boolean {
return process.env.ZBANK_PORTAL_REQUIRE_AUTH === '1' || process.env.ZBANK_PORTAL_REQUIRE_AUTH === 'true';
}
export function generateSessionSecret(): string {
return randomBytes(32).toString('hex');
}

View File

@@ -0,0 +1,42 @@
import fs from 'fs';
import path from 'path';
export type ZBankPosConfig = {
version: string;
officeId: number;
externalId: string;
issuer: { legalName: string; brand: string; guosmmNotice: string };
gl: { loadSource: string; cardPrepaidLiability: string; posClearing: string; pofPrimary: string };
proofOfFunds: { primaryGlCode: string; secondaryGlCodes: string[]; methodology: string };
cardProducts: Array<{
id: string;
label: string;
currency: string;
network: string;
maxBalance: string;
minLoad: string;
}>;
posTerminals: Array<{ terminalId: string; merchantName: string; mcc: string }>;
};
let cached: ZBankPosConfig | null = null;
function repoConfigPath(relative: string): string {
return path.resolve(__dirname, '../../../../', relative.replace(/^\//, ''));
}
export function loadZBankPosConfig(): ZBankPosConfig {
if (cached) return cached;
const configPath =
process.env.ZBANK_POS_CONFIG?.trim() || repoConfigPath('config/zbank-pos-cards.v1.json');
cached = JSON.parse(fs.readFileSync(configPath, 'utf8')) as ZBankPosConfig;
return cached;
}
export function findCardProduct(cfg: ZBankPosConfig, productId: string) {
return cfg.cardProducts.find((p) => p.id === productId);
}
export function findPosTerminal(cfg: ZBankPosConfig, terminalId: string) {
return cfg.posTerminals.find((t) => t.terminalId === terminalId);
}

View File

@@ -0,0 +1,81 @@
import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
import type { ZBankPrepaidCard, ZBankPosSaleRecord } from '@dbis/settlement-core';
import { loadZBankPosConfig } from '../services/zbank-pos-config';
const baseDir =
process.env.ZBANK_CARD_STORE_DIR ||
path.resolve(__dirname, '../../../data/zbank-card-store');
function storeDir(): string {
const officeId = loadZBankPosConfig().officeId;
const dir = path.join(baseDir, `office-${officeId}`);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
function cardPath(cardId: string): string {
return path.join(storeDir(), `card-${cardId}.json`);
}
function salePath(saleId: string): string {
return path.join(storeDir(), `sale-${saleId}.json`);
}
export class ZBankCardStore {
saveCard(card: ZBankPrepaidCard): void {
fs.writeFileSync(cardPath(card.cardId), JSON.stringify(card, null, 2));
}
getCard(cardId: string): ZBankPrepaidCard | null {
const p = cardPath(cardId);
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, 'utf8')) as ZBankPrepaidCard;
}
listCards(limit = 50): ZBankPrepaidCard[] {
const dir = storeDir();
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((f) => f.startsWith('card-') && f.endsWith('.json'))
.slice(0, limit)
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as ZBankPrepaidCard);
}
saveSale(sale: ZBankPosSaleRecord): void {
fs.writeFileSync(salePath(sale.saleId), JSON.stringify(sale, null, 2));
fs.writeFileSync(path.join(storeDir(), `sale-key-${sale.idempotencyKey}.json`), sale.saleId);
}
getSaleByIdempotency(key: string): ZBankPosSaleRecord | null {
const ref = path.join(storeDir(), `sale-key-${key}.json`);
if (!fs.existsSync(ref)) return null;
const id = fs.readFileSync(ref, 'utf8').trim();
const p = salePath(id);
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, 'utf8')) as ZBankPosSaleRecord;
}
listSales(limit = 50): ZBankPosSaleRecord[] {
const dir = storeDir();
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((f) => f.startsWith('sale-') && f.endsWith('.json') && !f.includes('sale-key-'))
.slice(0, limit)
.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as ZBankPosSaleRecord)
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
}
}
export const zbankCardStore = new ZBankCardStore();
export function newCardId(): string {
return `ZCARD-${randomUUID().slice(0, 8).toUpperCase()}`;
}
export function newSaleId(): string {
return `ZSALE-${randomUUID().slice(0, 8).toUpperCase()}`;
}

View File

@@ -15,9 +15,10 @@ import {
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
import { fetchGlBalances, postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
import { HybxProductionRail } from '../adapters/hybx-production';
import { archiveIso20022, requestTokenMint } from '../adapters/omnl';
import { archiveIso20022 } from '../adapters/omnl';
import { buildMt102Stub, buildMt103Stub, buildMt202Stub, buildMt910Stub, forwardSwiftToListener } from '../adapters/swift';
import { settlementStore } from '../store/settlement-store';
import { executeSettlementMintAndBridge, resolveM2TokenAddress } from './settlement-bridge';
export async function processExternalTransfer(
input: ExternalTransferRequest,
@@ -177,14 +178,18 @@ export async function processExternalTransfer(
advance('FAILED');
return record;
}
const mint = await requestTokenMint({
lineId: req.convertToCrypto.tokenLineId,
amount: m3Check.loadAmount,
recipient: req.convertToCrypto.recipientAddress,
settlementRef: record.settlementId,
dryRun: !cfg.production.allowChainMintExecute,
});
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
record = await executeSettlementMintAndBridge(
record,
{
lineId: req.convertToCrypto.tokenLineId,
amount: m3Check.loadAmount,
tokenAddress: resolveM2TokenAddress(req.convertToCrypto.tokenLineId),
dryRunMint: !cfg.production.allowChainMintExecute,
dryRunBridge: !cfg.production.allowChainBridgeExecute,
},
advance,
);
if (record.phase === 'FAILED') return record;
} else {
advance('CHAIN_MINT_REQUESTED');
}

View File

@@ -0,0 +1,153 @@
import type { ExternalTransferRequest, SettlementRecord } from '@dbis/settlement-core';
import {
assertBridgeDestinationAllowed,
findM2TokenByLineId,
loadOmnlChainRegistry,
type M2TokenRegistryFile,
} from '@dbis/settlement-core';
import fs from 'fs';
import path from 'path';
import { requestChainBridge } from '../adapters/bridge';
import { requestTokenMint } from '../adapters/omnl';
const PRIMARY_CHAIN_ID = 138;
function loadTokenRegistry(): M2TokenRegistryFile {
const registryPath =
process.env.OMNL_M2_TOKEN_REGISTRY ||
path.resolve(__dirname, '../../../config/omnl-m2-token-registry.v1.json');
return JSON.parse(fs.readFileSync(registryPath, 'utf8')) as M2TokenRegistryFile;
}
export function resolveM2TokenAddress(lineId: string, symbol?: string): string | undefined {
const registry = loadTokenRegistry();
const byLine = findM2TokenByLineId(registry, lineId);
if (byLine?.address.startsWith('0x') && byLine.address !== '0x0000000000000000000000000000000000000000') {
return byLine.address;
}
if (symbol) {
const bySym = registry.tokens.find((t) => t.symbol.toLowerCase() === symbol.toLowerCase());
if (bySym?.address.startsWith('0x') && bySym.address !== '0x0000000000000000000000000000000000000000') {
return bySym.address;
}
}
return undefined;
}
export function requiresCrossChainBridge(req: ExternalTransferRequest): boolean {
return Boolean(
req.convertToCrypto?.enabled &&
req.convertToCrypto.targetChainId !== PRIMARY_CHAIN_ID,
);
}
export function bridgeAlreadyCompleted(record: SettlementRecord): boolean {
return Boolean(
record.bridgeTxHash ||
record.phase === 'CHAIN_BRIDGE_REQUESTED' ||
record.phase === 'CHAIN_BRIDGE_PENDING' ||
(record.phase === 'SETTLED' && record.destinationChainId && record.destinationChainId !== PRIMARY_CHAIN_ID),
);
}
export async function executeSettlementMintAndBridge(
record: SettlementRecord,
params: {
lineId: string;
amount: string;
tokenAddress?: string;
dryRunMint: boolean;
dryRunBridge: boolean;
},
advance: (phase: SettlementRecord['phase'], patch?: Partial<SettlementRecord>) => void,
): Promise<SettlementRecord> {
const req = record.request;
const crypto = req.convertToCrypto;
if (!crypto?.enabled) {
advance('CHAIN_MINT_REQUESTED');
advance('SETTLED');
return record;
}
const targetChainId = crypto.targetChainId;
const mintChainId = PRIMARY_CHAIN_ID;
if (targetChainId === mintChainId) {
const mint = await requestTokenMint({
lineId: crypto.tokenLineId,
amount: params.amount,
recipient: crypto.recipientAddress,
settlementRef: record.settlementId,
dryRun: params.dryRunMint,
});
advance('CHAIN_MINT_REQUESTED', {
chainTxHash: mint.txHash,
mintChainId,
destinationChainId: targetChainId,
});
advance('SETTLED');
return record;
}
const { bridgeRail } = assertBridgeDestinationAllowed(
mintChainId,
targetChainId,
crypto.tokenLineId,
crypto.bridgeRail,
);
if (bridgeAlreadyCompleted(record)) {
advance('SETTLED');
return record;
}
const tokenAddress =
params.tokenAddress ?? resolveM2TokenAddress(crypto.tokenLineId);
const mint = await requestTokenMint({
lineId: crypto.tokenLineId,
amount: params.amount,
recipient: crypto.recipientAddress,
settlementRef: record.settlementId,
dryRun: params.dryRunMint,
mintToBridgeOperator: true,
targetChainId,
});
advance('CHAIN_MINT_REQUESTED', {
chainTxHash: mint.txHash,
mintChainId,
destinationChainId: targetChainId,
});
if (bridgeRail === 'trustless-lockbox' && !tokenAddress?.startsWith('0x')) {
record.errors.push(`tokenAddress required for trustless bridge (line ${crypto.tokenLineId})`);
advance('FAILED');
return record;
}
const bridge = await requestChainBridge({
settlementRef: record.settlementId,
destinationChainId: targetChainId,
tokenAddress,
amount: params.amount,
recipient: crypto.recipientAddress,
bridgeRail,
dryRun: params.dryRunBridge,
});
advance('CHAIN_BRIDGE_REQUESTED', {
bridgeTxHash: bridge.bridgeTxHash,
bridgeMessageId: bridge.messageId,
});
if (bridge.status === 'CHAIN_BRIDGE_PENDING' || bridge.status === 'DRY_RUN') {
advance('CHAIN_BRIDGE_PENDING');
}
advance('SETTLED');
return record;
}
export function primaryChainId(): number {
return loadOmnlChainRegistry().primaryChainId || PRIMARY_CHAIN_ID;
}

View File

@@ -13,8 +13,8 @@ import {
} from '@dbis/settlement-core';
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
import { fetchGlBalances, postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
import { requestTokenMint } from '../adapters/omnl';
import { settlementStore } from '../store/settlement-store';
import { executeSettlementMintAndBridge, resolveM2TokenAddress } from './settlement-bridge';
function loadTokenRegistry(): M2TokenRegistryFile {
const registryPath =
@@ -44,6 +44,7 @@ export async function processTokenLoad(input: TokenLoadRequest): Promise<Settlem
const profile = loadOfficeProfile();
const officeId = settlementOfficeId(input.officeId);
const req = { ...input, officeId };
const targetChainId = req.targetChainId ?? 138;
const existing = settlementStore.getByIdempotencyKey(req.idempotencyKey);
if (existing?.phase === 'SETTLED') return existing;
@@ -66,7 +67,7 @@ export async function processTokenLoad(input: TokenLoadRequest): Promise<Settlem
rail: 'INTERNAL',
convertToCrypto: {
enabled: true,
targetChainId: 138,
targetChainId,
tokenLineId: req.lineId,
recipientAddress: req.recipientAddress,
},
@@ -126,17 +127,18 @@ export async function processTokenLoad(input: TokenLoadRequest): Promise<Settlem
advance('HYBX_RAIL_DISPATCHED');
advance('ISO20022_ARCHIVED');
const mint = await requestTokenMint({
lineId: req.lineId,
amount: loadAmount,
recipient: req.recipientAddress,
settlementRef: record.settlementId,
dryRun: !cfg.production.allowChainMintExecute,
symbol: req.symbol,
tokenAddress: resolveTokenAddress(req),
});
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
advance('SETTLED');
record = await executeSettlementMintAndBridge(
record,
{
lineId: req.lineId,
amount: loadAmount,
tokenAddress: resolveTokenAddress(req) ?? resolveM2TokenAddress(req.lineId, req.symbol),
dryRunMint: !cfg.production.allowChainMintExecute,
dryRunBridge: !cfg.production.allowChainBridgeExecute,
},
advance,
);
if (record.phase === 'FAILED') return record;
return record;
} catch (err) {
record.errors.push(err instanceof Error ? err.message : String(err));

View File

@@ -16,9 +16,10 @@ import {
import { loadConfig, loadOfficeProfile, settlementOfficeId } from '../config';
import { fetchGlBalances, postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
import { HybxProductionRail } from '../adapters/hybx-production';
import { archiveIso20022, requestTokenMint } from '../adapters/omnl';
import { archiveIso20022 } from '../adapters/omnl';
import { buildMt103Stub, forwardSwiftToListener } from '../adapters/swift';
import { settlementStore } from '../store/settlement-store';
import { executeSettlementMintAndBridge, resolveM2TokenAddress } from './settlement-bridge';
function toExternalRequest(input: ZBankM0M4Request, officeId: number): ExternalTransferRequest {
return {
@@ -38,7 +39,7 @@ function toExternalRequest(input: ZBankM0M4Request, officeId: number): ExternalT
convertToCrypto: input.recipientAddress
? {
enabled: true,
targetChainId: 138,
targetChainId: input.targetChainId ?? 138,
tokenLineId: input.tokenLineId ?? 'USD-M2',
recipientAddress: input.recipientAddress,
}
@@ -203,14 +204,18 @@ export async function processZBankM0ToM4(input: ZBankM0M4Request): Promise<Settl
const { loadAmount, fullyBacked } = m2FiatToTokenLoadAmount(req.amount, snap.M2.broadMoney);
const m3Check = m3TokenLoadAmount(loadAmount, snap.M2.broadMoney, snap.M3.tokenLiabilities);
if (fullyBacked && m3Check.fullyBacked) {
const mint = await requestTokenMint({
lineId: req.convertToCrypto.tokenLineId,
amount: m3Check.loadAmount,
recipient: req.convertToCrypto.recipientAddress,
settlementRef: record.settlementId,
dryRun: !cfg.production.allowChainMintExecute,
});
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
record = await executeSettlementMintAndBridge(
record,
{
lineId: req.convertToCrypto.tokenLineId,
amount: m3Check.loadAmount,
tokenAddress: resolveM2TokenAddress(req.convertToCrypto.tokenLineId),
dryRunMint: !cfg.production.allowChainMintExecute,
dryRunBridge: !cfg.production.allowChainBridgeExecute,
},
advance,
);
if (record.phase === 'FAILED') return record;
} else {
record.errors.push('M3 mint skipped — insufficient M2/M3 backing');
advance('CHAIN_MINT_REQUESTED');

View File

@@ -0,0 +1,139 @@
import { v4 as uuidv4 } from 'uuid';
import type { SettlementRecord, ZBankCardLoadRequest } from '@dbis/settlement-core';
import { buildMoneySupplySnapshot } from '@dbis/settlement-core';
import { fetchGlBalances, postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
import { settlementStore } from '../store/settlement-store';
import { findCardProduct, loadZBankPosConfig } from '../services/zbank-pos-config';
import { newCardId, zbankCardStore } from '../store/zbank-card-store';
export async function processZBankCardLoad(input: ZBankCardLoadRequest): Promise<{
settlement: SettlementRecord;
card: ReturnType<typeof zbankCardStore.getCard>;
}> {
const cfg = loadZBankPosConfig();
const officeId = input.officeId ?? cfg.officeId;
const product = findCardProduct(cfg, input.cardProductId);
if (!product) {
throw new Error(`Unknown card product: ${input.cardProductId}`);
}
const amount = parseFloat(input.amount);
const minLoad = parseFloat(product.minLoad);
const maxBalance = parseFloat(product.maxBalance);
if (!Number.isFinite(amount) || amount < minLoad) {
throw new Error(`Minimum load is ${product.minLoad} ${product.currency}`);
}
const existing = settlementStore.getByIdempotencyKey(input.idempotencyKey);
if (existing?.phase === 'SETTLED') {
const cardId = existing.request.remittanceInfo?.match(/card (ZCARD-[A-Z0-9]+)/)?.[1];
return { settlement: existing, card: cardId ? zbankCardStore.getCard(cardId) : null };
}
const now = new Date().toISOString();
let record: SettlementRecord = existing ?? {
settlementId: uuidv4(),
idempotencyKey: input.idempotencyKey,
phase: 'RECEIVED',
request: {
idempotencyKey: input.idempotencyKey,
officeId,
valueDate: now.slice(0, 10),
currency: input.currency ?? product.currency,
amount: input.amount,
creditorIban: 'ZBANK-CARD-LOAD',
beneficiaryName: input.cardholderName ?? 'Z Card holder',
remittanceInfo: `zBank card load · ${product.label}`,
moneyLayers: ['M2'],
rail: 'INTERNAL',
},
errors: [],
createdAt: now,
updatedAt: now,
};
const advance = (phase: SettlementRecord['phase'], patch: Partial<SettlementRecord> = {}) => {
record = { ...record, phase, updatedAt: new Date().toISOString(), ...patch };
settlementStore.save(record);
};
try {
advance('VALIDATED');
const balances = await fetchGlBalances(officeId);
const snap = buildMoneySupplySnapshot(officeId, balances);
const m2Available = parseFloat(snap.M2.broadMoney) || 0;
if (amount > m2Available) {
record.errors.push(`Insufficient M2 broad money (${snap.M2.broadMoney} available)`);
advance('FAILED');
return { settlement: record, card: null };
}
advance('VERBIAGE_ROLLED', {
verbiageDocument: [
`zBank prepaid card load`,
`Office ${officeId} · product ${product.id}`,
`Amount ${input.amount} ${product.currency}`,
`GL ${cfg.gl.loadSource}${cfg.gl.cardPrepaidLiability}`,
].join('\n'),
});
const [m2GlId, cardGlId] = await Promise.all([
resolveGlAccountId(cfg.gl.loadSource),
resolveGlAccountId(cfg.gl.cardPrepaidLiability),
]);
const je = await postJournalEntry({
officeId,
transactionDate: now.slice(0, 10),
referenceNumber: input.idempotencyKey,
comments: `zBank card load ${product.label}`,
debits: [{ glAccountId: m2GlId, amount }],
credits: [{ glAccountId: cardGlId, amount }],
});
advance('FINERACT_POSTED', { fineractJournalRef: String(je.resourceId) });
advance('HYBX_RAIL_DISPATCHED');
advance('ISO20022_ARCHIVED');
const cardLast4 = (input.cardLast4 ?? String(Math.floor(1000 + Math.random() * 9000))).slice(-4);
const cardId = newCardId();
const existingCard = zbankCardStore.listCards(500).find((c) => c.cardLast4 === cardLast4 && c.productId === product.id);
let card = existingCard;
if (card) {
const newBal = (parseFloat(card.balance) + amount).toFixed(2);
if (parseFloat(newBal) > maxBalance) {
record.errors.push(`Card max balance ${product.maxBalance} exceeded`);
advance('FAILED');
return { settlement: record, card };
}
card = {
...card,
balance: newBal,
updatedAt: now,
lastLoadAt: now,
};
} else {
card = {
cardId,
productId: product.id,
productLabel: product.label,
officeId,
cardholderName: input.cardholderName ?? 'Z Card holder',
cardLast4,
currency: product.currency,
balance: amount.toFixed(2),
status: 'active',
createdAt: now,
updatedAt: now,
lastLoadAt: now,
};
}
zbankCardStore.saveCard(card);
record.request.remittanceInfo = `zBank card load · card ${card.cardId} ·••• ${cardLast4}`;
advance('SETTLED', { chainTxHash: card.cardId });
return { settlement: record, card };
} catch (err) {
record.errors.push(err instanceof Error ? err.message : String(err));
advance('FAILED');
return { settlement: record, card: null };
}
}

View File

@@ -0,0 +1,91 @@
import type { ZBankPosSaleRequest } from '@dbis/settlement-core';
import { postJournalEntry, resolveGlAccountId } from '../adapters/fineract';
import { findPosTerminal, loadZBankPosConfig } from '../services/zbank-pos-config';
import { newSaleId, zbankCardStore } from '../store/zbank-card-store';
export async function processZBankPosSale(input: ZBankPosSaleRequest): Promise<ReturnType<typeof zbankCardStore.getSaleByIdempotency>> {
const cfg = loadZBankPosConfig();
const officeId = input.officeId ?? cfg.officeId;
const terminal = findPosTerminal(cfg, input.terminalId);
if (!terminal) {
throw new Error(`Unknown POS terminal: ${input.terminalId}`);
}
const dup = zbankCardStore.getSaleByIdempotency(input.idempotencyKey);
if (dup) return dup;
const card = zbankCardStore.getCard(input.cardId);
const now = new Date().toISOString();
const amount = parseFloat(input.amount);
if (!card || card.status !== 'active') {
const declined = {
saleId: newSaleId(),
idempotencyKey: input.idempotencyKey,
cardId: input.cardId,
terminalId: input.terminalId,
merchantName: terminal.merchantName,
amount: input.amount,
currency: input.currency ?? 'USD',
status: 'declined' as const,
declineReason: 'Card not found or blocked',
createdAt: now,
};
zbankCardStore.saveSale(declined);
return declined;
}
const balance = parseFloat(card.balance);
if (!Number.isFinite(amount) || amount <= 0 || amount > balance) {
const declined = {
saleId: newSaleId(),
idempotencyKey: input.idempotencyKey,
cardId: input.cardId,
terminalId: input.terminalId,
merchantName: terminal.merchantName,
amount: input.amount,
currency: input.currency ?? card.currency,
status: 'declined' as const,
declineReason: amount > balance ? 'Insufficient card balance' : 'Invalid amount',
createdAt: now,
};
zbankCardStore.saveSale(declined);
return declined;
}
const [debitGlId, creditGlId] = await Promise.all([
resolveGlAccountId(cfg.gl.cardPrepaidLiability),
resolveGlAccountId(cfg.gl.posClearing),
]);
const je = await postJournalEntry({
officeId,
transactionDate: now.slice(0, 10),
referenceNumber: input.idempotencyKey,
comments: `POS ${terminal.terminalId} · card •••${card.cardLast4}`,
debits: [{ glAccountId: debitGlId, amount }],
credits: [{ glAccountId: creditGlId, amount }],
});
const newBalance = (balance - amount).toFixed(2);
zbankCardStore.saveCard({
...card,
balance: newBalance,
updatedAt: now,
lastSaleAt: now,
});
const approved = {
saleId: newSaleId(),
idempotencyKey: input.idempotencyKey,
cardId: input.cardId,
terminalId: input.terminalId,
merchantName: terminal.merchantName,
amount: input.amount,
currency: input.currency ?? card.currency,
fineractJournalRef: String(je.resourceId),
status: 'approved' as const,
createdAt: now,
};
zbankCardStore.saveSale(approved);
return approved;
}

View File

@@ -0,0 +1,51 @@
import { createHash } from 'crypto';
import { buildMoneySupplySnapshot } from '@dbis/settlement-core';
import { fetchGlBalances } from '../adapters/fineract';
import { loadZBankPosConfig } from '../services/zbank-pos-config';
import { loadConfig } from '../config';
export async function buildZBankProofOfFunds(officeId?: number): Promise<Record<string, unknown>> {
const cfg = loadZBankPosConfig();
const oid = officeId ?? cfg.officeId;
const middleware = loadConfig();
const balances = await fetchGlBalances(oid);
const snap = buildMoneySupplySnapshot(oid, balances);
const glCodes = [cfg.proofOfFunds.primaryGlCode, ...cfg.proofOfFunds.secondaryGlCodes];
const glBalances = glCodes.map((code) => ({
glCode: code,
amount: balances[code] ?? '0',
}));
const primaryAmount = balances[cfg.proofOfFunds.primaryGlCode] ?? snap.M1.gl2100 ?? '0';
const generatedAt = new Date().toISOString();
const body = {
documentType: 'proof-of-funds',
generatedAtUtc: generatedAt,
settlementRef: `ZBANK-POF-${generatedAt.slice(0, 10).replace(/-/g, '')}`,
issuer: {
legalName: cfg.issuer.legalName,
brand: cfg.issuer.brand,
officeId: oid,
externalId: cfg.externalId,
guosmmNotice: cfg.issuer.guosmmNotice,
},
attestation: {
primaryGlCode: cfg.proofOfFunds.primaryGlCode,
primaryAmountUsd: primaryAmount,
currency: 'USD',
methodology: cfg.proofOfFunds.methodology,
},
moneySupply: snap,
glBalances,
cardProgram: {
prepaidGl: cfg.gl.cardPrepaidLiability,
prepaidPool: balances[cfg.gl.cardPrepaidLiability] ?? '0',
loadSourceGl: cfg.gl.loadSource,
posClearingGl: cfg.gl.posClearing,
},
omnlLegalName: middleware.omnlLegalName,
disclaimer:
'Accounting attestation from Fineract GL balances — not a bank guarantee letter. For correspondent banking use official SWIFT MT799/POF where required.',
};
const sha256 = createHash('sha256').update(JSON.stringify(body)).digest('hex');
return { ...body, integrity: { sha256 } };
}

View File

@@ -21,7 +21,18 @@ import {
executeErc20Transfer,
executeM2TokenMint,
chainRailConfigured,
getSettlementOperatorAddress,
} from '../../services/omnl-settlement-chain';
import {
bridgeRailConfigured,
dryRunCcipWeth9Bridge,
executeCcipWeth9Bridge,
getBridgeOperatorAddress,
} from '../../services/omnl-settlement-bridge';
import {
dryRunTrustlessLockboxBridge,
executeTrustlessLockboxDeposit,
} from '../../services/omnl-settlement-trustless-bridge';
import fs from 'fs';
import path from 'path';
@@ -379,7 +390,17 @@ router.get('/omnl/health', async (req: Request, res: Response) => {
* POST /omnl/settlement/token-load — M2→M3 fiat-backed token mint (Office 24 settlement).
*/
router.post('/omnl/settlement/token-load', async (req: Request, res: Response) => {
const { lineId, amount, recipient, settlementRef, dryRun, symbol, tokenAddress } = req.body as {
const {
lineId,
amount,
recipient,
settlementRef,
dryRun,
symbol,
tokenAddress,
mintToBridgeOperator,
targetChainId,
} = req.body as {
lineId?: string;
amount?: string;
recipient?: string;
@@ -387,9 +408,26 @@ router.post('/omnl/settlement/token-load', async (req: Request, res: Response) =
dryRun?: boolean;
symbol?: string;
tokenAddress?: string;
mintToBridgeOperator?: boolean;
targetChainId?: number;
};
if (!lineId || !amount || !recipient) {
res.status(400).json({ error: 'lineId, amount, recipient required' });
if (!lineId || !amount) {
res.status(400).json({ error: 'lineId, amount required' });
return;
}
let mintRecipient = recipient;
if (mintToBridgeOperator || (typeof targetChainId === 'number' && targetChainId !== 138)) {
mintRecipient = getSettlementOperatorAddress() ?? getBridgeOperatorAddress() ?? undefined;
if (!mintRecipient) {
res.status(400).json({
error: 'mintToBridgeOperator requires OMNL_MINT_OPERATOR_PRIVATE_KEY (or OMNL_BRIDGE_OPERATOR_PRIVATE_KEY)',
});
return;
}
}
if (!mintRecipient) {
res.status(400).json({ error: 'recipient required when not minting to bridge operator' });
return;
}
const execute =
@@ -412,10 +450,13 @@ router.post('/omnl/settlement/token-load', async (req: Request, res: Response) =
loadId,
lineId,
amount,
recipient,
recipient: mintRecipient,
symbol: symbol ?? null,
tokenAddress: mintTokenAddress ?? null,
settlementRef: settlementRef ?? null,
mintChainId: 138,
targetChainId: targetChainId ?? 138,
mintToBridgeOperator: Boolean(mintToBridgeOperator || (targetChainId && targetChainId !== 138)),
moneyLayer: 'M3',
loadFromGl: '2200',
creditGl: '2300',
@@ -440,7 +481,7 @@ router.post('/omnl/settlement/token-load', async (req: Request, res: Response) =
try {
const { txHash } = await executeM2TokenMint({
tokenAddress: mintTokenAddress,
recipient,
recipient: mintRecipient,
amount,
});
res.json({
@@ -448,10 +489,13 @@ router.post('/omnl/settlement/token-load', async (req: Request, res: Response) =
loadId,
lineId,
amount,
recipient,
recipient: mintRecipient,
symbol: symbol ?? null,
tokenAddress: mintTokenAddress,
settlementRef: settlementRef ?? null,
mintChainId: 138,
targetChainId: targetChainId ?? 138,
mintToBridgeOperator: Boolean(mintToBridgeOperator || (targetChainId && targetChainId !== 138)),
moneyLayer: 'M3',
capabilities,
txHash,
@@ -522,4 +566,149 @@ router.post('/omnl/settlement/token-transfer', async (req: Request, res: Respons
}
});
/**
* POST /omnl/settlement/bridge-to-chain — CCIP WETH9 bridge from chain 138 to destination (Phase 1).
*/
router.post('/omnl/settlement/bridge-to-chain', async (req: Request, res: Response) => {
const {
settlementRef,
sourceChainId,
destinationChainId,
tokenAddress,
amount,
recipient,
bridgeRail,
dryRun,
decimals,
wrapNative,
} = req.body as {
settlementRef?: string;
sourceChainId?: number;
destinationChainId?: number;
tokenAddress?: string;
amount?: string;
recipient?: string;
bridgeRail?: 'ccip-weth9' | 'trustless-lockbox';
dryRun?: boolean;
decimals?: number;
wrapNative?: boolean;
};
if (!amount || !recipient) {
res.status(400).json({ error: 'amount, recipient required' });
return;
}
if (sourceChainId && sourceChainId !== 138) {
res.status(400).json({ error: 'Only chain 138 is supported as bridge source' });
return;
}
const destChainId = destinationChainId ?? 1;
if (bridgeRail && bridgeRail !== 'ccip-weth9' && bridgeRail !== 'trustless-lockbox') {
res.status(400).json({ error: `Unsupported bridgeRail: ${bridgeRail}` });
return;
}
const rail = bridgeRail ?? 'ccip-weth9';
const bridgeId = settlementRef ?? `BR-${Date.now()}`;
const execute =
!dryRun &&
(process.env.SETTLEMENT_ALLOW_CHAIN_BRIDGE_EXECUTE === '1' ||
process.env.OMNL_ALLOW_CHAIN_BRIDGE_EXECUTE === '1');
if (!execute) {
if (rail === 'trustless-lockbox') {
const preview = await dryRunTrustlessLockboxBridge({
recipient,
amount,
tokenAddress,
});
res.json({
...preview,
status: 'DRY_RUN',
bridgeId,
settlementRef: settlementRef ?? null,
sourceChainId: 138,
destinationChainId: destChainId,
amount,
recipient,
tokenAddress,
message: 'Trustless bridge validated — set SETTLEMENT_ALLOW_CHAIN_BRIDGE_EXECUTE=1 to deposit on chain 138',
});
return;
}
const preview = await dryRunCcipWeth9Bridge({
destinationChainId: destChainId,
recipient,
amount,
tokenAddress,
});
res.json({
...preview,
status: 'DRY_RUN',
bridgeId,
settlementRef: settlementRef ?? null,
sourceChainId: 138,
tokenAddress: tokenAddress ?? preview.contracts.weth9,
message: 'Bridge validated — set SETTLEMENT_ALLOW_CHAIN_BRIDGE_EXECUTE=1 to submit on-chain',
});
return;
}
try {
if (rail === 'trustless-lockbox') {
if (!tokenAddress?.startsWith('0x')) {
res.status(400).json({ error: 'tokenAddress required for trustless-lockbox bridge' });
return;
}
const { bridgeTxHash, depositId } = await executeTrustlessLockboxDeposit({
tokenAddress,
amount,
recipient,
decimals,
});
res.json({
status: 'CHAIN_BRIDGE_PENDING',
bridgeId,
settlementRef: settlementRef ?? null,
bridgeRail: 'trustless-lockbox',
sourceChainId: 138,
destinationChainId: destChainId,
amount,
recipient,
tokenAddress,
bridgeTxHash,
messageId: depositId,
message:
'Trustless lockbox deposit on chain 138 — run settlement-trustless-claim-worker on mainnet to finalize',
});
return;
}
const { bridgeTxHash, messageId } = await executeCcipWeth9Bridge({
destinationChainId: destChainId,
recipient,
amount,
tokenAddress,
decimals,
wrapNative,
});
res.json({
status: 'CHAIN_BRIDGE_PENDING',
bridgeId,
settlementRef: settlementRef ?? null,
bridgeRail: 'ccip-weth9',
sourceChainId: 138,
destinationChainId: destChainId,
amount,
recipient,
bridgeTxHash,
messageId,
message: 'CCIP WETH9 bridge submitted on chain 138 — awaiting mainnet release',
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
res.status(502).json({ error: msg, status: 'CHAIN_BRIDGE_FAILED', bridgeId });
}
});
export default router;