feat(portal): wire DashboardPage to live Chain-138 RPC + SolaceScan Explorer

- Add services/{http,chain138,explorer,proxmox,dbisCore} + hooks/{useLiveChain,useOnChainBalances}
- Add BackendStatusBar + LiveNetworkPanel components on DashboardPage
- Overlay on-chain META balance on account rows carrying a walletAddress
- Normalize EIP-55 checksum in chain138.getNativeBalance so hand-typed
  sample custody addresses (e.g. 0x742d35Cc...bD38) don't silently drop
  out of the balance map
- Default RPC: https://rpc.d-bis.org (user-preferred gateway)
- proxmox.ts stays mocked (CF-Access, needs BFF); dbisCore.ts stays
  mocked (no public deployment yet)

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
Devin AI
2026-04-19 00:33:46 +00:00
parent 52676016fb
commit 007c79d7a9
11 changed files with 781 additions and 14 deletions

133
src/services/chain138.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Chain 138 (DeFi Oracle Meta Mainnet) read-only client.
*
* Uses `ethers` v6 JsonRpcProvider against `rpc-core.d-bis.org`.
* All calls are read-only — no signing, no tx submission from here.
* Wallet signing happens through the MetaMask BrowserProvider in AuthContext.
*/
import { JsonRpcProvider, formatEther, formatUnits, getAddress } from 'ethers';
import { endpoints } from '../config/endpoints';
/**
* Normalize an EVM address before handing it to the provider. Ethers v6
* enforces EIP-55 checksum for mixed-case addresses and throws
* `bad address checksum` otherwise — which silently loses balances for any
* hand-typed sample address whose casing doesn't match the canonical
* checksum. Lowercasing sidesteps that validation while remaining a
* perfectly valid on-chain reference. If the string isn't a well-formed
* address at all we still let `getAddress` surface the error.
*/
function normalizeAddress(address: string): string {
try {
return getAddress(address);
} catch {
return getAddress(address.toLowerCase());
}
}
let _provider: JsonRpcProvider | null = null;
export function getChain138Provider(): JsonRpcProvider {
if (_provider) return _provider;
_provider = new JsonRpcProvider(endpoints.chain138.rpcUrl, {
chainId: endpoints.chain138.chainId,
name: endpoints.chain138.name,
});
return _provider;
}
export interface ChainHealth {
chainId: number;
blockNumber: number;
gasPriceGwei: number;
latencyMs: number;
rpcUrl: string;
}
export async function getChainHealth(): Promise<ChainHealth> {
const provider = getChain138Provider();
const t0 = performance.now();
const [network, blockNumber, feeData] = await Promise.all([
provider.getNetwork(),
provider.getBlockNumber(),
provider.getFeeData(),
]);
const latencyMs = Math.round(performance.now() - t0);
const gasPriceWei = feeData.gasPrice ?? feeData.maxFeePerGas ?? 0n;
const gasPriceGwei = Number(formatUnits(gasPriceWei, 'gwei'));
return {
chainId: Number(network.chainId),
blockNumber,
gasPriceGwei,
latencyMs,
rpcUrl: endpoints.chain138.rpcUrl,
};
}
export interface OnChainBalance {
address: string;
balanceEth: string;
balanceWei: string;
blockNumber: number;
}
export async function getNativeBalance(address: string): Promise<OnChainBalance> {
const provider = getChain138Provider();
const normalized = normalizeAddress(address);
const [balanceWei, blockNumber] = await Promise.all([
provider.getBalance(normalized),
provider.getBlockNumber(),
]);
return {
address,
balanceWei: balanceWei.toString(),
balanceEth: formatEther(balanceWei),
blockNumber,
};
}
export async function getNativeBalances(addresses: string[]): Promise<Record<string, OnChainBalance>> {
const results = await Promise.all(
addresses.map(a =>
getNativeBalance(a).catch(err => {
// Surface in dev — otherwise a single bad address silently disappears
// from the balances map and the UI shows "off-chain" forever.
// eslint-disable-next-line no-console
console.warn(`[chain138] getNativeBalance(${a}) failed:`, err);
return { address: a, error: err };
}),
),
);
const out: Record<string, OnChainBalance> = {};
for (const r of results) {
if ('error' in r) continue;
out[r.address] = r;
}
return out;
}
export interface LatestBlock {
number: number;
hash: string;
timestamp: number;
txCount: number;
gasUsed: string;
gasLimit: string;
miner: string;
}
export async function getLatestBlock(): Promise<LatestBlock | null> {
const provider = getChain138Provider();
const block = await provider.getBlock('latest');
if (!block) return null;
return {
number: block.number,
hash: block.hash ?? '',
timestamp: block.timestamp,
txCount: block.transactions.length,
gasUsed: block.gasUsed?.toString() ?? '0',
gasLimit: block.gasLimit?.toString() ?? '0',
miner: block.miner ?? '',
};
}

