Add Z Bank mainnet payment presets

This commit is contained in:
defiQUG
2026-07-05 16:48:36 -07:00
parent b5d4f268bf
commit abdd8881e2
9 changed files with 464 additions and 22 deletions

View File

@@ -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() {
<QuickPortalLinks />
<div className="flex gap-2 flex-wrap mb-6">
{([
['overview', 'Overview'],
['payments', 'Payments'],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setActiveTab(value)}
className={`px-3 py-1 text-xs border font-mono ${
activeTab === value
? 'border-[#00d4ff] text-[#00d4ff] bg-[#00d4ff]/10'
: 'border-[#1e4a8a] text-[#7a9bb8]'
}`}
>
{label}
</button>
))}
</div>
{cb.error && (
@@ -102,19 +125,17 @@ export default function CentralBankDashboard() {
<FineractLedgerBanner
{activeTab === 'payments' ? (
<ZBankMainnetTransferPanel />
) : (
<>
<FineractLedgerBanner
moneySupply={cb.moneySupply}
loading={cb.loading}
officeName={office?.name}
/>
moneySupply={cb.moneySupply}
loading={cb.loading}
officeName={office?.name}
/>
<section id="z-full-settlement" className="scroll-mt-20">
<section id="z-full-settlement" className="scroll-mt-20">
<h2 className="dash-section-title mb-1 flex items-center gap-2">
@@ -146,11 +167,11 @@ export default function CentralBankDashboard() {
</section>
</section>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div id="z-settlement-protocols" className="scroll-mt-20">
@@ -312,11 +333,12 @@ export default function CentralBankDashboard() {
<ChainMatrixPanel />
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -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 (
<section className="dash-card mb-6">
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
<div>
<h2 className="dash-section-title flex items-center gap-2">
<NasaIcon name="bridge" size={14} className="nasa-icon--telemetry" />
Payments Send to Ethereum mainnet
</h2>
<p className="dash-section-subtitle mt-1">
One-click settlement, mint, and bridge presets for Ethereum mainnet.
</p>
</div>
<span className="nasa-badge nasa-badge--telemetry">{settlement.status}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<label className="block">
<span className="dash-stat-label mb-1 block">Ethereum mainnet wallet</span>
<input
type="text"
placeholder="0x..."
value={settlement.mainnetWallet}
onChange={(event) => settlement.setMainnetWallet(event.target.value)}
className="nasa-input text-sm"
/>
</label>
<label className="block">
<span className="dash-stat-label mb-1 block">Z Bank IBAN</span>
<input
type="text"
placeholder="IBAN"
value={settlement.creditorIban}
onChange={(event) => settlement.setCreditorIban(event.target.value)}
className="nasa-input text-sm"
/>
</label>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{settlement.presets.map((preset) => (
<div key={preset.id} className="rounded border border-[#1e4a8a] bg-[#08131f] p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div>
<p className="text-[#e8f4ff] font-semibold">{preset.buttonLabel}</p>
<p className="text-xs text-[#7a9bb8] font-mono">
{preset.bridgeRail} · {preset.tokenLineId} · targetChainId: {preset.destinationChainId}
</p>
</div>
<span className="nasa-badge nasa-badge--caution">{preset.amountLabel}</span>
</div>
<label className="block mb-3">
<span className="dash-stat-label mb-1 block">{preset.amountLabel}</span>
<input
type="text"
inputMode="decimal"
placeholder="0.0"
value={settlement.amounts[preset.id]}
onChange={(event) =>
settlement.setAmounts((current) => ({
...current,
[preset.id]: event.target.value,
}))
}
className="nasa-input text-sm"
/>
</label>
<button
type="button"
className="nasa-btn nasa-btn--primary w-full"
onClick={actionByPreset[preset.id]}
disabled={settlement.loadingPreset !== null}
>
<NasaIcon name="rocket" size={14} />
{settlement.loadingPreset === preset.id ? 'Processing…' : preset.buttonLabel}
</button>
</div>
))}
</div>
{settlement.error && (
<p className="text-sm text-[#fc3d21] flex items-center gap-1 mb-3">
<NasaIcon name="alert" size={14} />
{settlement.error}
</p>
)}
<div className="rounded border border-[#1e4a8a] bg-[#050b14] p-4 text-sm">
<p className="dash-stat-label mb-2">Result</p>
{settlement.result ? (
<dl className="space-y-2 font-mono text-xs">
<div className="flex justify-between gap-3">
<dt className="text-[#7a9bb8]">Settlement phase</dt>
<dd className="text-[#e8f4ff]">{settlement.result.settlement?.phase ?? '—'}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-[#7a9bb8]">Settlement ID</dt>
<dd className="text-[#e8f4ff] break-all text-right">{settlement.result.settlement?.settlementId ?? '—'}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-[#7a9bb8]">Mint tx</dt>
<dd className="text-[#e8f4ff] break-all text-right">{settlement.result.settlement?.chainTxHash ?? '—'}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-[#7a9bb8]">Bridge tx</dt>
<dd className="text-[#e8f4ff] break-all text-right">{settlement.result.bridgeTxHash ?? '—'}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-[#7a9bb8]">Route</dt>
<dd className="text-[#e8f4ff] text-right">
{settlement.result.executionPlan?.plannerResponse?.selectedRouteReason ??
settlement.result.executionPlan?.plannerResponse?.routePlan?.legs
?.map((leg) => leg.provider)
.filter(Boolean)
.join(' → ') ??
'—'}
</dd>
</div>
</dl>
) : (
<p className="text-[#7a9bb8]">No transfer submitted yet.</p>
)}
</div>
</section>
);
}

View File

@@ -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<Record<ZBankMainnetPreset['id'], string>>({
'eth-mainnet': '',
'usdt-mainnet': '',
});
const [loadingPreset, setLoadingPreset] = useState<ZBankMainnetPreset['id'] | null>(null);
const [status, setStatus] = useState('Idle');
const [result, setResult] = useState<TransferResult | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (address && !mainnetWallet) {
setMainnetWallet(address);
}
}, [address, mainnetWallet]);
async function postJson<T>(url: string, body: Record<string, unknown>): Promise<T> {
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<SettlementRecord>(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<ExecutionPlan>(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]),
};
}

View File

@@ -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',
},
];

View File

@@ -57,6 +57,8 @@ export type ExternalTransferRequest = {
targetChainId: number;
tokenLineId: string;
recipientAddress: string;
symbol?: string;
tokenAddress?: string;
};
};

View File

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

View File

@@ -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<string, unknown>;
const {
tokenIn,
tokenOut,
amountIn,
recipient,
maxSlippageBps,
destinationChainId,
allowBridge,
preferredBridges,
} = req.body as Record<string, unknown>;
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<string, unknown>;
const {
tokenIn,
tokenOut,
amountIn,
recipient,
maxSlippageBps,
destinationChainId,
allowBridge,
preferredBridges,
} = req.body as Record<string, unknown>;
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) {

View File

@@ -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'],
});

View File

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