From c2af15075320bf41f2f93cefb4290b7a1c3d0e85 Mon Sep 17 00:00:00 2001 From: zaragoza444 Date: Sun, 5 Jul 2026 07:46:06 -0700 Subject: [PATCH] feat: OMNL Bank online production - 128 chains, Central Bank UI, settlement stack --- config/omnl-supported-chains.v1.json | 25 +- .../settlement-middleware.production.v1.json | 1 + config/settlement-middleware.v1.json | 3 +- config/zbank-consumer-account.v1.json | 74 + config/zbank-portal-auth.v1.json | 21 + frontend-dapp/src/App.tsx | 5 +- .../components/layout/OmnlProductLayout.tsx | 10 +- .../components/layout/RevolutBankLayout.tsx | 36 + frontend-dapp/src/config/dex.ts | 36 +- .../online-bank/GuosmmNoticesPanel.tsx | 2 +- .../features/online-bank/OnlineBankApp.tsx | 456 +++--- .../online-bank/RevolutHomeScreen.tsx | 324 ++++ .../online-bank/RevolutLoginScreen.tsx | 75 + .../features/online-bank/RevolutTabBar.tsx | 35 + .../online-bank/SettlementProtocolsPanel.tsx | 47 +- .../online-bank/ZBankFullSettlementPanel.tsx | 52 +- .../online-bank/ZBankPosCardsPanel.tsx | 282 ++++ .../features/online-bank/useRevolutBank.ts | 126 ++ .../src/features/online-bank/useZBankAuth.ts | 96 ++ .../src/features/online-bank/useZBankPos.ts | 182 +++ .../online-bank/useZBankSettlement.ts | 28 +- frontend-dapp/src/styles/online-bank.css | 1369 ++++++++++++++--- .../dist/bridge-validation.d.ts | 11 + .../settlement-core/dist/bridge-validation.js | 67 + .../settlement-core/dist/chain-registry.d.ts | 1 + packages/settlement-core/dist/index.d.ts | 1 + packages/settlement-core/dist/index.js | 1 + .../settlement-core/dist/state-machine.js | 2 + packages/settlement-core/dist/types.d.ts | 58 +- packages/settlement-core/dist/verbiage.js | 4 + packages/settlement-core/pnpm-lock.yaml | 39 + .../settlement-core/src/bridge-validation.ts | 83 + .../settlement-core/src/chain-registry.ts | 1 + packages/settlement-core/src/index.ts | 1 + packages/settlement-core/src/state-machine.ts | 2 + packages/settlement-core/src/types.ts | 63 + packages/settlement-core/src/verbiage.ts | 6 + .../build-portal-lxc-env-fragment.sh | 2 +- .../dist/adapters/bridge.js | 41 + .../dist/adapters/swift.js | 24 + .../dist/api/routes/settlement.js | 234 +++ services/settlement-middleware/dist/config.js | 9 + services/settlement-middleware/dist/index.js | 17 +- .../dist/services/zbank-consumer-banking.js | 263 ++++ .../dist/services/zbank-consumer-config.js | 20 + .../dist/services/zbank-portal-auth.js | 145 ++ .../dist/services/zbank-pos-config.js | 27 + .../dist/store/zbank-card-store.js | 81 + .../dist/workflows/external-transfer.js | 37 +- .../dist/workflows/settlement-bridge.js | 118 ++ .../dist/workflows/token-load.js | 21 +- .../dist/workflows/z-bank-m0-m4.js | 205 +++ .../dist/workflows/zbank-card-load.js | 130 ++ .../dist/workflows/zbank-pos-sale.js | 86 ++ .../dist/workflows/zbank-proof-of-funds.js | 52 + .../src/adapters/bridge.ts | 53 + .../src/adapters/omnl.ts | 2 + .../src/api/routes/settlement.ts | 240 ++- services/settlement-middleware/src/config.ts | 10 + services/settlement-middleware/src/index.ts | 18 +- .../src/services/zbank-consumer-banking.ts | 360 +++++ .../src/services/zbank-consumer-config.ts | 72 + .../src/services/zbank-portal-auth.ts | 169 ++ .../src/services/zbank-pos-config.ts | 42 + .../src/store/zbank-card-store.ts | 81 + .../src/workflows/external-transfer.ts | 23 +- .../src/workflows/settlement-bridge.ts | 153 ++ .../src/workflows/token-load.ts | 28 +- .../src/workflows/z-bank-m0-m4.ts | 25 +- .../src/workflows/zbank-card-load.ts | 139 ++ .../src/workflows/zbank-pos-sale.ts | 91 ++ .../src/workflows/zbank-proof-of-funds.ts | 51 + .../token-aggregation/src/api/routes/omnl.ts | 201 ++- 73 files changed, 6319 insertions(+), 576 deletions(-) create mode 100644 config/zbank-consumer-account.v1.json create mode 100644 config/zbank-portal-auth.v1.json create mode 100644 frontend-dapp/src/components/layout/RevolutBankLayout.tsx create mode 100644 frontend-dapp/src/features/online-bank/RevolutHomeScreen.tsx create mode 100644 frontend-dapp/src/features/online-bank/RevolutLoginScreen.tsx create mode 100644 frontend-dapp/src/features/online-bank/RevolutTabBar.tsx create mode 100644 frontend-dapp/src/features/online-bank/ZBankPosCardsPanel.tsx create mode 100644 frontend-dapp/src/features/online-bank/useRevolutBank.ts create mode 100644 frontend-dapp/src/features/online-bank/useZBankAuth.ts create mode 100644 frontend-dapp/src/features/online-bank/useZBankPos.ts create mode 100644 packages/settlement-core/dist/bridge-validation.d.ts create mode 100644 packages/settlement-core/dist/bridge-validation.js create mode 100644 packages/settlement-core/pnpm-lock.yaml create mode 100644 packages/settlement-core/src/bridge-validation.ts create mode 100644 services/settlement-middleware/dist/adapters/bridge.js create mode 100644 services/settlement-middleware/dist/services/zbank-consumer-banking.js create mode 100644 services/settlement-middleware/dist/services/zbank-consumer-config.js create mode 100644 services/settlement-middleware/dist/services/zbank-portal-auth.js create mode 100644 services/settlement-middleware/dist/services/zbank-pos-config.js create mode 100644 services/settlement-middleware/dist/store/zbank-card-store.js create mode 100644 services/settlement-middleware/dist/workflows/settlement-bridge.js create mode 100644 services/settlement-middleware/dist/workflows/z-bank-m0-m4.js create mode 100644 services/settlement-middleware/dist/workflows/zbank-card-load.js create mode 100644 services/settlement-middleware/dist/workflows/zbank-pos-sale.js create mode 100644 services/settlement-middleware/dist/workflows/zbank-proof-of-funds.js create mode 100644 services/settlement-middleware/src/adapters/bridge.ts create mode 100644 services/settlement-middleware/src/services/zbank-consumer-banking.ts create mode 100644 services/settlement-middleware/src/services/zbank-consumer-config.ts create mode 100644 services/settlement-middleware/src/services/zbank-portal-auth.ts create mode 100644 services/settlement-middleware/src/services/zbank-pos-config.ts create mode 100644 services/settlement-middleware/src/store/zbank-card-store.ts create mode 100644 services/settlement-middleware/src/workflows/settlement-bridge.ts create mode 100644 services/settlement-middleware/src/workflows/zbank-card-load.ts create mode 100644 services/settlement-middleware/src/workflows/zbank-pos-sale.ts create mode 100644 services/settlement-middleware/src/workflows/zbank-proof-of-funds.ts diff --git a/config/omnl-supported-chains.v1.json b/config/omnl-supported-chains.v1.json index c05ed1c..0310e12 100644 --- a/config/omnl-supported-chains.v1.json +++ b/config/omnl-supported-chains.v1.json @@ -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, diff --git a/config/settlement-middleware.production.v1.json b/config/settlement-middleware.production.v1.json index 27d66c3..8088eff 100644 --- a/config/settlement-middleware.production.v1.json +++ b/config/settlement-middleware.production.v1.json @@ -46,6 +46,7 @@ "requireApiKey": true, "allowHybxProduction": true, "allowChainMintExecute": true, + "allowChainBridgeExecute": true, "onlineBankMode": true }, "guosmm": { diff --git a/config/settlement-middleware.v1.json b/config/settlement-middleware.v1.json index 624e8f7..77ecbec 100644 --- a/config/settlement-middleware.v1.json +++ b/config/settlement-middleware.v1.json @@ -38,6 +38,7 @@ "production": { "requireApiKey": true, "allowHybxProduction": false, - "allowChainMintExecute": false + "allowChainMintExecute": false, + "allowChainBridgeExecute": false } } diff --git a/config/zbank-consumer-account.v1.json b/config/zbank-consumer-account.v1.json new file mode 100644 index 0000000..df43ab9 --- /dev/null +++ b/config/zbank-consumer-account.v1.json @@ -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" } + ] +} diff --git a/config/zbank-portal-auth.v1.json b/config/zbank-portal-auth.v1.json new file mode 100644 index 0000000..f67c63e --- /dev/null +++ b/config/zbank-portal-auth.v1.json @@ -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" + } + ] +} diff --git a/frontend-dapp/src/App.tsx b/frontend-dapp/src/App.tsx index 65fc3a2..a56dc4c 100644 --- a/frontend-dapp/src/App.tsx +++ b/frontend-dapp/src/App.tsx @@ -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() { }> } /> - } /> } /> } /> } /> + }> + } /> + diff --git a/frontend-dapp/src/components/layout/OmnlProductLayout.tsx b/frontend-dapp/src/components/layout/OmnlProductLayout.tsx index b139764..c83253d 100644 --- a/frontend-dapp/src/components/layout/OmnlProductLayout.tsx +++ b/frontend-dapp/src/components/layout/OmnlProductLayout.tsx @@ -34,7 +34,11 @@ export default function OmnlProductLayout() { return (
-
+
-
-
- {bank.activities.length === 0 ? ( -
- - + {tab === 'invest' && ( +
+
+

Invest

+

Stocks, crypto & markets

+
+
+ + + -
-

No transactions yet

-

Swaps and settlements appear here

-
+ Stocks + DBIS spot · commission-free + + + + + + Crypto + Buy & exchange on chain 138 + + + + + + OneX Wallet + MPC · Shiva blockchain + +
+
+ )} + + {tab === 'payments' && ( +
+
+

Payments

+

Send, settle & load cards

+
+ {(bank.error || revolut.error) && ( +
+ {revolut.error ?? bank.error}
- ) : ( - bank.activities.map((item) => ( -
- - - -
-

{item.title}

-

{item.meta}

-
- - {item.amount} - -
- )) )} + + +
- + )} -
-

- Explore -

-
- - Digital Swap - Convert & load M2 tokens - - - DBIS Trade - Spot markets - - - Office 24 Central Bank - 128-bank corridors · IPSAS - - - OneX Wallet - Shiva blockchain · MPC wallet - - - OneX Bank - HYBX corridor · office 29 - - - Nova Bank Online - Import wires · M4 - - - Reserve - Coverage & attestation - + {tab === 'hub' && ( +
+
+

Hub

+

All products in one place

+
+
+ + + + + Exchange + Convert & load M2 + + + + + + Stocks + Spot markets + + + + + + Office 24 + 128-bank corridors + + + + + + Crypto + OneX · MPC wallet + + + + + + + Vaults + Reserve attestation + + + + + + Bridge + Cross-chain transfer + + + + + + DBIS Dashboard + Operator consoles + +
-
+ )} - + {tab === 'more' && ( +
+
+

More

+

Settings & operator tools

+
+
+
{initials(displayName)}
+
+

{displayName}

+

+ {revolut.overview?.profile.plan ?? 'Standard'} · {revolut.overview?.profile.externalId ?? bank.officeLabel} +

+
+
-
- - Treasury & operator console - - -
- + {revolut.overview && ( +
+

Limits

+
+ + + + +
+
+ )} + +
+

Personal details

+
+
+
Email
+
{revolut.overview?.profile.email ?? '—'}
+
+
+
Phone
+
{revolut.overview?.profile.phone ?? '—'}
+
+
+
KYC
+
{revolut.overview?.profile.kycStatus ?? '—'} ({revolut.overview?.profile.kycLevel ?? '—'})
+
+
+
Member since
+
{revolut.overview?.profile.memberSince ?? '—'}
+
+
+
+
+ +
+ +
+ + Treasury & operator console + + +
+ +
+
-
+ )}
+ +
); } -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 ( +
+
+ {label} + + {used} / {limit} USD + +
+
+ +
+
+ ); } diff --git a/frontend-dapp/src/features/online-bank/RevolutHomeScreen.tsx b/frontend-dapp/src/features/online-bank/RevolutHomeScreen.tsx new file mode 100644 index 0000000..2af0b5b --- /dev/null +++ b/frontend-dapp/src/features/online-bank/RevolutHomeScreen.tsx @@ -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 ( +
+
+
+
+ ); +} + +export default function RevolutHomeScreen({ + overview, + loading, + balanceHidden, + onToggleBalance, + onGoPayments, + onGoCards, + initials, +}: Props) { + const [activeCard, setActiveCard] = useState(0); + const [selectedAccount, setSelectedAccount] = useState(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 ( + <> +
+ +
+ + {overview?.fineractLive ? 'Live' : 'Offline'} + + + +
+
+ + {visibleCards.length > 0 && ( +
+
+ {visibleCards.map((card, index) => { + const offset = (index - activeCard + visibleCards.length) % visibleCards.length; + return ( + + ); + })} +
+
+ {visibleCards.map((c, i) => ( + + ))} +
+
+ )} + + {loading && !overview ? ( + + ) : ( +
+
+

Total balance

+ +
+

+ {formatBalance(overview?.totalBalance ?? '0', overview?.totalBalanceCurrency ?? 'USD', balanceHidden)} +

+ {overview?.profile.plan && ( +

+ {overview.profile.plan} · {overview.profile.externalId} +

+ )} +
+ )} + + + +
+
+

Accounts

+ +
+
+ {accounts.map((acct) => ( + + ))} +
+
+ + {selectedAccount && ( + setSelectedAccount(null)} /> + )} + +
+
+

Transactions

+
+
+ {transactions.length === 0 ? ( +
+ + + +
+

No transactions yet

+

Your activity will show up here

+
+
+ ) : ( + transactions.slice(0, 12).map((item) => ( +
+ + + +
+

{item.title}

+

{formatTxMeta(item)}

+
+ + {txAmount(item, balanceHidden)} + +
+ )) + )} +
+
+ + ); +} + +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 ( + + ); +} + +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 ( +
+
e.stopPropagation()} + > +
+