63
src/services/dbisCore.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* DBIS Core Banking client — STUB.
*
* The `d-bis/dbis_core` system is fully specified (707-line README describing
* Global Ledger, Accounts, Payments, FX Engine, CBDC, Compliance, Settlement)
* but it is not currently deployed at any resolvable hostname. `api.dbis-core.d-bis.org`
* fails DNS.
*
* Every method here returns the existing sample data from `src/data/portalData.ts`
* with a one-time console.warn so it's obvious the portal is not yet wired to
* the real ledger. Flip `endpoints.dbisCore.mocked = false` (and implement the
* fetch bodies below) when the core banking API is stood up.
*/
import type { Account, FinancialSummary, TreasuryPosition, CashForecast, SettlementRecord, ReportConfig } from '../types/portal';
import { sampleAccounts, financialSummary, treasuryPositions, cashForecasts, settlementRecords, reportConfigs } from '../data/portalData';
import { endpoints } from '../config/endpoints';
let warned = false;
function warnMock(method: string): void {
if (warned) return;
warned = true;
// eslint-disable-next-line no-console
console.warn(
`[dbisCore] Using sample data for ${method}() — dbis_core API is not deployed. ` +
`Set VITE_DBIS_CORE_API_BASE_URL and flip endpoints.dbisCore.mocked once available.`,
);
}
function delay<T>(value: T, ms = 150): Promise<T> {
return new Promise(resolve => setTimeout(() => resolve(value), ms));
}
export async function listAccounts(): Promise<Account[]> {
if (endpoints.dbisCore.mocked) { warnMock('listAccounts'); return delay(sampleAccounts); }
// TODO: fetch(`${endpoints.dbisCore.apiBaseUrl}/v1/accounts`)
throw new Error('dbis_core live mode not implemented');
}
export async function getFinancialSummary(): Promise<FinancialSummary> {
if (endpoints.dbisCore.mocked) { warnMock('getFinancialSummary'); return delay(financialSummary); }
throw new Error('dbis_core live mode not implemented');
}
export async function listTreasuryPositions(): Promise<TreasuryPosition[]> {
if (endpoints.dbisCore.mocked) { warnMock('listTreasuryPositions'); return delay(treasuryPositions); }
throw new Error('dbis_core live mode not implemented');
}
export async function listCashForecasts(): Promise<CashForecast[]> {
if (endpoints.dbisCore.mocked) { warnMock('listCashForecasts'); return delay(cashForecasts); }
throw new Error('dbis_core live mode not implemented');
}
export async function listSettlements(): Promise<SettlementRecord[]> {
if (endpoints.dbisCore.mocked) { warnMock('listSettlements'); return delay(settlementRecords); }
throw new Error('dbis_core live mode not implemented');
}
export async function listReports(): Promise<ReportConfig[]> {
if (endpoints.dbisCore.mocked) { warnMock('listReports'); return delay(reportConfigs); }
throw new Error('dbis_core live mode not implemented');
}

82
src/services/explorer.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* SolaceScan Explorer (Blockscout v2) client for Chain 138.
*
* Base URL: https://api.explorer.d-bis.org (CORS *)
* Fallback: https://explorer.d-bis.org/api/v2 (same data, different host)
*
* We hit the `api.*` subdomain by default because it returns clean JSON
* without the Next.js HTML wrapper.
*/
import { httpJson } from './http';
import { endpoints } from '../config/endpoints';
const api = (path: string) => `${endpoints.explorer.apiBaseUrl}/api/v2${path}`;
export interface ExplorerStats {
total_blocks: number;
total_transactions: number;
total_addresses: number;
latest_block: number;
average_block_time: number;
gas_prices: { average: number; fast?: number; slow?: number };
network_utilization_percentage: number;
transactions_today: number;
}
export async function getExplorerStats(): Promise<ExplorerStats> {
return httpJson<ExplorerStats>(api('/stats'));
}
export interface ExplorerBlock {
height: number;
hash: string;
timestamp: string;
tx_count: number;
gas_used: string;
gas_limit: string;
size: number;
miner: { hash: string };
}
export async function getLatestBlocks(): Promise<ExplorerBlock[]> {
return httpJson<ExplorerBlock[]>(api('/main-page/blocks'));
}
export interface ExplorerTx {
hash: string;
block_number: number;
timestamp: string;
from: { hash: string };
to: { hash: string } | null;
value: string; // wei
gas_used: string;
gas_price: string;
status: 'ok' | 'error' | null;
method: string | null;
fee: { value: string };
}
interface PagedTxResponse { items: ExplorerTx[]; next_page_params?: unknown }
export async function getLatestTransactions(limit = 20): Promise<ExplorerTx[]> {
const data = await httpJson<PagedTxResponse>(api('/transactions'));
return (data.items ?? []).slice(0, limit);
}
export async function getAddressTransactions(address: string, limit = 20): Promise<ExplorerTx[]> {
const data = await httpJson<PagedTxResponse>(api(`/addresses/${address}/transactions`));
return (data.items ?? []).slice(0, limit);
}
export function explorerTxUrl(hash: string): string {
return `${endpoints.explorer.baseUrl}/tx/${hash}`;
}
export function explorerAddressUrl(address: string): string {
return `${endpoints.explorer.baseUrl}/address/${address}`;
}
export function explorerBlockUrl(height: number): string {
return `${endpoints.explorer.baseUrl}/block/${height}`;
}

