feat(portal): wire Accounts/Treasury/Reporting/Compliance/Settlements/TransactionBuilder to live Chain-138 + SolaceScan

Extends the POC from #2 beyond the Dashboard so every portal page that
can benefit from on-chain signal now pulls from live backends while
preserving its existing UX. Pages without an on-chain analogue (the
IFRS/GAAP/IPSAS report rows, the dbis_core compliance alerts) stay on
sample data with an explicit 'mocked' note.

New shared primitives
---------------------
src/hooks/useLatestTransactions.ts   — polls SolaceScan /transactions every 15s
src/hooks/useAddressTransactions.ts  — per-address tx feed, 60s polling
src/components/portal/LiveTransactionsPanel.tsx  — reusable live-tx card
src/components/portal/LiveChainBanner.tsx        — slim status banner
src/components/portal/OnChainBalanceTag.tsx      — shared live/off-chain pill

Per-page wiring
---------------
AccountsPage          — on-chain pill + META balance + SolaceScan link on
                        each account row that carries a walletAddress;
                        overlay renders only on wallet rows (negative check).
SettlementsPage       — replaces the static 'Settlement Rate' tile with a
                        live Chain-138 block + tx-today tile; adds a
                        LiveTransactionsPanel above the CSD queue so the
                        page no longer renders identical output when RPC
                        is dead.
ReportingPage         — new On-Chain Reporting Snapshot row (Blockscout
                        /stats: block depth, total tx, total addrs,
                        utilisation, avg block time). Clear note that
                        the IFRS/GAAP/IPSAS rows come from dbis_core and
                        are still mocked.
TreasuryPage          — two new summary tiles: live Chain-138 gas +
                        aggregated on-chain custody (META) from sample
                        wallet addresses. Uses the same
                        useOnChainBalances hook as Accounts.
CompliancePage        — AML monitor strip with wallet selector; dedicated
                        'On-Chain Tx Feed' card shows IN/OUT per tracked
                        wallet via SolaceScan. dbis_core alerts still
                        mocked (no public deploy).
TransactionBuilder    — LiveChainBanner inserted above the composer so
                        users know chain health + gas + latency before
                        composing; transaction-builder-module made a
                        flex column so the banner doesn't cover the
                        canvas.

Assertions baked into every live widget
---------------------------------------
- RPC failure flips colour + text to 'degraded'/'—' (no silent freeze).
- Loading state is distinct from both live and degraded.
- Each overlay is only rendered where real data differs from sample data
  (walletAddress rows for balances, tracked custody for AML, etc.) so a
  page without live overlays is proof-of-scope, not proof-of-brokenness.

Verified locally
----------------
- tsc --noEmit: clean
- npm run build: clean (2066 modules, 565 ms)