+ {account.name} +

+

{d.shareText}

+
+ {rows.map((row) => ( +
+
{row.label}
+
+ {row.value} + {row.copy && ( + + )} +
+
+ ))} +
+ +
+
+ ); +} + +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}`; +} diff --git a/frontend-dapp/src/features/online-bank/RevolutLoginScreen.tsx b/frontend-dapp/src/features/online-bank/RevolutLoginScreen.tsx new file mode 100644 index 0000000..bbfb346 --- /dev/null +++ b/frontend-dapp/src/features/online-bank/RevolutLoginScreen.tsx @@ -0,0 +1,75 @@ +import { FormEvent, useState } from 'react'; + +type Props = { + onSuccess?: () => void; + login: (phone: string, passcode: string) => Promise; +}; + +export default function RevolutLoginScreen({ onSuccess, login }: Props) { + const [phone, setPhone] = useState(''); + const [passcode, setPasscode] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( +
+
+
Z
+

Z Online Bank

+

International portal — secure sign in

+
+ +
+ + + + + {error ?

{error}

: null} + + +
+ +

+ Protected by session encryption. Contact treasury for access. +

+
+ ); +} diff --git a/frontend-dapp/src/features/online-bank/RevolutTabBar.tsx b/frontend-dapp/src/features/online-bank/RevolutTabBar.tsx new file mode 100644 index 0000000..15c16bb --- /dev/null +++ b/frontend-dapp/src/features/online-bank/RevolutTabBar.tsx @@ -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 ( + + ); +} diff --git a/frontend-dapp/src/features/online-bank/SettlementProtocolsPanel.tsx b/frontend-dapp/src/features/online-bank/SettlementProtocolsPanel.tsx index edc54b8..8e36d4c 100644 --- a/frontend-dapp/src/features/online-bank/SettlementProtocolsPanel.tsx +++ b/frontend-dapp/src/features/online-bank/SettlementProtocolsPanel.tsx @@ -11,6 +11,8 @@ const PHASE_LABEL: Record = { 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 = { 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') && ( - + <> + + + )} )} @@ -341,6 +359,21 @@ export default function SettlementProtocolsPanel({ onSuccess }: { onSuccess?: () Fineract: {s.lastResult.fineractJournalRef}

)} + {s.lastResult.chainTxHash && ( +

+ Chain mint: {s.lastResult.chainTxHash} +

+ )} + {s.lastResult.bridgeTxHash && ( +

+ Bridge tx: {s.lastResult.bridgeTxHash} +

+ )} + {s.lastResult.bridgeMessageId && ( +

+ Bridge message: {s.lastResult.bridgeMessageId} +

+ )}
)} diff --git a/frontend-dapp/src/features/online-bank/ZBankFullSettlementPanel.tsx b/frontend-dapp/src/features/online-bank/ZBankFullSettlementPanel.tsx index deb27d1..77680e2 100644 --- a/frontend-dapp/src/features/online-bank/ZBankFullSettlementPanel.tsx +++ b/frontend-dapp/src/features/online-bank/ZBankFullSettlementPanel.tsx @@ -11,6 +11,8 @@ const PHASE_LABEL: Record = { 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) Include M3 on-chain mint (M2→M3→M4 path) {includeM3 && ( - + <> + + + )}
@@ -193,6 +210,21 @@ export default function ZBankFullSettlementPanel({ onSuccess, balances }: Props) Chain mint: {s.lastResult.chainTxHash}

)} + {s.lastResult.bridgeTxHash && ( +

+ Bridge tx: {s.lastResult.bridgeTxHash} +

+ )} + {s.lastResult.bridgeMessageId && ( +

+ Bridge message: {s.lastResult.bridgeMessageId} +

+ )} + {s.lastResult.destinationChainId && s.lastResult.destinationChainId !== 138 && ( +

+ Destination chain: {s.lastResult.destinationChainId} +

+ )}
)} diff --git a/frontend-dapp/src/features/online-bank/ZBankPosCardsPanel.tsx b/frontend-dapp/src/features/online-bank/ZBankPosCardsPanel.tsx new file mode 100644 index 0000000..eb15909 --- /dev/null +++ b/frontend-dapp/src/features/online-bank/ZBankPosCardsPanel.tsx @@ -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('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 ( +
+

zBank · Cards & POS

+

+ Load prepaid cards from zBank M2 (Office 29), issue proof-of-funds attestations, and run POS sales. +

+ +
+ {( + [ + ['pof', 'Proof of funds'], + ['load', 'Load card'], + ['pos', 'POS terminal'], + ] as const + ).map(([id, label]) => ( + + ))} +
+ + {pos.error && ( +
+ {pos.error} +
+ )} + + {tab === 'pof' && ( +
+

+ Fineract GL attestation for zBank (Office {pos.config?.officeId ?? 29}) — M1/M2 + card prepaid pool. +

+ + {pos.pof && ( +
+

+ Ref: {pos.pof.settlementRef} +

+

+ Primary GL {pos.pof.attestation.primaryGlCode}:{' '} + {formatUsd(pos.pof.attestation.primaryAmountUsd)} {pos.pof.attestation.currency} +

+

{pos.pof.attestation.methodology}

+ {pos.pof.integrity?.sha256 && ( +

+ SHA-256: {pos.pof.integrity.sha256.slice(0, 24)}… +

+ )} + +
+ )} +
+ )} + + {tab === 'load' && ( +
+
+ + + + +
+ + {pos.lastLoad?.card && ( +
+

+ Card: •••• {(pos.lastLoad.card as { cardLast4: string }).cardLast4} ·{' '} + {(pos.lastLoad.card as { cardId: string }).cardId} +

+

+ Balance: {formatUsd((pos.lastLoad.card as { balance: string }).balance)} +

+
+ )} +
+ )} + + {tab === 'pos' && ( +
+
+ + + +
+ + {pos.lastSale && ( +
+

+ Status: {String(pos.lastSale.status)} +

+ {pos.lastSale.merchantName && ( +

+ Merchant: {String(pos.lastSale.merchantName)} +

+ )} +
+ )} +
+ )} + + {pos.cards.length > 0 && ( +
+

Loaded cards

+
    + {pos.cards.map((c) => ( +
  • + + Z + +
    +

    + {c.productLabel} · •••• {c.cardLast4} +

    +

    {c.cardholderName}

    +
    + {formatUsd(c.balance)} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend-dapp/src/features/online-bank/useRevolutBank.ts b/frontend-dapp/src/features/online-bank/useRevolutBank.ts new file mode 100644 index 0000000..982fd7f --- /dev/null +++ b/frontend-dapp/src/features/online-bank/useRevolutBank.ts @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +} diff --git a/frontend-dapp/src/features/online-bank/useZBankAuth.ts b/frontend-dapp/src/features/online-bank/useZBankAuth.ts new file mode 100644 index 0000000..3a512f2 --- /dev/null +++ b/frontend-dapp/src/features/online-bank/useZBankAuth.ts @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +} diff --git a/frontend-dapp/src/features/online-bank/useZBankPos.ts b/frontend-dapp/src/features/online-bank/useZBankPos.ts new file mode 100644 index 0000000..86a3bbb --- /dev/null +++ b/frontend-dapp/src/features/online-bank/useZBankPos.ts @@ -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(null); + const [cards, setCards] = useState([]); + const [pof, setPof] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastSale, setLastSale] = useState | null>(null); + const [lastLoad, setLastLoad] = useState | 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; 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 & { 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), + }; +} diff --git a/frontend-dapp/src/features/online-bank/useZBankSettlement.ts b/frontend-dapp/src/features/online-bank/useZBankSettlement.ts index 1aabeaa..bf6ede3 100644 --- a/frontend-dapp/src/features/online-bank/useZBankSettlement.ts +++ b/frontend-dapp/src/features/online-bank/useZBankSettlement.ts @@ -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(exchangeApi('/token-load'), { + const r = await postJson(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(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, diff --git a/frontend-dapp/src/styles/online-bank.css b/frontend-dapp/src/styles/online-bank.css index 8aa3ce8..5f663a0 100644 --- a/frontend-dapp/src/styles/online-bank.css +++ b/frontend-dapp/src/styles/online-bank.css @@ -1,36 +1,67 @@ /** - * Z Online Bank — Wise-inspired online banking UI + * Z Bank — Revolut-inspired dark fintech UI + * Tokens: canvas #000, elevated #16181a, cobalt #494fdf, Inter UI type */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +/* ── Full-screen Revolut app shell ── */ +.revolut-app-shell { + min-height: 100dvh; + background: #000000; + display: flex; + flex-direction: column; +} + +.revolut-app-shell__main { + flex: 1; + min-width: 0; + overflow-x: hidden; +} + .revolut-bank { - --rb-bg: #f2f2f2; - --rb-surface: #ffffff; - --rb-card: #ffffff; - --rb-text: #163300; - --rb-text-body: #454745; - --rb-muted: #6a6a6a; - --rb-subject: #454745; - --rb-accent: #9fe870; - --rb-accent-hover: #8fd960; - --rb-accent-text: #163300; - --rb-accent-deep: #0e2f0a; - --rb-accent-soft: #e8f8e0; - --rb-border: #e2e2e2; - --rb-border-strong: #d3d3d3; - --rb-shadow: 0 1px 3px rgba(22, 51, 0, 0.06); - --rb-shadow-md: 0 4px 12px rgba(22, 51, 0, 0.08); + --rb-bg: #000000; + --rb-surface: #16181a; + --rb-surface-soft: #191c1f; + --rb-surface-elevated: #232628; + --rb-card: #16181a; + --rb-text: #ffffff; + --rb-text-body: #ffffff; + --rb-muted: #8d969e; + --rb-subject: #8d969e; + --rb-accent: #494fdf; + --rb-accent-hover: #5a5fe8; + --rb-accent-text: #ffffff; + --rb-accent-deep: #494fdf; + --rb-accent-soft: rgba(73, 79, 223, 0.18); + --rb-border: rgba(255, 255, 255, 0.08); + --rb-border-strong: rgba(255, 255, 255, 0.14); + --rb-positive: #26c281; + --rb-negative: #ff6b6b; + --rb-warning-bg: rgba(255, 180, 0, 0.12); + --rb-warning-text: #ffb400; + --rb-error-bg: rgba(255, 107, 107, 0.12); + --rb-error-text: #ff6b6b; --rb-radius: 16px; + --rb-radius-lg: 20px; --rb-radius-sm: 12px; --rb-radius-pill: 9999px; - min-height: calc(100dvh - 3.5rem); + min-height: 100dvh; background: var(--rb-bg); color: var(--rb-text-body); font-family: Inter, 'Segoe UI', system-ui, -apple-system, sans-serif; + letter-spacing: 0.01em; -webkit-font-smoothing: antialiased; + padding-bottom: calc(4.25rem + env(safe-area-inset-bottom, 0)); +} + +/* Shell: Revolut black header + bottom nav on Z Bank route */ +.omnl-banking-theme { + background: var(--rb-bg); } .omnl-banking-theme .omnl-app-header { - background: #ffffff !important; + background: #000000 !important; border-bottom: 1px solid var(--rb-border) !important; box-shadow: none !important; } @@ -38,7 +69,7 @@ .omnl-banking-theme .omnl-app-header a, .omnl-banking-theme .omnl-app-header .font-semibold, .omnl-banking-theme .omnl-app-header button { - color: var(--rb-text) !important; + color: #ffffff !important; } .omnl-banking-theme .omnl-app-header nav a { @@ -46,42 +77,57 @@ } .omnl-banking-theme .omnl-app-header nav a.bg-green-600 { - background: var(--rb-accent) !important; - color: var(--rb-accent-text) !important; + background: var(--rb-surface-elevated) !important; + color: #ffffff !important; + box-shadow: inset 0 0 0 1px var(--rb-border-strong); } .omnl-banking-theme .omnl-app-header .bg-green-600 { - background: var(--rb-accent-deep) !important; + background: var(--rb-accent) !important; } .omnl-banking-theme .omnl-app-header .text-white { - color: var(--rb-text) !important; + color: #ffffff !important; } .omnl-banking-theme .omnl-app-header .hover\:bg-white\/10:hover { - background: var(--rb-bg) !important; + background: var(--rb-surface) !important; +} + +.omnl-banking-theme .dbis-bottom-nav { + background: #000000; + border-top: 1px solid var(--rb-border); +} + +.omnl-banking-theme .dbis-bottom-nav__item { + color: var(--rb-muted); +} + +.omnl-banking-theme .dbis-bottom-nav__item--active { + color: #ffffff; } .revolut-bank__inner { - max-width: 42rem; + max-width: 100%; margin: 0 auto; - padding: 1.25rem 1rem 6rem; + padding: 0.75rem 1rem 1rem; } @media (min-width: 768px) { .revolut-bank__inner { - max-width: 52rem; - padding: 2rem 1.5rem 3rem; + max-width: 430px; + padding: 1rem 1.25rem 1.5rem; } } -/* Page header */ +/* Page header — Revolut: avatar only, icons right */ .revolut-bank__page-header { display: flex; align-items: center; justify-content: space-between; - gap: 1rem; - margin-bottom: 1.75rem; + gap: 0.75rem; + margin-bottom: 1rem; + padding-top: env(safe-area-inset-top, 0); } .revolut-bank__logo-row { @@ -91,16 +137,31 @@ min-width: 0; } +.revolut-bank__avatar { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + background: var(--rb-surface-elevated); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.8125rem; + flex-shrink: 0; + border: 1px solid var(--rb-border-strong); +} + .revolut-bank__logo-mark { width: 2.5rem; height: 2.5rem; border-radius: 50%; background: var(--rb-accent); - color: var(--rb-accent-text); + color: #ffffff; display: flex; align-items: center; justify-content: center; - font-weight: 800; + font-weight: 700; font-size: 1rem; flex-shrink: 0; } @@ -110,74 +171,255 @@ } .revolut-bank__brand-title { - font-size: 1.125rem; - font-weight: 700; + font-size: 1.25rem; + font-weight: 600; color: var(--rb-text); - letter-spacing: -0.02em; + letter-spacing: -0.03em; margin: 0; - line-height: 1.2; + line-height: 1.15; } .revolut-bank__greeting-label { font-size: 0.8125rem; color: var(--rb-muted); - margin: 0; + margin: 0.15rem 0 0; + letter-spacing: 0.02em; } .revolut-bank__greeting-name { - font-size: 0.8125rem; - font-weight: 600; + font-weight: 500; color: var(--rb-text); - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +} + +.revolut-bank__header-actions { + display: flex; + align-items: center; + gap: 0.35rem; + flex-shrink: 0; +} + +.revolut-bank__icon-btn { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + border: none; + background: var(--rb-surface); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + text-decoration: none; + transition: background 0.15s; +} + +.revolut-bank__icon-btn:hover { + background: var(--rb-surface-elevated); } .revolut-bank__status { - display: inline-flex; + display: none; align-items: center; gap: 0.35rem; font-size: 0.6875rem; font-weight: 600; padding: 0.35rem 0.65rem; border-radius: var(--rb-radius-pill); - background: var(--rb-accent-soft); - color: var(--rb-accent-deep); - border: 1px solid rgba(159, 232, 112, 0.5); - flex-shrink: 0; + background: rgba(38, 194, 129, 0.15); + color: var(--rb-positive); + border: 1px solid rgba(38, 194, 129, 0.25); +} + +@media (min-width: 480px) { + .revolut-bank__status { + display: inline-flex; + } } .revolut-bank__status--offline { - background: #f5f5f5; + background: var(--rb-surface); color: var(--rb-muted); border-color: var(--rb-border); } +/* Virtual card stack — Revolut peek deck */ +.revolut-bank__card-stack-wrap { + margin-bottom: 1.5rem; +} + +.revolut-bank__card-stack { + position: relative; + height: 11.5rem; + margin: 0 auto; + max-width: 22rem; +} + +.revolut-bank__virtual-card { + position: absolute; + left: 0; + right: 0; + width: 100%; + border: none; + cursor: pointer; + text-align: left; + aspect-ratio: 1.586; + border-radius: var(--rb-radius-lg); + padding: 1.25rem; + display: flex; + flex-direction: column; + justify-content: space-between; + color: #ffffff; + overflow: hidden; + transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); +} + +.revolut-bank__virtual-card--stack-0 { + z-index: 3; + transform: translateY(0) scale(1); + opacity: 1; +} + +.revolut-bank__virtual-card--stack-1 { + z-index: 2; + transform: translateY(14px) scale(0.94); + opacity: 0.75; + pointer-events: none; +} + +.revolut-bank__virtual-card--stack-2 { + z-index: 1; + transform: translateY(28px) scale(0.88); + opacity: 0.45; + pointer-events: none; +} + +.revolut-bank__virtual-card::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, transparent 55%); + pointer-events: none; +} + +.revolut-bank__virtual-card--metal { + background: linear-gradient(135deg, #2b2f33 0%, #6b7280 45%, #374151 100%); +} + +.revolut-bank__virtual-card--standard { + background: linear-gradient(135deg, #494fdf 0%, #7c3aed 55%, #494fdf 100%); +} + +.revolut-bank__virtual-card--premium { + background: linear-gradient(135deg, #0f0f0f 0%, #232628 50%, #494fdf 100%); +} + +.revolut-bank__virtual-card-top { + display: flex; + align-items: center; + justify-content: space-between; + position: relative; + z-index: 1; +} + +.revolut-bank__virtual-card-brand { + font-size: 1.125rem; + font-weight: 700; + letter-spacing: -0.04em; +} + +.revolut-bank__virtual-card-type { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.85; +} + +.revolut-bank__virtual-card-number { + font-size: 1.125rem; + font-weight: 500; + letter-spacing: 0.12em; + margin: 0; + position: relative; + z-index: 1; +} + +.revolut-bank__virtual-card-name { + font-size: 0.8125rem; + font-weight: 500; + margin: 0; + opacity: 0.9; + position: relative; + z-index: 1; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.revolut-bank__card-dots { + display: flex; + justify-content: center; + gap: 0.35rem; + margin-top: 2.25rem; +} + +.revolut-bank__card-dot { + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background: var(--rb-border-strong); +} + +.revolut-bank__card-dot--active { + background: #ffffff; + width: 1.25rem; + border-radius: var(--rb-radius-pill); +} + /* Balance hero */ .revolut-bank__balance-card { background: transparent; - padding: 0 0 1.5rem; - margin-bottom: 0.5rem; - border: none; - box-shadow: none; - position: relative; + padding: 0 0 1.25rem; + margin-bottom: 0.25rem; } -.revolut-bank__balance-card::before { - display: none; +.revolut-bank__balance-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.35rem; } .revolut-bank__balance-label { font-size: 0.875rem; color: var(--rb-muted); - margin-bottom: 0.5rem; + margin: 0; font-weight: 400; + letter-spacing: 0.02em; +} + +.revolut-bank__balance-toggle { + width: 1.75rem; + height: 1.75rem; + border: none; + background: transparent; + color: var(--rb-muted); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + border-radius: 50%; +} + +.revolut-bank__balance-toggle:hover { + color: #ffffff; + background: var(--rb-surface); } .revolut-bank__balance-amount { - font-size: 2.75rem; - font-weight: 700; + font-size: 2.5rem; + font-weight: 600; letter-spacing: -0.04em; line-height: 1.05; color: var(--rb-text); @@ -185,32 +427,85 @@ @media (min-width: 768px) { .revolut-bank__balance-amount { - font-size: 3.25rem; + font-size: 3rem; } } .revolut-bank__balance-sub { - margin-top: 0.65rem; + margin-top: 0.5rem; font-size: 0.8125rem; color: var(--rb-muted); display: flex; flex-wrap: wrap; gap: 0.75rem; - border: none; +} + +.revolut-bank__balance-account { + margin: 0.35rem 0 0; + font-size: 0.8125rem; + color: var(--rb-muted); + letter-spacing: 0.02em; +} + +/* Quick actions — Revolut circular icons */ +.revolut-bank__actions { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.35rem; + margin-bottom: 1.75rem; padding: 0; } -.revolut-bank__balance-sub span:last-child { - color: var(--rb-accent-deep); - font-weight: 600; +.revolut-bank__action { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.45rem; + padding: 0.35rem 0.25rem; + text-decoration: none; + color: var(--rb-text); + border: none; + background: none; + cursor: pointer; + font-size: 0.6875rem; + font-weight: 500; + letter-spacing: 0.02em; } -/* Primary CTAs — Wise Send / Add money */ +.revolut-bank__action-icon { + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + background: var(--rb-surface); + border: 1px solid var(--rb-border); + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + transition: background 0.15s, transform 0.1s; +} + +.revolut-bank__action-icon--primary { + background: #ffffff; + color: #000000; + border-color: #ffffff; +} + +.revolut-bank__action:hover .revolut-bank__action-icon { + background: var(--rb-surface-elevated); + transform: scale(1.03); +} + +.revolut-bank__action:hover .revolut-bank__action-icon--primary { + background: #f0f0f0; +} + +/* Legacy CTA row (panels may still reference) */ .revolut-bank__cta-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; - margin-bottom: 2rem; + margin-bottom: 1.5rem; } .revolut-bank__cta { @@ -225,169 +520,124 @@ text-decoration: none; cursor: pointer; border: none; - transition: background 0.15s, transform 0.1s; min-height: 48px; + transition: background 0.15s; } .revolut-bank__cta--primary { - background: var(--rb-accent); - color: var(--rb-accent-text); + background: #ffffff; + color: #000000; } .revolut-bank__cta--primary:hover { - background: var(--rb-accent-hover); + background: #e8e8e8; } .revolut-bank__cta--secondary { background: var(--rb-surface); - color: var(--rb-text); + color: #ffffff; border: 1px solid var(--rb-border-strong); } .revolut-bank__cta--secondary:hover { - background: #fafafa; + background: var(--rb-surface-elevated); } -/* Account list — Wise-style rows */ +/* Accounts — horizontal scroll tiles */ .revolut-bank__accounts-section { - margin-bottom: 2rem; + margin-bottom: 1.75rem; } .revolut-bank__accounts-heading { font-size: 1.125rem; - font-weight: 700; + font-weight: 600; color: var(--rb-text); - margin: 0 0 0.75rem; + margin: 0; letter-spacing: -0.02em; } .revolut-bank__accounts { display: flex; - flex-direction: column; - gap: 0.5rem; - overflow: visible; - padding: 0; - margin: 0; + gap: 0.65rem; + overflow-x: auto; + scroll-snap-type: x mandatory; + scrollbar-width: none; + padding: 0.75rem 0 0.25rem; + margin: 0 -0.25rem; +} + +.revolut-bank__accounts::-webkit-scrollbar { + display: none; } .revolut-bank__account-pill { + flex: 0 0 auto; + min-width: 7.5rem; display: flex; - align-items: center; - gap: 0.875rem; - min-width: 0; - padding: 1rem 1.125rem; - border-radius: var(--rb-radius-sm); + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; + padding: 0.875rem 1rem; + border-radius: var(--rb-radius); background: var(--rb-surface); border: 1px solid var(--rb-border); - box-shadow: var(--rb-shadow); - cursor: default; + scroll-snap-align: start; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; } .revolut-bank__account-pill--active { border-color: var(--rb-accent); - background: var(--rb-surface); - box-shadow: var(--rb-shadow-md); + background: linear-gradient(160deg, rgba(73, 79, 223, 0.2) 0%, var(--rb-surface) 60%); } .revolut-bank__account-flag { - width: 2.5rem; - height: 2.5rem; + width: 2rem; + height: 2rem; border-radius: 50%; - background: var(--rb-bg); - border: 1px solid var(--rb-border); + background: var(--rb-surface-elevated); + border: none; display: flex; align-items: center; justify-content: center; - font-size: 0.6875rem; - font-weight: 800; - color: var(--rb-text); + font-size: 1rem; flex-shrink: 0; - letter-spacing: -0.02em; + line-height: 1; } .revolut-bank__account-pill--active .revolut-bank__account-flag { background: var(--rb-accent-soft); - border-color: rgba(159, 232, 112, 0.6); -} - -.revolut-bank__account-pill-body { - flex: 1; - min-width: 0; + border-color: rgba(73, 79, 223, 0.45); + color: #ffffff; } .revolut-bank__account-pill-label { - font-size: 0.9375rem; - font-weight: 600; + font-size: 0.875rem; + font-weight: 500; color: var(--rb-text); - margin-bottom: 0.1rem; - text-transform: none; - letter-spacing: 0; -} - -.revolut-bank__account-pill-meta { - font-size: 0.75rem; - color: var(--rb-muted); + margin: 0; } .revolut-bank__account-pill-value { font-size: 1rem; - font-weight: 700; - color: var(--rb-text); - flex-shrink: 0; - text-align: right; -} - -/* Quick links row */ -.revolut-bank__actions { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 0.5rem; - margin-bottom: 2rem; - padding: 0; - background: transparent; - border: none; - box-shadow: none; -} - -.revolut-bank__action { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.4rem; - padding: 0.5rem 0.25rem; - text-decoration: none; - color: var(--rb-subject); - border: none; - background: none; - cursor: pointer; - font-size: 0.6875rem; font-weight: 600; -} - -.revolut-bank__action-icon { - width: 3rem; - height: 3rem; - border-radius: 50%; - background: var(--rb-surface); - border: 1px solid var(--rb-border); - display: flex; - align-items: center; - justify-content: center; color: var(--rb-text); - transition: background 0.15s, border-color 0.15s; - box-shadow: var(--rb-shadow); + margin: 0; + letter-spacing: -0.02em; } -.revolut-bank__action:hover .revolut-bank__action-icon { - background: var(--rb-accent-soft); - border-color: rgba(159, 232, 112, 0.5); - transform: none; +.revolut-bank__account-pill-meta { + font-size: 0.6875rem; + color: var(--rb-muted); + margin: 0; } /* Sections */ .revolut-bank__section-title { font-size: 1.125rem; - font-weight: 700; + font-weight: 600; margin-bottom: 0.75rem; display: flex; align-items: center; @@ -406,26 +656,29 @@ .revolut-bank__section-link { font-size: 0.875rem; - font-weight: 600; - color: var(--rb-accent-deep); + font-weight: 500; + color: var(--rb-accent); + text-decoration: none; +} + +.revolut-bank__section-link:hover { text-decoration: underline; text-underline-offset: 2px; } -/* Activity */ +/* Activity / transactions */ .revolut-bank__activity { background: var(--rb-surface); - border-radius: var(--rb-radius-sm); + border-radius: var(--rb-radius); border: 1px solid var(--rb-border); - box-shadow: var(--rb-shadow); overflow: hidden; - margin-bottom: 2rem; + margin-bottom: 1.75rem; } .revolut-bank__error { - color: #9a6700; - background: #fffbeb; - border-color: #fcd34d; + color: var(--rb-warning-text); + background: var(--rb-warning-bg); + border-color: rgba(255, 180, 0, 0.25); } .revolut-bank__activity-item { @@ -444,19 +697,19 @@ width: 2.5rem; height: 2.5rem; border-radius: 50%; - background: var(--rb-bg); + background: var(--rb-surface-elevated); border: 1px solid var(--rb-border); display: flex; align-items: center; justify-content: center; flex-shrink: 0; - color: var(--rb-text); + color: #ffffff; } .revolut-bank__activity-icon--swap { - background: #eef6ff; - border-color: #cce0ff; - color: #2563eb; + background: rgba(73, 79, 223, 0.2); + border-color: rgba(73, 79, 223, 0.35); + color: #a5a9ff; } .revolut-bank__activity-body { @@ -466,7 +719,7 @@ .revolut-bank__activity-title { font-size: 0.9375rem; - font-weight: 600; + font-weight: 500; color: var(--rb-text); white-space: nowrap; overflow: hidden; @@ -481,52 +734,69 @@ .revolut-bank__activity-amount { font-size: 0.9375rem; - font-weight: 700; + font-weight: 600; flex-shrink: 0; color: var(--rb-text); } .revolut-bank__activity-amount--positive { - color: #0d7a4f; + color: var(--rb-positive); } -/* Services grid */ +/* Hub / services grid */ .revolut-bank__services { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.65rem; - margin-bottom: 2rem; + margin-bottom: 1.75rem; } @media (min-width: 640px) { .revolut-bank__services { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); } } .revolut-bank__service-card { display: flex; flex-direction: column; - gap: 0.25rem; - padding: 1rem 1.125rem; - border-radius: var(--rb-radius-sm); + gap: 0.35rem; + padding: 1rem; + border-radius: var(--rb-radius); background: var(--rb-surface); border: 1px solid var(--rb-border); - box-shadow: var(--rb-shadow); text-decoration: none; color: var(--rb-text); - transition: border-color 0.15s, box-shadow 0.15s; - min-height: 5rem; + transition: background 0.15s, border-color 0.15s; + min-height: 5.5rem; } .revolut-bank__service-card:hover { + background: var(--rb-surface-elevated); border-color: var(--rb-border-strong); - box-shadow: var(--rb-shadow-md); } +.revolut-bank__service-icon { + width: 2rem; + height: 2rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.15rem; +} + +.revolut-bank__service-icon--teal { background: rgba(38, 194, 129, 0.2); color: #26c281; } +.revolut-bank__service-icon--violet { background: rgba(73, 79, 223, 0.25); color: #a5a9ff; } +.revolut-bank__service-icon--pink { background: rgba(236, 72, 153, 0.2); color: #f472b6; } +.revolut-bank__service-icon--blue { background: rgba(59, 130, 246, 0.2); color: #60a5fa; } +.revolut-bank__service-icon--orange { background: rgba(251, 146, 60, 0.2); color: #fb923c; } +.revolut-bank__service-icon--lime { background: rgba(163, 230, 53, 0.15); color: #a3e635; } +.revolut-bank__service-icon--grey { background: var(--rb-surface-elevated); color: var(--rb-muted); } + .revolut-bank__service-label { font-size: 0.875rem; - font-weight: 700; + font-weight: 600; color: var(--rb-text); } @@ -545,16 +815,15 @@ list-style: none; cursor: pointer; padding: 1rem 1.125rem; - border-radius: var(--rb-radius-sm); + border-radius: var(--rb-radius); background: var(--rb-surface); border: 1px solid var(--rb-border); font-size: 0.9375rem; - font-weight: 600; + font-weight: 500; color: var(--rb-text); display: flex; align-items: center; justify-content: space-between; - box-shadow: var(--rb-shadow); } .revolut-bank__treasury summary::-webkit-details-marker { @@ -567,12 +836,12 @@ .revolut-bank__treasury-body { margin-top: 0.75rem; - border-radius: var(--rb-radius-sm); + border-radius: var(--rb-radius); overflow: hidden; } .revolut-bank__skeleton { - background: linear-gradient(90deg, #ececec 25%, #e0e0e0 50%, #ececec 75%); + background: linear-gradient(90deg, #16181a 25%, #232628 50%, #16181a 75%); background-size: 200% 100%; animation: rb-shimmer 1.2s infinite; border-radius: 8px; @@ -583,14 +852,13 @@ 100% { background-position: -200% 0; } } -/* Settlement panel */ +/* Settlement / protocol panels */ .revolut-bank__protocols { - margin-bottom: 2rem; + margin-bottom: 1.75rem; padding: 1.25rem; - border-radius: var(--rb-radius-sm); + border-radius: var(--rb-radius); background: var(--rb-surface); border: 1px solid var(--rb-border); - box-shadow: var(--rb-shadow); } .revolut-bank__protocols-hint { @@ -618,17 +886,18 @@ padding: 0.5rem 0.875rem; border-radius: var(--rb-radius-pill); border: 1px solid var(--rb-border); - background: var(--rb-surface); - color: var(--rb-subject); + background: var(--rb-surface-elevated); + color: var(--rb-muted); font-size: 0.75rem; - font-weight: 600; + font-weight: 500; cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; } .revolut-bank__protocol-tab--active { - background: var(--rb-accent); - border-color: var(--rb-accent); - color: var(--rb-accent-text); + background: #ffffff; + border-color: #ffffff; + color: #000000; } .revolut-bank__protocol-form { @@ -642,49 +911,53 @@ flex-direction: column; gap: 0.35rem; font-size: 0.8125rem; - color: var(--rb-text); - font-weight: 600; + color: var(--rb-muted); + font-weight: 500; } .revolut-bank__input { width: 100%; - padding: 0.75rem 0.875rem; - border-radius: 10px; + padding: 0.875rem 1rem; + border-radius: var(--rb-radius-sm); border: 1px solid var(--rb-border-strong); - background: #fff; - color: var(--rb-text); + background: var(--rb-surface-elevated); + color: #ffffff; font-size: 1rem; font-weight: 400; } +.revolut-bank__input::placeholder { + color: var(--rb-muted); +} + .revolut-bank__input:focus { outline: none; - border-color: var(--rb-accent-deep); - box-shadow: 0 0 0 3px rgba(159, 232, 112, 0.35); + border-color: var(--rb-accent); + box-shadow: 0 0 0 3px var(--rb-accent-soft); } .revolut-bank__protocol-error { padding: 0.875rem; - border-radius: 10px; - background: #fef2f2; - border: 1px solid #fecaca; - color: #b91c1c; + border-radius: var(--rb-radius-sm); + background: var(--rb-error-bg); + border: 1px solid rgba(255, 107, 107, 0.25); + color: var(--rb-error-text); font-size: 0.8125rem; margin-bottom: 0.875rem; } .revolut-bank__protocol-result { padding: 0.875rem; - border-radius: 10px; - background: var(--rb-accent-soft); - border: 1px solid rgba(159, 232, 112, 0.45); + border-radius: var(--rb-radius-sm); + background: rgba(38, 194, 129, 0.12); + border: 1px solid rgba(38, 194, 129, 0.25); font-size: 0.8125rem; - color: var(--rb-subject); + color: var(--rb-text); margin-bottom: 0.875rem; } .revolut-bank__protocol-result strong { - color: var(--rb-accent-deep); + color: var(--rb-positive); } .revolut-bank__submit { @@ -692,9 +965,9 @@ padding: 0.9375rem 1rem; border-radius: var(--rb-radius-pill); border: none; - background: var(--rb-accent); - color: var(--rb-accent-text); - font-weight: 700; + background: #ffffff; + color: #000000; + font-weight: 600; font-size: 1rem; cursor: pointer; min-height: 48px; @@ -702,11 +975,11 @@ } .revolut-bank__submit:hover:not(:disabled) { - background: var(--rb-accent-hover); + background: #e8e8e8; } .revolut-bank__submit:disabled { - opacity: 0.5; + opacity: 0.45; cursor: not-allowed; } @@ -735,20 +1008,18 @@ .revolut-bank__panel { background: var(--rb-surface); border: 1px solid var(--rb-border); - border-radius: var(--rb-radius-sm); - box-shadow: var(--rb-shadow); + border-radius: var(--rb-radius); padding: 1.25rem; - margin-bottom: 2rem; + margin-bottom: 1.75rem; } -/* M0→M4 full settlement pipeline */ +/* M0→M4 settlement pipeline */ .zbank-settle { - margin-bottom: 2rem; + margin-bottom: 1.75rem; padding: 1.25rem; - border-radius: var(--rb-radius-sm); + border-radius: var(--rb-radius); background: var(--rb-surface); border: 1px solid var(--rb-border); - box-shadow: var(--rb-shadow); } .zbank-settle__pipeline { @@ -809,30 +1080,30 @@ padding: 0.85rem 0.75rem; border-radius: var(--rb-radius-sm); border: 1px solid var(--rb-border); - background: var(--rb-bg); + background: var(--rb-surface-elevated); min-height: 6.5rem; } .zbank-settle__layer--active { border-color: var(--rb-accent); - box-shadow: 0 0 0 2px rgba(159, 232, 112, 0.35); + box-shadow: 0 0 0 1px var(--rb-accent); } .zbank-settle__layer--done { - border-color: rgba(159, 232, 112, 0.6); + border-color: rgba(73, 79, 223, 0.45); background: var(--rb-accent-soft); } .zbank-settle__layer-id { font-size: 0.6875rem; - font-weight: 800; + font-weight: 700; letter-spacing: 0.06em; - color: var(--rb-accent-deep); + color: var(--rb-accent); } .zbank-settle__layer-name { font-size: 0.8125rem; - font-weight: 700; + font-weight: 600; color: var(--rb-text); } @@ -844,7 +1115,7 @@ .zbank-settle__layer-bal { font-size: 0.75rem; - font-weight: 700; + font-weight: 600; color: var(--rb-text); margin-top: 0.15rem; } @@ -861,25 +1132,26 @@ align-items: center; gap: 0.5rem !important; font-weight: 500 !important; + color: var(--rb-text) !important; } .zbank-settle__checkbox input { width: 1rem; height: 1rem; - accent-color: var(--rb-accent-deep); + accent-color: var(--rb-accent); } .zbank-settle__steps { margin-bottom: 1rem; padding: 0.875rem; - border-radius: 10px; - background: var(--rb-bg); + border-radius: var(--rb-radius-sm); + background: var(--rb-surface-elevated); border: 1px solid var(--rb-border); } .zbank-settle__steps-title { font-size: 0.8125rem; - font-weight: 700; + font-weight: 600; color: var(--rb-text); margin: 0 0 0.5rem; } @@ -905,12 +1177,12 @@ } .zbank-settle__step-status--posted { - color: #0d7a4f; + color: var(--rb-positive); font-weight: 600; } .zbank-settle__step-status--failed { - color: #b91c1c; + color: var(--rb-negative); font-weight: 600; } @@ -935,7 +1207,7 @@ padding: 0.75rem 0.875rem; border-radius: var(--rb-radius-sm); border: 1px solid var(--rb-border); - background: var(--rb-surface); + background: var(--rb-surface-elevated); cursor: pointer; text-align: left; } @@ -947,7 +1219,7 @@ .zbank-settle__protocol-group-label { font-size: 0.8125rem; - font-weight: 700; + font-weight: 600; color: var(--rb-text); } @@ -957,10 +1229,599 @@ line-height: 1.35; } -/* Legacy header elements — hidden when using Wise layout */ +/* POS + prepaid cards */ +.zbank-pos { + margin-top: 0; + margin-bottom: 1.75rem; + padding: 1.25rem; + border-radius: var(--rb-radius); + background: var(--rb-surface); + border: 1px solid var(--rb-border); +} + +.zbank-pos__tabs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.zbank-pos__tab { + padding: 0.5rem 0.875rem; + border-radius: var(--rb-radius-pill); + border: 1px solid var(--rb-border); + background: var(--rb-surface-elevated); + color: var(--rb-muted); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; +} + +.zbank-pos__tab--active { + border-color: #ffffff; + background: #ffffff; + color: #000000; +} + +.zbank-pos__panel { + margin-bottom: 1rem; +} + +.zbank-pos__hint { + font-size: 0.8125rem; + color: var(--rb-muted); + margin-bottom: 0.75rem; +} + +.zbank-pos__pof { + margin-top: 1rem; + padding: 0.875rem; + border-radius: var(--rb-radius-sm); + border: 1px solid var(--rb-border); + background: var(--rb-surface-elevated); + font-size: 0.8125rem; + color: var(--rb-text); +} + +.zbank-pos__meta, +.zbank-pos__sha { + font-size: 0.75rem; + color: var(--rb-muted); + margin-top: 0.5rem; +} + +.zbank-pos__tap { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.zbank-pos__cards { + margin-top: 1.25rem; +} + +.zbank-pos__cards-title { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--rb-text); +} + +.zbank-pos__cards ul { + list-style: none; + margin: 0; + padding: 0; +} + +.zbank-pos__card-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--rb-border); +} + +.zbank-pos__card-chip { + width: 2.25rem; + height: 1.5rem; + border-radius: 4px; + background: linear-gradient(135deg, #494fdf, #7c3aed); + color: #fff; + font-size: 0.625rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; +} + +.zbank-pos__card-label { + font-size: 0.8125rem; + font-weight: 600; + margin: 0; + color: var(--rb-text); +} + +.zbank-pos__card-meta { + font-size: 0.6875rem; + color: var(--rb-muted); + margin: 0.15rem 0 0; +} + +.zbank-pos__card-balance { + margin-left: auto; + font-weight: 600; + font-size: 0.875rem; + color: var(--rb-text); +} + +/* Gazette */ +.revolut-bank__gazette { + margin-bottom: 1.75rem; +} + +.revolut-bank__gazette .revolut-bank__gazette-hint { + font-size: 0.75rem; + color: var(--rb-muted); + margin-bottom: 0.75rem; + line-height: 1.5; +} + +/* Legacy hidden */ .revolut-bank__header-bar, .revolut-bank__brand, .revolut-bank__topbar, -.revolut-bank__avatar { +.revolut-bank__logo-mark, +.revolut-bank__brand-block, +.revolut-bank__greeting-label { display: none; } + +/* Flat transaction list on black — Revolut home */ +.revolut-bank__activity--flat { + background: transparent; + border: none; + border-radius: 0; +} + +.revolut-bank__activity--flat .revolut-bank__activity-item { + padding: 0.875rem 0; + border-bottom: 1px solid var(--rb-border); +} + +/* Widgets horizontal scroll */ +.revolut-bank__widgets { + margin-bottom: 1.5rem; +} + +.revolut-bank__widgets-scroll { + display: flex; + gap: 0.65rem; + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 0.25rem; +} + +.revolut-bank__widgets-scroll::-webkit-scrollbar { + display: none; +} + +.revolut-bank__widget { + flex: 0 0 auto; + min-width: 8.5rem; + padding: 1rem; + border-radius: var(--rb-radius); + background: var(--rb-surface); + border: 1px solid var(--rb-border); + display: flex; + flex-direction: column; + gap: 0.35rem; + text-decoration: none; + color: var(--rb-text); + cursor: pointer; + text-align: left; +} + +.revolut-bank__widget-label { + font-size: 0.875rem; + font-weight: 600; +} + +.revolut-bank__widget-desc { + font-size: 0.6875rem; + color: var(--rb-muted); +} + +.revolut-bank__widget--teal { border-color: rgba(0, 168, 126, 0.25); } +.revolut-bank__widget--violet { border-color: rgba(73, 79, 223, 0.35); } +.revolut-bank__widget--orange { border-color: rgba(236, 126, 0, 0.25); } +.revolut-bank__widget--grey { border-color: var(--rb-border); } + +/* Tab screens */ +.revolut-bank__tab-screen { + padding-top: env(safe-area-inset-top, 0); +} + +.revolut-bank__tab-header { + margin-bottom: 1.25rem; +} + +.revolut-bank__tab-header h1 { + font-size: 1.75rem; + font-weight: 600; + letter-spacing: -0.03em; + margin: 0; + color: var(--rb-text); +} + +.revolut-bank__tab-sub { + font-size: 0.875rem; + color: var(--rb-muted); + margin: 0.35rem 0 0; +} + +.revolut-bank__services--hub { + grid-template-columns: repeat(2, 1fr); +} + +/* Profile — More tab */ +.revolut-bank__profile-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.25rem; + border-radius: var(--rb-radius); + background: var(--rb-surface); + border: 1px solid var(--rb-border); + margin-bottom: 1rem; +} + +.revolut-bank__avatar--lg { + width: 3.5rem; + height: 3.5rem; + font-size: 1rem; +} + +.revolut-bank__profile-name { + font-size: 1.0625rem; + font-weight: 600; + margin: 0; + color: var(--rb-text); +} + +.revolut-bank__profile-meta { + font-size: 0.8125rem; + color: var(--rb-muted); + margin: 0.2rem 0 0; +} + +.revolut-bank__wallet-row { + margin-bottom: 1.25rem; +} + +/* Revolut bottom tab bar */ +.revolut-tab-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 80; + display: flex; + justify-content: space-around; + align-items: stretch; + min-height: 3.75rem; + padding-bottom: env(safe-area-inset-bottom, 0); + background: #000000; + border-top: 1px solid var(--rb-border); +} + +.revolut-tab-bar__item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.2rem; + padding: 0.45rem 0.25rem; + border: none; + background: transparent; + color: var(--rb-muted); + font-size: 0.625rem; + font-weight: 500; + letter-spacing: 0.02em; + cursor: pointer; + touch-action: manipulation; +} + +.revolut-tab-bar__item--active { + color: #ffffff; +} + +.revolut-tab-bar__item:active { + opacity: 0.7; +} + +@media (min-width: 768px) { + .revolut-tab-bar { + max-width: 430px; + left: 50%; + transform: translateX(-50%); + border-radius: var(--rb-radius-lg) var(--rb-radius-lg) 0 0; + border-left: 1px solid var(--rb-border); + border-right: 1px solid var(--rb-border); + } +} + +/* Account details bottom sheet */ +.revolut-bank__sheet-backdrop { + position: fixed; + inset: 0; + z-index: 90; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: flex-end; + justify-content: center; +} + +.revolut-bank__sheet { + width: 100%; + max-width: 430px; + max-height: 85vh; + overflow-y: auto; + background: var(--rb-surface); + border-radius: var(--rb-radius-lg) var(--rb-radius-lg) 0 0; + padding: 1rem 1.25rem calc(1.5rem + env(safe-area-inset-bottom, 0)); + border-top: 1px solid var(--rb-border); +} + +.revolut-bank__sheet-handle { + width: 2.5rem; + height: 0.25rem; + border-radius: var(--rb-radius-pill); + background: var(--rb-border-strong); + margin: 0 auto 1rem; +} + +.revolut-bank__sheet-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 0.35rem; + letter-spacing: -0.02em; +} + +.revolut-bank__sheet-hint { + font-size: 0.8125rem; + color: var(--rb-muted); + margin: 0 0 1rem; + line-height: 1.45; +} + +.revolut-bank__detail-list { + margin: 0 0 1.25rem; +} + +.revolut-bank__detail-list--inline { + background: var(--rb-surface-elevated); + border-radius: var(--rb-radius); + border: 1px solid var(--rb-border); + padding: 0.5rem 1rem; +} + +.revolut-bank__detail-row { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.65rem 0; + border-bottom: 1px solid var(--rb-border); +} + +.revolut-bank__detail-row:last-child { + border-bottom: none; +} + +.revolut-bank__detail-row dt { + font-size: 0.6875rem; + font-weight: 500; + color: var(--rb-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.revolut-bank__detail-row dd { + margin: 0; + font-size: 0.9375rem; + font-weight: 500; + color: var(--rb-text); + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + word-break: break-all; +} + +.revolut-bank__copy-btn { + flex-shrink: 0; + border: none; + background: var(--rb-accent-soft); + color: var(--rb-accent); + font-size: 0.75rem; + font-weight: 600; + padding: 0.35rem 0.65rem; + border-radius: var(--rb-radius-pill); + cursor: pointer; +} + +.revolut-bank__virtual-card-badge { + position: absolute; + top: 1rem; + right: 1rem; + z-index: 2; + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 0.25rem 0.5rem; + border-radius: var(--rb-radius-pill); + background: rgba(0, 0, 0, 0.35); +} + +.revolut-bank__activity-amount--failed { + color: var(--rb-negative); +} + +.revolut-bank__limits { + margin-bottom: 1.25rem; +} + +.revolut-bank__limits-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + border-radius: var(--rb-radius); + background: var(--rb-surface); + border: 1px solid var(--rb-border); +} + +.revolut-bank__limit-row { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.revolut-bank__limit-head { + display: flex; + justify-content: space-between; + font-size: 0.8125rem; + color: var(--rb-muted); +} + +.revolut-bank__limit-bar { + height: 0.25rem; + border-radius: var(--rb-radius-pill); + background: var(--rb-surface-elevated); + overflow: hidden; +} + +.revolut-bank__limit-bar span { + display: block; + height: 100%; + background: var(--rb-accent); + border-radius: inherit; +} + +.revolut-bank__profile-details { + margin-bottom: 1.25rem; +} + +/* ── Secure portal login (Revolut-style) ── */ +.revolut-login { + min-height: 100dvh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem 1.5rem; + background: #000; + color: #fff; + font-family: Inter, system-ui, sans-serif; +} + +.revolut-login--loading { + justify-content: center; + color: #8d969e; +} + +.revolut-login__brand { + text-align: center; + margin-bottom: 2.5rem; +} + +.revolut-login__logo { + width: 4rem; + height: 4rem; + margin: 0 auto 1rem; + border-radius: 1rem; + background: linear-gradient(135deg, #494fdf, #6b71ff); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + font-weight: 700; +} + +.revolut-login__brand h1 { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.5rem; +} + +.revolut-login__brand p { + margin: 0; + color: #8d969e; + font-size: 0.9375rem; +} + +.revolut-login__form { + width: 100%; + max-width: 22rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.revolut-login__field { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.revolut-login__field span { + font-size: 0.8125rem; + color: #8d969e; +} + +.revolut-login__field input { + background: #16181a; + border: 1px solid #232628; + border-radius: 0.75rem; + padding: 0.875rem 1rem; + color: #fff; + font-size: 1rem; +} + +.revolut-login__field input:focus { + outline: none; + border-color: #494fdf; +} + +.revolut-login__error { + color: #ff6b6b; + font-size: 0.875rem; + margin: 0; +} + +.revolut-login__submit { + margin-top: 0.5rem; + background: #494fdf; + color: #fff; + border: none; + border-radius: 999px; + padding: 0.9375rem 1.25rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; +} + +.revolut-login__submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.revolut-login__footer { + margin-top: 2rem; + font-size: 0.75rem; + color: #8d969e; + text-align: center; + max-width: 18rem; +} diff --git a/packages/settlement-core/dist/bridge-validation.d.ts b/packages/settlement-core/dist/bridge-validation.d.ts new file mode 100644 index 0000000..da77e39 --- /dev/null +++ b/packages/settlement-core/dist/bridge-validation.d.ts @@ -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; +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; +}; diff --git a/packages/settlement-core/dist/bridge-validation.js b/packages/settlement-core/dist/bridge-validation.js new file mode 100644 index 0000000..ce066c8 --- /dev/null +++ b/packages/settlement-core/dist/bridge-validation.js @@ -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 }; +} diff --git a/packages/settlement-core/dist/chain-registry.d.ts b/packages/settlement-core/dist/chain-registry.d.ts index c9a4239..1c95f2c 100644 --- a/packages/settlement-core/dist/chain-registry.d.ts +++ b/packages/settlement-core/dist/chain-registry.d.ts @@ -13,6 +13,7 @@ export type OmnlChainEntry = { m2LoadEnabled: boolean; swapEnabled: boolean; bridgeEnabled: boolean; + bridgeDestinations?: number[]; officeId: number; }; complianceEnv?: string; diff --git a/packages/settlement-core/dist/index.d.ts b/packages/settlement-core/dist/index.d.ts index e31b7fa..baf0cd5 100644 --- a/packages/settlement-core/dist/index.d.ts +++ b/packages/settlement-core/dist/index.d.ts @@ -5,3 +5,4 @@ export * from './verbiage'; export * from './state-machine'; export * from './m2-token-registry'; export * from './chain-registry'; +export * from './bridge-validation'; diff --git a/packages/settlement-core/dist/index.js b/packages/settlement-core/dist/index.js index 519b4d9..fa1de14 100644 --- a/packages/settlement-core/dist/index.js +++ b/packages/settlement-core/dist/index.js @@ -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); diff --git a/packages/settlement-core/dist/state-machine.js b/packages/settlement-core/dist/state-machine.js index 37a08e0..b387a95 100644 --- a/packages/settlement-core/dist/state-machine.js +++ b/packages/settlement-core/dist/state-machine.js @@ -10,6 +10,8 @@ const ORDER = [ 'HYBX_RAIL_DISPATCHED', 'ISO20022_ARCHIVED', 'CHAIN_MINT_REQUESTED', + 'CHAIN_BRIDGE_REQUESTED', + 'CHAIN_BRIDGE_PENDING', 'SETTLED', ]; function nextPhase(current) { diff --git a/packages/settlement-core/dist/types.d.ts b/packages/settlement-core/dist/types.d.ts index 5fb70f6..eaf61a2 100644 --- a/packages/settlement-core/dist/types.d.ts +++ b/packages/settlement-core/dist/types.d.ts @@ -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; +}; diff --git a/packages/settlement-core/dist/verbiage.js b/packages/settlement-core/dist/verbiage.js index d29d60a..50249c3 100644 --- a/packages/settlement-core/dist/verbiage.js +++ b/packages/settlement-core/dist/verbiage.js @@ -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'); diff --git a/packages/settlement-core/pnpm-lock.yaml b/packages/settlement-core/pnpm-lock.yaml new file mode 100644 index 0000000..d0c445f --- /dev/null +++ b/packages/settlement-core/pnpm-lock.yaml @@ -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: {} diff --git a/packages/settlement-core/src/bridge-validation.ts b/packages/settlement-core/src/bridge-validation.ts new file mode 100644 index 0000000..1236ffc --- /dev/null +++ b/packages/settlement-core/src/bridge-validation.ts @@ -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 }; +} diff --git a/packages/settlement-core/src/chain-registry.ts b/packages/settlement-core/src/chain-registry.ts index c8f558b..f2afa0d 100644 --- a/packages/settlement-core/src/chain-registry.ts +++ b/packages/settlement-core/src/chain-registry.ts @@ -16,6 +16,7 @@ export type OmnlChainEntry = { m2LoadEnabled: boolean; swapEnabled: boolean; bridgeEnabled: boolean; + bridgeDestinations?: number[]; officeId: number; }; complianceEnv?: string; diff --git a/packages/settlement-core/src/index.ts b/packages/settlement-core/src/index.ts index e31b7fa..baf0cd5 100644 --- a/packages/settlement-core/src/index.ts +++ b/packages/settlement-core/src/index.ts @@ -5,3 +5,4 @@ export * from './verbiage'; export * from './state-machine'; export * from './m2-token-registry'; export * from './chain-registry'; +export * from './bridge-validation'; diff --git a/packages/settlement-core/src/state-machine.ts b/packages/settlement-core/src/state-machine.ts index 9908e95..5fa67cb 100644 --- a/packages/settlement-core/src/state-machine.ts +++ b/packages/settlement-core/src/state-machine.ts @@ -8,6 +8,8 @@ const ORDER: SettlementPhase[] = [ 'HYBX_RAIL_DISPATCHED', 'ISO20022_ARCHIVED', 'CHAIN_MINT_REQUESTED', + 'CHAIN_BRIDGE_REQUESTED', + 'CHAIN_BRIDGE_PENDING', 'SETTLED', ]; diff --git a/packages/settlement-core/src/types.ts b/packages/settlement-core/src/types.ts index a1a131e..a142b32 100644 --- a/packages/settlement-core/src/types.ts +++ b/packages/settlement-core/src/types.ts @@ -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; +}; diff --git a/packages/settlement-core/src/verbiage.ts b/packages/settlement-core/src/verbiage.ts index 9fe22eb..b3e4149 100644 --- a/packages/settlement-core/src/verbiage.ts +++ b/packages/settlement-core/src/verbiage.ts @@ -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( '', diff --git a/scripts/deployment/build-portal-lxc-env-fragment.sh b/scripts/deployment/build-portal-lxc-env-fragment.sh index 634596d..fc956f0 100755 --- a/scripts/deployment/build-portal-lxc-env-fragment.sh +++ b/scripts/deployment/build-portal-lxc-env-fragment.sh @@ -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:-}" diff --git a/services/settlement-middleware/dist/adapters/bridge.js b/services/settlement-middleware/dist/adapters/bridge.js new file mode 100644 index 0000000..240643e --- /dev/null +++ b/services/settlement-middleware/dist/adapters/bridge.js @@ -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; + } +} diff --git a/services/settlement-middleware/dist/adapters/swift.js b/services/settlement-middleware/dist/adapters/swift.js index ef2f9ff..70b0fe5 100644 --- a/services/settlement-middleware/dist/adapters/swift.js +++ b/services/settlement-middleware/dist/adapters/swift.js @@ -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} +-}`; +} diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js index 80d84bd..fa28962 100644 --- a/services/settlement-middleware/dist/api/routes/settlement.js +++ b/services/settlement-middleware/dist/api/routes/settlement.js @@ -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; } diff --git a/services/settlement-middleware/dist/config.js b/services/settlement-middleware/dist/config.js index a85c0e7..d8d7c12 100644 --- a/services/settlement-middleware/dist/config.js +++ b/services/settlement-middleware/dist/config.js @@ -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() { diff --git a/services/settlement-middleware/dist/index.js b/services/settlement-middleware/dist/index.js index 4954e3e..6155608 100644 --- a/services/settlement-middleware/dist/index.js +++ b/services/settlement-middleware/dist/index.js @@ -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)(); diff --git a/services/settlement-middleware/dist/services/zbank-consumer-banking.js b/services/settlement-middleware/dist/services/zbank-consumer-banking.js new file mode 100644 index 0000000..b72ebda --- /dev/null +++ b/services/settlement-middleware/dist/services/zbank-consumer-banking.js @@ -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, + }; +} diff --git a/services/settlement-middleware/dist/services/zbank-consumer-config.js b/services/settlement-middleware/dist/services/zbank-consumer-config.js new file mode 100644 index 0000000..be9adb2 --- /dev/null +++ b/services/settlement-middleware/dist/services/zbank-consumer-config.js @@ -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; +} diff --git a/services/settlement-middleware/dist/services/zbank-portal-auth.js b/services/settlement-middleware/dist/services/zbank-portal-auth.js new file mode 100644 index 0000000..ee964c4 --- /dev/null +++ b/services/settlement-middleware/dist/services/zbank-portal-auth.js @@ -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'); +} diff --git a/services/settlement-middleware/dist/services/zbank-pos-config.js b/services/settlement-middleware/dist/services/zbank-pos-config.js new file mode 100644 index 0000000..be6f50e --- /dev/null +++ b/services/settlement-middleware/dist/services/zbank-pos-config.js @@ -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); +} diff --git a/services/settlement-middleware/dist/store/zbank-card-store.js b/services/settlement-middleware/dist/store/zbank-card-store.js new file mode 100644 index 0000000..ae81a2a --- /dev/null +++ b/services/settlement-middleware/dist/store/zbank-card-store.js @@ -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()}`; +} diff --git a/services/settlement-middleware/dist/workflows/external-transfer.js b/services/settlement-middleware/dist/workflows/external-transfer.js index 77cfc41..0bd9731 100644 --- a/services/settlement-middleware/dist/workflows/external-transfer.js +++ b/services/settlement-middleware/dist/workflows/external-transfer.js @@ -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'); diff --git a/services/settlement-middleware/dist/workflows/settlement-bridge.js b/services/settlement-middleware/dist/workflows/settlement-bridge.js new file mode 100644 index 0000000..2b089f9 --- /dev/null +++ b/services/settlement-middleware/dist/workflows/settlement-bridge.js @@ -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; +} diff --git a/services/settlement-middleware/dist/workflows/token-load.js b/services/settlement-middleware/dist/workflows/token-load.js index d48ca15..45640e6 100644 --- a/services/settlement-middleware/dist/workflows/token-load.js +++ b/services/settlement-middleware/dist/workflows/token-load.js @@ -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) { diff --git a/services/settlement-middleware/dist/workflows/z-bank-m0-m4.js b/services/settlement-middleware/dist/workflows/z-bank-m0-m4.js new file mode 100644 index 0000000..5fa1ea5 --- /dev/null +++ b/services/settlement-middleware/dist/workflows/z-bank-m0-m4.js @@ -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; + } +} diff --git a/services/settlement-middleware/dist/workflows/zbank-card-load.js b/services/settlement-middleware/dist/workflows/zbank-card-load.js new file mode 100644 index 0000000..8a58c2f --- /dev/null +++ b/services/settlement-middleware/dist/workflows/zbank-card-load.js @@ -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 }; + } +} diff --git a/services/settlement-middleware/dist/workflows/zbank-pos-sale.js b/services/settlement-middleware/dist/workflows/zbank-pos-sale.js new file mode 100644 index 0000000..1132f85 --- /dev/null +++ b/services/settlement-middleware/dist/workflows/zbank-pos-sale.js @@ -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; +} diff --git a/services/settlement-middleware/dist/workflows/zbank-proof-of-funds.js b/services/settlement-middleware/dist/workflows/zbank-proof-of-funds.js new file mode 100644 index 0000000..e08f2ac --- /dev/null +++ b/services/settlement-middleware/dist/workflows/zbank-proof-of-funds.js @@ -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 } }; +} diff --git a/services/settlement-middleware/src/adapters/bridge.ts b/services/settlement-middleware/src/adapters/bridge.ts new file mode 100644 index 0000000..2635816 --- /dev/null +++ b/services/settlement-middleware/src/adapters/bridge.ts @@ -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 = { '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; + } +} diff --git a/services/settlement-middleware/src/adapters/omnl.ts b/services/settlement-middleware/src/adapters/omnl.ts index 8020be8..7e3aedf 100644 --- a/services/settlement-middleware/src/adapters/omnl.ts +++ b/services/settlement-middleware/src/adapters/omnl.ts @@ -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 || ''; diff --git a/services/settlement-middleware/src/api/routes/settlement.ts b/services/settlement-middleware/src/api/routes/settlement.ts index dec5597..814416b 100644 --- a/services/settlement-middleware/src/api/routes/settlement.ts +++ b/services/settlement-middleware/src/api/routes/settlement.ts @@ -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 { 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; } diff --git a/services/settlement-middleware/src/config.ts b/services/settlement-middleware/src/config.ts index 155da7f..22277e8 100644 --- a/services/settlement-middleware/src/config.ts +++ b/services/settlement-middleware/src/config.ts @@ -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; } diff --git a/services/settlement-middleware/src/index.ts b/services/settlement-middleware/src/index.ts index 919126c..23c2ab4 100644 --- a/services/settlement-middleware/src/index.ts +++ b/services/settlement-middleware/src/index.ts @@ -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(); \ No newline at end of file diff --git a/services/settlement-middleware/src/services/zbank-consumer-banking.ts b/services/settlement-middleware/src/services/zbank-consumer-banking.ts new file mode 100644 index 0000000..89504c8 --- /dev/null +++ b/services/settlement-middleware/src/services/zbank-consumer-banking.ts @@ -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 = { + 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): 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, 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 { + 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, + }; +} diff --git a/services/settlement-middleware/src/services/zbank-consumer-config.ts b/services/settlement-middleware/src/services/zbank-consumer-config.ts new file mode 100644 index 0000000..31e9b30 --- /dev/null +++ b/services/settlement-middleware/src/services/zbank-consumer-config.ts @@ -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; + 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; +} diff --git a/services/settlement-middleware/src/services/zbank-portal-auth.ts b/services/settlement-middleware/src/services/zbank-portal-auth.ts new file mode 100644 index 0000000..71b7c43 --- /dev/null +++ b/services/settlement-middleware/src/services/zbank-portal-auth.ts @@ -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(); +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'); +} diff --git a/services/settlement-middleware/src/services/zbank-pos-config.ts b/services/settlement-middleware/src/services/zbank-pos-config.ts new file mode 100644 index 0000000..058408c --- /dev/null +++ b/services/settlement-middleware/src/services/zbank-pos-config.ts @@ -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); +} diff --git a/services/settlement-middleware/src/store/zbank-card-store.ts b/services/settlement-middleware/src/store/zbank-card-store.ts new file mode 100644 index 0000000..f1771ab --- /dev/null +++ b/services/settlement-middleware/src/store/zbank-card-store.ts @@ -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()}`; +} diff --git a/services/settlement-middleware/src/workflows/external-transfer.ts b/services/settlement-middleware/src/workflows/external-transfer.ts index e050d36..269cfed 100644 --- a/services/settlement-middleware/src/workflows/external-transfer.ts +++ b/services/settlement-middleware/src/workflows/external-transfer.ts @@ -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'); } diff --git a/services/settlement-middleware/src/workflows/settlement-bridge.ts b/services/settlement-middleware/src/workflows/settlement-bridge.ts new file mode 100644 index 0000000..3676393 --- /dev/null +++ b/services/settlement-middleware/src/workflows/settlement-bridge.ts @@ -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) => void, +): Promise { + 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; +} diff --git a/services/settlement-middleware/src/workflows/token-load.ts b/services/settlement-middleware/src/workflows/token-load.ts index 7722eb2..f7427ad 100644 --- a/services/settlement-middleware/src/workflows/token-load.ts +++ b/services/settlement-middleware/src/workflows/token-load.ts @@ -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; +}> { + 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 = {}) => { + 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 }; + } +} diff --git a/services/settlement-middleware/src/workflows/zbank-pos-sale.ts b/services/settlement-middleware/src/workflows/zbank-pos-sale.ts new file mode 100644 index 0000000..07da4db --- /dev/null +++ b/services/settlement-middleware/src/workflows/zbank-pos-sale.ts @@ -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> { + 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; +} diff --git a/services/settlement-middleware/src/workflows/zbank-proof-of-funds.ts b/services/settlement-middleware/src/workflows/zbank-proof-of-funds.ts new file mode 100644 index 0000000..e0a60a3 --- /dev/null +++ b/services/settlement-middleware/src/workflows/zbank-proof-of-funds.ts @@ -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> { + 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 } }; +} diff --git a/services/token-aggregation/src/api/routes/omnl.ts b/services/token-aggregation/src/api/routes/omnl.ts index 5358767..abf1769 100644 --- a/services/token-aggregation/src/api/routes/omnl.ts +++ b/services/token-aggregation/src/api/routes/omnl.ts @@ -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;