diff --git a/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx b/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx
index d60bdc5..2629546 100644
--- a/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx
+++ b/frontend-dapp/src/features/central-bank/CentralBankDashboard.tsx
@@ -1,8 +1,10 @@
+import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useCentralBank } from './useCentralBank';
import ChainMatrixPanel from './ChainMatrixPanel';
+import ZBankMainnetTransferPanel from './ZBankMainnetTransferPanel';
import FineractLedgerBanner from '../omnl-dashboard/FineractLedgerBanner';
@@ -23,6 +25,7 @@ import { formatUsd } from '../../utils/formatMoney';
export default function CentralBankDashboard() {
const cb = useCentralBank();
+ const [activeTab, setActiveTab] = useState<'overview' | 'payments'>('overview');
const office = cb.office as {
@@ -80,6 +83,26 @@ export default function CentralBankDashboard() {
+
+ {([
+ ['overview', 'Overview'],
+ ['payments', 'Payments'],
+ ] as const).map(([value, label]) => (
+
+ ))}
+
+
{cb.error && (
@@ -102,19 +125,17 @@ export default function CentralBankDashboard() {
-
+ ) : (
+ <>
+
- moneySupply={cb.moneySupply}
-
- loading={cb.loading}
-
- officeName={office?.name}
-
- />
-
-
-
-
+
@@ -146,11 +167,11 @@ export default function CentralBankDashboard() {
-
+
-
+
@@ -312,11 +333,12 @@ export default function CentralBankDashboard() {
-
+
+ >
+ )}
);
}
-
diff --git a/frontend-dapp/src/features/central-bank/ZBankMainnetTransferPanel.tsx b/frontend-dapp/src/features/central-bank/ZBankMainnetTransferPanel.tsx
new file mode 100644
index 0000000..f4be742
--- /dev/null
+++ b/frontend-dapp/src/features/central-bank/ZBankMainnetTransferPanel.tsx
@@ -0,0 +1,140 @@
+import NasaIcon from '../../components/icons/NasaIcon';
+
+import { useZBankSettlement } from './useZBankSettlement';
+
+export default function ZBankMainnetTransferPanel() {
+ const settlement = useZBankSettlement();
+
+ const actionByPreset = {
+ 'eth-mainnet': settlement.sendEthToMainnet,
+ 'usdt-mainnet': settlement.sendUsdtToMainnet,
+ } as const;
+
+ return (
+
+
+
+
+
+ Payments → Send to Ethereum mainnet
+
+
+ One-click settlement, mint, and bridge presets for Ethereum mainnet.
+
+
+
{settlement.status}
+
+
+
+
+
+
+
+
+
+ {settlement.presets.map((preset) => (
+
+
+
+
{preset.buttonLabel}
+
+ {preset.bridgeRail} · {preset.tokenLineId} · targetChainId: {preset.destinationChainId}
+
+
+
{preset.amountLabel}
+
+
+
+
+
+
+ ))}
+
+
+ {settlement.error && (
+
+
+ {settlement.error}
+
+ )}
+
+
+
Result
+ {settlement.result ? (
+
+
+
- Settlement phase
+ - {settlement.result.settlement?.phase ?? '—'}
+
+
+
- Settlement ID
+ - {settlement.result.settlement?.settlementId ?? '—'}
+
+
+
- Mint tx
+ - {settlement.result.settlement?.chainTxHash ?? '—'}
+
+
+
- Bridge tx
+ - {settlement.result.bridgeTxHash ?? '—'}
+
+
+
- Route
+ -
+ {settlement.result.executionPlan?.plannerResponse?.selectedRouteReason ??
+ settlement.result.executionPlan?.plannerResponse?.routePlan?.legs
+ ?.map((leg) => leg.provider)
+ .filter(Boolean)
+ .join(' → ') ??
+ '—'}
+
+
+
+ ) : (
+
No transfer submitted yet.
+ )}
+
+
+ );
+}
diff --git a/frontend-dapp/src/features/central-bank/useZBankSettlement.ts b/frontend-dapp/src/features/central-bank/useZBankSettlement.ts
new file mode 100644
index 0000000..dcfca10
--- /dev/null
+++ b/frontend-dapp/src/features/central-bank/useZBankSettlement.ts
@@ -0,0 +1,188 @@
+import { useEffect, useState } from 'react';
+import { useAccount, useSendTransaction } from 'wagmi';
+
+import { omnlApiHeaders, parseAmountToWei, exchangeApi, settlementApi } from '../../config/dex';
+
+import { ZBANK_MAINNET_PRESETS, type ZBankMainnetPreset } from './zbank-chain-assets';
+
+type SettlementRecord = {
+ settlementId?: string;
+ phase?: string;
+ chainTxHash?: string;
+ errors?: string[];
+};
+
+type ExecutionPlan = {
+ error?: string;
+ execution?: {
+ contractAddress: string;
+ encodedCalldata: string;
+ };
+ plannerResponse?: {
+ selectedRouteReason?: string;
+ routePlan?: {
+ legs?: Array<{ provider?: string }>;
+ };
+ };
+};
+
+type TransferResult = {
+ presetId: ZBankMainnetPreset['id'];
+ amount: string;
+ recipientWallet: string;
+ creditorIban: string;
+ settlement?: SettlementRecord;
+ executionPlan?: ExecutionPlan;
+ bridgeTxHash?: string;
+};
+
+export function useZBankSettlement() {
+ const { address, isConnected } = useAccount();
+ const { sendTransactionAsync } = useSendTransaction();
+
+ const [mainnetWallet, setMainnetWallet] = useState('');
+ const [creditorIban, setCreditorIban] = useState('');
+ const [amounts, setAmounts] = useState>({
+ 'eth-mainnet': '',
+ 'usdt-mainnet': '',
+ });
+ const [loadingPreset, setLoadingPreset] = useState(null);
+ const [status, setStatus] = useState('Idle');
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (address && !mainnetWallet) {
+ setMainnetWallet(address);
+ }
+ }, [address, mainnetWallet]);
+
+ async function postJson(url: string, body: Record): Promise {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: omnlApiHeaders(),
+ body: JSON.stringify(body),
+ });
+ const data = (await res.json()) as T & { error?: string };
+ if (!res.ok || data.error) {
+ throw new Error(data.error || `Request failed: ${res.status}`);
+ }
+ return data;
+ }
+
+ async function runPreset(preset: ZBankMainnetPreset) {
+ const amount = amounts[preset.id]?.trim();
+ if (!isConnected || !address) {
+ setError('Connect wallet before sending to mainnet.');
+ return;
+ }
+ if (!mainnetWallet.trim()) {
+ setError('Ethereum mainnet wallet is required.');
+ return;
+ }
+ if (!creditorIban.trim()) {
+ setError('Z Bank IBAN is required.');
+ return;
+ }
+ if (!amount) {
+ setError(`${preset.amountLabel} amount is required.`);
+ return;
+ }
+
+ setLoadingPreset(preset.id);
+ setStatus('Posting settlement');
+ setError(null);
+ setResult(null);
+
+ try {
+ const settlement = await postJson(settlementApi('/convert/fiat-to-crypto'), {
+ idempotencyKey: `ZB-${preset.id}-${Date.now()}`,
+ valueDate: new Date().toISOString().slice(0, 10),
+ currency: preset.currency,
+ amount,
+ creditorIban: creditorIban.trim(),
+ beneficiaryName: 'Z Bank Mainnet Transfer',
+ remittanceInfo: `${preset.buttonLabel} → ${mainnetWallet.trim()}`,
+ convertToCrypto: {
+ enabled: true,
+ targetChainId: preset.destinationChainId,
+ tokenLineId: preset.tokenLineId,
+ recipientAddress: address,
+ symbol: preset.sourceSymbol,
+ tokenAddress: preset.sourceTokenAddress,
+ },
+ });
+
+ setResult({
+ presetId: preset.id,
+ amount,
+ recipientWallet: mainnetWallet.trim(),
+ creditorIban: creditorIban.trim(),
+ settlement,
+ });
+
+ setStatus('Building bridge execution');
+ const executionPlan = await postJson(exchangeApi('/execute-plan'), {
+ tokenIn: preset.sourceTokenAddress,
+ tokenOut: preset.destinationTokenAddress,
+ amountIn: parseAmountToWei(amount, preset.sourceDecimals),
+ recipient: mainnetWallet.trim(),
+ destinationChainId: preset.destinationChainId,
+ allowBridge: true,
+ preferredBridges: [preset.preferredBridgeLabel],
+ });
+
+ if (!executionPlan.execution) {
+ throw new Error(executionPlan.error || 'Bridge execution plan missing calldata.');
+ }
+
+ setResult((current) =>
+ current
+ ? {
+ ...current,
+ executionPlan,
+ }
+ : current,
+ );
+
+ setStatus('Submitting bridge transaction');
+ const bridgeTxHash = await sendTransactionAsync({
+ to: executionPlan.execution.contractAddress as `0x${string}`,
+ data: executionPlan.execution.encodedCalldata as `0x${string}`,
+ });
+
+ setResult((current) =>
+ current
+ ? {
+ ...current,
+ bridgeTxHash,
+ }
+ : current,
+ );
+ setStatus('Submitted');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Mainnet transfer failed');
+ setStatus('Failed');
+ } finally {
+ setLoadingPreset(null);
+ }
+ }
+
+ return {
+ address,
+ isConnected,
+ mainnetWallet,
+ setMainnetWallet,
+ creditorIban,
+ setCreditorIban,
+ amounts,
+ setAmounts,
+ loadingPreset,
+ status,
+ result,
+ error,
+ presets: ZBANK_MAINNET_PRESETS,
+ sendEthToMainnet: () => runPreset(ZBANK_MAINNET_PRESETS[0]),
+ sendUsdtToMainnet: () => runPreset(ZBANK_MAINNET_PRESETS[1]),
+ };
+}
diff --git a/frontend-dapp/src/features/central-bank/zbank-chain-assets.ts b/frontend-dapp/src/features/central-bank/zbank-chain-assets.ts
new file mode 100644
index 0000000..446c930
--- /dev/null
+++ b/frontend-dapp/src/features/central-bank/zbank-chain-assets.ts
@@ -0,0 +1,48 @@
+export type ZBankMainnetPreset = {
+ id: 'eth-mainnet' | 'usdt-mainnet';
+ buttonLabel: string;
+ amountLabel: string;
+ sourceSymbol: string;
+ sourceTokenAddress: `0x${string}`;
+ sourceDecimals: number;
+ destinationSymbol: string;
+ destinationTokenAddress: `0x${string}`;
+ destinationChainId: 1;
+ bridgeRail: string;
+ preferredBridgeLabel: string;
+ tokenLineId: string;
+ currency: string;
+};
+
+export const ZBANK_MAINNET_PRESETS: ZBankMainnetPreset[] = [
+ {
+ id: 'eth-mainnet',
+ buttonLabel: 'Send ETH → Mainnet',
+ amountLabel: 'ETH',
+ sourceSymbol: 'WETH',
+ sourceTokenAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
+ sourceDecimals: 18,
+ destinationSymbol: 'WETH',
+ destinationTokenAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
+ destinationChainId: 1,
+ bridgeRail: 'ccip-weth9',
+ preferredBridgeLabel: 'CCIPWETH9Bridge',
+ tokenLineId: 'WETH-M2',
+ currency: 'USD',
+ },
+ {
+ id: 'usdt-mainnet',
+ buttonLabel: 'Send USDT → Mainnet',
+ amountLabel: 'USDT',
+ sourceSymbol: 'USDT',
+ sourceTokenAddress: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
+ sourceDecimals: 6,
+ destinationSymbol: 'USDT',
+ destinationTokenAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
+ destinationChainId: 1,
+ bridgeRail: 'trustless-lockbox',
+ preferredBridgeLabel: 'GRUTransport',
+ tokenLineId: 'USD-M2',
+ currency: 'USD',
+ },
+];
diff --git a/packages/settlement-core/src/types.ts b/packages/settlement-core/src/types.ts
index 488c4a7..ed6d535 100644
--- a/packages/settlement-core/src/types.ts
+++ b/packages/settlement-core/src/types.ts
@@ -57,6 +57,8 @@ export type ExternalTransferRequest = {
targetChainId: number;
tokenLineId: string;
recipientAddress: string;
+ symbol?: string;
+ tokenAddress?: string;
};
};
diff --git a/services/dbis-exchange/src/adapters/token-aggregation.ts b/services/dbis-exchange/src/adapters/token-aggregation.ts
index 187fa5f..b1b4dc7 100644
--- a/services/dbis-exchange/src/adapters/token-aggregation.ts
+++ b/services/dbis-exchange/src/adapters/token-aggregation.ts
@@ -26,6 +26,8 @@ export async function fetchRoutePlan(body: {
amountIn: string;
recipient?: string;
maxSlippageBps?: number;
+ allowBridge?: boolean;
+ preferredBridges?: string[];
}) {
const { data } = await axios.post(
`${tokenAggUrl()}/api/v2/routes/plan`,
@@ -36,7 +38,11 @@ export async function fetchRoutePlan(body: {
tokenOut: body.tokenOut,
amountIn: body.amountIn,
recipient: body.recipient,
- constraints: { maxSlippageBps: body.maxSlippageBps ?? 50, allowBridge: false },
+ constraints: {
+ maxSlippageBps: body.maxSlippageBps ?? 50,
+ allowBridge: body.allowBridge ?? false,
+ preferredBridges: body.preferredBridges,
+ },
},
{ timeout: 120000 },
);
@@ -51,6 +57,8 @@ export async function fetchExecutionPlan(body: {
amountIn: string;
recipient: string;
maxSlippageBps?: number;
+ allowBridge?: boolean;
+ preferredBridges?: string[];
}) {
const { data } = await axios.post(
`${tokenAggUrl()}/api/v2/routes/internal-execution-plan`,
@@ -61,7 +69,11 @@ export async function fetchExecutionPlan(body: {
tokenOut: body.tokenOut,
amountIn: body.amountIn,
recipient: body.recipient,
- constraints: { maxSlippageBps: body.maxSlippageBps ?? 50, allowBridge: false },
+ constraints: {
+ maxSlippageBps: body.maxSlippageBps ?? 50,
+ allowBridge: body.allowBridge ?? false,
+ preferredBridges: body.preferredBridges,
+ },
},
{ timeout: 120000 },
);
diff --git a/services/dbis-exchange/src/api/routes/exchange.ts b/services/dbis-exchange/src/api/routes/exchange.ts
index 51d1f97..c8756e1 100644
--- a/services/dbis-exchange/src/api/routes/exchange.ts
+++ b/services/dbis-exchange/src/api/routes/exchange.ts
@@ -106,19 +106,32 @@ export function createExchangeRouter(): Router {
router.post('/plan', async (req, res) => {
if (!requireTrade(req, res)) return;
try {
- const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body as Record;
+ const {
+ tokenIn,
+ tokenOut,
+ amountIn,
+ recipient,
+ maxSlippageBps,
+ destinationChainId,
+ allowBridge,
+ preferredBridges,
+ } = req.body as Record;
if (!tokenIn || !tokenOut || !amountIn) {
res.status(400).json({ error: 'tokenIn, tokenOut, amountIn required' });
return;
}
const plan = await fetchRoutePlan({
sourceChainId: cfg.chainId,
- destinationChainId: cfg.chainId,
+ destinationChainId: destinationChainId ? Number(destinationChainId) : cfg.chainId,
tokenIn: String(tokenIn),
tokenOut: String(tokenOut),
amountIn: String(amountIn),
recipient: recipient ? String(recipient) : undefined,
maxSlippageBps: maxSlippageBps ? Number(maxSlippageBps) : cfg.defaultSlippageBps,
+ allowBridge: typeof allowBridge === 'boolean' ? allowBridge : undefined,
+ preferredBridges: Array.isArray(preferredBridges)
+ ? preferredBridges.map((value) => String(value))
+ : undefined,
});
res.json(plan);
} catch (e) {
@@ -129,19 +142,32 @@ export function createExchangeRouter(): Router {
router.post('/execute-plan', async (req, res) => {
if (!requireTrade(req, res)) return;
try {
- const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body as Record;
+ const {
+ tokenIn,
+ tokenOut,
+ amountIn,
+ recipient,
+ maxSlippageBps,
+ destinationChainId,
+ allowBridge,
+ preferredBridges,
+ } = req.body as Record;
if (!tokenIn || !tokenOut || !amountIn || !recipient) {
res.status(400).json({ error: 'tokenIn, tokenOut, amountIn, recipient required' });
return;
}
const plan = await fetchExecutionPlan({
sourceChainId: cfg.chainId,
- destinationChainId: cfg.chainId,
+ destinationChainId: destinationChainId ? Number(destinationChainId) : cfg.chainId,
tokenIn: String(tokenIn),
tokenOut: String(tokenOut),
amountIn: String(amountIn),
recipient: String(recipient),
maxSlippageBps: maxSlippageBps ? Number(maxSlippageBps) : cfg.defaultSlippageBps,
+ allowBridge: typeof allowBridge === 'boolean' ? allowBridge : undefined,
+ preferredBridges: Array.isArray(preferredBridges)
+ ? preferredBridges.map((value) => String(value))
+ : undefined,
});
res.json(plan);
} catch (e) {
diff --git a/services/settlement-middleware/src/api/routes/settlement.ts b/services/settlement-middleware/src/api/routes/settlement.ts
index b410a74..e0e3cac 100644
--- a/services/settlement-middleware/src/api/routes/settlement.ts
+++ b/services/settlement-middleware/src/api/routes/settlement.ts
@@ -224,6 +224,8 @@ export function createSettlementRouter(): Router {
targetChainId: body.convertToCrypto?.targetChainId ?? 138,
tokenLineId: body.convertToCrypto?.tokenLineId ?? `${body.currency ?? 'USD'}-M2`,
recipientAddress: body.convertToCrypto?.recipientAddress ?? '',
+ symbol: body.convertToCrypto?.symbol,
+ tokenAddress: body.convertToCrypto?.tokenAddress,
},
moneyLayers: ['M2', 'M3'],
});
diff --git a/services/settlement-middleware/src/workflows/external-transfer.ts b/services/settlement-middleware/src/workflows/external-transfer.ts
index dd5f437..4dd0043 100644
--- a/services/settlement-middleware/src/workflows/external-transfer.ts
+++ b/services/settlement-middleware/src/workflows/external-transfer.ts
@@ -160,6 +160,8 @@ export async function processExternalTransfer(
recipient: req.convertToCrypto.recipientAddress,
settlementRef: record.settlementId,
dryRun: !cfg.production.allowChainMintExecute,
+ symbol: req.convertToCrypto.symbol,
+ tokenAddress: req.convertToCrypto.tokenAddress,
});
advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash });
} else {