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,54 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getAddressTransactions, type ExplorerTx } from '../services/explorer';
export interface AddressTransactionsState {
transactions: ExplorerTx[];
loading: boolean;
error: string | null;
lastUpdated: Date | null;
refresh: () => void;
}
/**
* Fetches recent transactions for a single address from SolaceScan.
* Re-fetches on address change; also re-polls every `pollMs` (default 30s).
* Empty address short-circuits — hook returns an idle state with no error.
*/
export function useAddressTransactions(address: string | null | undefined, limit = 10, pollMs = 30_000): AddressTransactionsState {
const [transactions, setTransactions] = useState<ExplorerTx[]>([]);
const [loading, setLoading] = useState(!!address);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const mounted = useRef(true);
const tick = useCallback(async () => {
if (!address) {
setTransactions([]);
setLoading(false);
return;
}
try {
const txs = await getAddressTransactions(address, limit);
if (!mounted.current) return;
setTransactions(txs);
setError(null);
setLastUpdated(new Date());
} catch (e) {
if (!mounted.current) return;
setError(e instanceof Error ? e.message : String(e));
setTransactions([]);
} finally {
if (mounted.current) setLoading(false);
}
}, [address, limit]);
useEffect(() => {
mounted.current = true;
void tick();
if (!address) return () => { mounted.current = false; };
const id = setInterval(tick, pollMs);
return () => { mounted.current = false; clearInterval(id); };
}, [tick, address, pollMs]);
return { transactions, loading, error, lastUpdated, refresh: () => { void tick(); } };
}

View File

@@ -0,0 +1,46 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getLatestTransactions, type ExplorerTx } from '../services/explorer';
export interface LatestTransactionsState {
transactions: ExplorerTx[];
loading: boolean;
error: string | null;
lastUpdated: Date | null;
refresh: () => void;
}
/**
* Polls SolaceScan (Blockscout v2) `/transactions` every `pollMs` and
* returns the top `limit` rows. Never throws — error surfaces in state.
*/
export function useLatestTransactions(limit = 20, pollMs = 15_000): LatestTransactionsState {
const [transactions, setTransactions] = useState<ExplorerTx[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const mounted = useRef(true);
const tick = useCallback(async () => {
try {
const txs = await getLatestTransactions(limit);
if (!mounted.current) return;
setTransactions(txs);
setError(null);
setLastUpdated(new Date());
} catch (e) {
if (!mounted.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (mounted.current) setLoading(false);
}
}, [limit]);
useEffect(() => {
mounted.current = true;
void tick();
const id = setInterval(tick, pollMs);
return () => { mounted.current = false; clearInterval(id); };
}, [tick, pollMs]);
return { transactions, loading, error, lastUpdated, refresh: () => { void tick(); } };
}