Still intentionally mocked
--------------------------
- proxmox.ts — CF-Access protected; a BFF route is now open in
  orchestrator PR (see companion PR for /api/proxmox/*).
- dbisCore.ts — no public deployment exists yet.
This commit is contained in:
2026-04-19 08:31:04 +00:00
parent 007c79d7a9
commit 7253ad1974
11 changed files with 570 additions and 19 deletions

View File

@@ -0,0 +1,50 @@
import { useLiveChain } from '../../hooks/useLiveChain';
import { endpoints } from '../../config/endpoints';
import { Zap } from 'lucide-react';
/**
* Slim one-line banner suitable for embedding above dense UIs (e.g. the
* transaction-builder canvas). Shows chain health + block + gas.
* Flips to a red "RPC degraded" state on polling failure so you don't
* accidentally compose a tx against a dead endpoint.
*/
export default function LiveChainBanner() {
const { health, error, lastUpdated } = useLiveChain();
const ok = !error && !!health;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
padding: '6px 12px',
fontSize: 11,
background: ok ? 'rgba(34,197,94,0.06)' : error ? 'rgba(239,68,68,0.08)' : 'rgba(148,163,184,0.06)',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
>
<Zap size={12} style={{ color: ok ? '#22c55e' : error ? '#ef4444' : '#6b7280' }} />
<span style={{ color: '#cbd5e1' }}>
Chain {endpoints.chain138.chainId} ({endpoints.chain138.name})
</span>
<span style={{ color: ok ? '#22c55e' : error ? '#ef4444' : '#eab308' }}>
{error ? `● RPC degraded · ${error}` : ok ? '● LIVE' : '○ connecting…'}
</span>
{ok && (
<>
<span className="mono" style={{ color: '#6b7280' }}>block</span>
<span className="mono">{health.blockNumber.toLocaleString()}</span>
<span className="mono" style={{ color: '#6b7280' }}>gas</span>
<span className="mono">{health.gasPriceGwei.toFixed(4)} gwei</span>
<span className="mono" style={{ color: '#6b7280' }}>rpc</span>
<span className="mono">{health.latencyMs}ms</span>
</>
)}
<span style={{ marginLeft: 'auto', color: '#6b7280' }}>
rpc: <a href={endpoints.chain138.rpcUrl} target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>{endpoints.chain138.rpcUrl}</a>
{lastUpdated && <span style={{ marginLeft: 8 }}>· {lastUpdated.toLocaleTimeString()}</span>}
</span>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useLatestTransactions } from '../../hooks/useLatestTransactions';
import { explorerTxUrl, explorerAddressUrl, type ExplorerTx } from '../../services/explorer';
import { formatEther } from 'ethers';
import { Activity } from 'lucide-react';
const shortHash = (h: string) => `${h.slice(0, 10)}${h.slice(-6)}`;
const shortAddr = (a: string) => `${a.slice(0, 6)}${a.slice(-4)}`;
const formatMETA = (wei: string) => {
try { return `${Number(formatEther(BigInt(wei))).toFixed(4)} META`; } catch { return `${wei} wei`; }
};
const relativeTime = (iso: string) => {
const then = new Date(iso).getTime();
const dt = Date.now() - then;
if (dt < 60_000) return `${Math.max(1, Math.round(dt / 1000))}s ago`;
if (dt < 3_600_000) return `${Math.round(dt / 60_000)}m ago`;
return `${Math.round(dt / 3_600_000)}h ago`;
};
interface Props {
/** Max rows to show (default 10). */
limit?: number;
/** Custom card header label — defaults to "Live Chain-138 Transactions". */
title?: string;
}
/**
* Renders the most recent on-chain transactions from SolaceScan.
* Degraded state shows the error message; empty state shows a one-liner.
* Links every hash/address to the explorer.
*/
export default function LiveTransactionsPanel({ limit = 10, title = 'Live Chain-138 Transactions' }: Props) {
const { transactions, loading, error, lastUpdated } = useLatestTransactions(limit);
return (
<div className="dashboard-card live-transactions-card">
<div className="card-header">
<h3><Activity size={16} /> {title}</h3>
<span className="small" style={{ color: '#6b7280' }}>
{error
? <span style={{ color: '#ef4444' }}>RPC degraded · {error}</span>
: loading
? 'loading…'
: `${transactions.length} tx · ${lastUpdated ? lastUpdated.toLocaleTimeString() : '—'}`}
</span>
</div>
<div className="live-transactions-list">
{!loading && transactions.length === 0 && !error && (
<div style={{ padding: 12, color: '#6b7280', fontSize: 12 }}>
No transactions returned yet SolaceScan may be indexing.
</div>
)}
{transactions.map((tx: ExplorerTx) => (
<div
key={tx.hash}
className="live-tx-row"
style={{
display: 'grid',
gridTemplateColumns: '1.3fr 1fr 1fr 0.9fr 0.7fr 0.4fr',
gap: 8,
padding: '6px 12px',
borderBottom: '1px solid rgba(255,255,255,0.04)',
fontSize: 11,
alignItems: 'center',
}}
>
<a href={explorerTxUrl(tx.hash)} target="_blank" rel="noreferrer" className="mono" style={{ color: '#60a5fa' }}>
{shortHash(tx.hash)}
</a>
<a href={explorerAddressUrl(tx.from.hash)} target="_blank" rel="noreferrer" className="mono" style={{ color: '#cbd5e1' }}>
{shortAddr(tx.from.hash)}
</a>
<span className="mono" style={{ color: tx.to ? '#cbd5e1' : '#6b7280' }}>
{tx.to ? shortAddr(tx.to.hash) : '— contract create —'}
</span>
<span className="mono">{formatMETA(tx.value)}</span>
<span className="small" style={{ color: '#6b7280' }}>{relativeTime(tx.timestamp)}</span>
<span style={{
color: tx.status === 'error' ? '#ef4444' : tx.status === 'ok' ? '#22c55e' : '#eab308',
fontSize: 9,
}}>
{tx.status ?? 'pending'}
</span>
</div>
))}
</div>
<div style={{ padding: '6px 12px', fontSize: 10, color: '#6b7280' }}>
Source: <a href="https://explorer.d-bis.org" target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>SolaceScan Explorer</a>
{' · polls every 15s · Blockscout v2 /transactions'}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import type { OnChainBalance } from '../../services/chain138';
import { endpoints } from '../../config/endpoints';
import { explorerAddressUrl } from '../../services/explorer';
interface Props {
address: string;
balance: OnChainBalance | undefined;
loading: boolean;
compact?: boolean;
}
/**
* Renders a small pill that flips between three states:
* ● live · chain 138 — on-chain read succeeded
* ○ fetching… — initial RPC call in flight
* ○ off-chain — RPC call failed / empty state
*
* When `balance` is present, the pill becomes a link to the address page on
* SolaceScan. We never hide this tag once a walletAddress is present —
* otherwise there's no way to tell "not wired" apart from "backend is down".
*/
export default function OnChainBalanceTag({ address, balance, loading, compact }: Props) {
const color = balance ? '#22c55e' : loading ? '#eab308' : '#6b7280';
const label = balance ? `● live · chain ${endpoints.chain138.chainId}` : loading ? '○ fetching…' : '○ off-chain';
const href = explorerAddressUrl(address);
const style: React.CSSProperties = {
fontSize: compact ? 9 : 10,
color,
textDecoration: 'none',
letterSpacing: 0.2,
};
return (
<a href={href} target="_blank" rel="noreferrer" title={`View ${address} on SolaceScan`} style={style}>
{label}
{balance && (
<span className="mono" style={{ color: '#60a5fa', marginLeft: 6 }}>
{Number(balance.balanceEth).toFixed(4)} META
</span>
)}
</a>
);
}