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:
133
src/services/chain138.ts
Normal file
133
src/services/chain138.ts
Normal 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
63
src/services/dbisCore.ts
Normal 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
82
src/services/explorer.ts
Normal 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
57
src/services/http.ts
Normal 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
63
src/services/proxmox.ts
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user