57
src/services/http.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Thin fetch wrapper with timeout + JSON handling + typed errors.
* Keep this dependency-free so every service can share it.
*/
export class HttpError extends Error {
readonly status: number;
readonly statusText: string;
readonly url: string;
readonly body?: unknown;
constructor(status: number, statusText: string, url: string, body?: unknown) {
super(`HTTP ${status} ${statusText} (${url})`);
this.name = 'HttpError';
this.status = status;
this.statusText = statusText;
this.url = url;
this.body = body;
}
}
export interface HttpOptions extends Omit<RequestInit, 'body'> {
/** Request body — automatically JSON-stringified when an object. */
body?: unknown;
/** Abort the request after N ms. Default 10000. */
timeoutMs?: number;
}
export async function httpJson<T>(url: string, opts: HttpOptions = {}): Promise<T> {
const { body, timeoutMs = 10_000, headers, ...rest } = opts;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
...rest,
signal: controller.signal,
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
});
if (!res.ok) {
let parsed: unknown;
try { parsed = await res.json(); } catch { parsed = await res.text().catch(() => undefined); }
throw new HttpError(res.status, res.statusText, url, parsed);
}
const ct = res.headers.get('content-type') ?? '';
if (ct.includes('application/json')) return (await res.json()) as T;
return (await res.text()) as unknown as T;
} finally {
clearTimeout(timer);
}
}

63
src/services/proxmox.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Proxmox infrastructure health client — STUB pending BFF.
*
* `proxmox-api.d-bis.org` is live but fronted by Cloudflare Access. A browser
* from the Solace portal cannot present a CF-Access JWT without completing
* the SSO flow (which is a full redirect, not appropriate for a dashboard
* widget). The correct integration is via a BFF that holds a CF-Access
* Service Token and exposes scoped read-only endpoints.
*
* Until that BFF exists, this returns static sample data with a console.warn
* so the UI degrades gracefully.
*/
import { endpoints } from '../config/endpoints';
export interface ProxmoxNode {
id: string;
name: string;
status: 'online' | 'offline' | 'unknown';
cpu: number; // 0..1
memoryPct: number; // 0..100
uptimeSec: number;
}
export interface ProxmoxClusterHealth {
nodes: ProxmoxNode[];
vmCount: number;
lxcCount: number;
quorum: 'ok' | 'degraded' | 'lost';
source: 'live' | 'mock';
}
const MOCK: ProxmoxClusterHealth = {
nodes: [
{ id: 'pve1', name: 'pve1.d-bis.org', status: 'online', cpu: 0.34, memoryPct: 62, uptimeSec: 8_294_400 },
{ id: 'pve2', name: 'pve2.d-bis.org', status: 'online', cpu: 0.18, memoryPct: 41, uptimeSec: 8_294_400 },
{ id: 'pve3', name: 'pve3.d-bis.org', status: 'online', cpu: 0.52, memoryPct: 78, uptimeSec: 6_912_000 },
],
vmCount: 34,
lxcCount: 112,
quorum: 'ok',
source: 'mock',
};
let warned = false;
function warnMock(): void {
if (warned) return;
warned = true;
// eslint-disable-next-line no-console
console.warn(
`[proxmox] Using mock cluster health — ${endpoints.proxmox.apiBaseUrl} is behind Cloudflare Access. ` +
`Route calls through a BFF holding a CF-Access Service Token and remove this stub.`,
);
}
export async function getClusterHealth(): Promise<ProxmoxClusterHealth> {
if (endpoints.proxmox.requiresBff) {
warnMock();
return new Promise(resolve => setTimeout(() => resolve(MOCK), 150));
}
// TODO: fetch(`${endpoints.proxmox.apiBaseUrl}/api2/json/cluster/status`, { headers: { 'CF-Access-Client-Id': ..., 'CF-Access-Client-Secret': ... } })
throw new Error('proxmox live mode not implemented');
}