From 391e84797fcc6de5901b7650e8ac19b184c5c5bf Mon Sep 17 00:00:00 2001 From: defiQUG Date: Wed, 24 Jun 2026 01:24:43 -0700 Subject: [PATCH] Show HO cash liquidity, nostro, and reserve coverage in explorer wallets. Add a three-line TokenLiquidityStack with Fineract audit freshness and a token-detail HoBackingSummaryCard fed by token-aggregation market-batch HO metrics. Co-authored-by: Cursor --- .../common/HoBackingSummaryCard.tsx | 81 +++++++++ frontend/src/components/home/HomePage.tsx | 12 +- .../components/wallet/TokenLiquidityStack.tsx | 52 ++++++ .../wallet/WalletFundedTokenListing.tsx | 18 +- frontend/src/pages/addresses/[address].tsx | 9 +- frontend/src/pages/search/index.tsx | 5 +- frontend/src/pages/tokens/[address].tsx | 77 +++++++- frontend/src/pages/tokens/index.tsx | 7 +- frontend/src/services/api/tokenAggregation.ts | 84 ++++++++- frontend/src/utils/tokenMarket.ts | 172 +++++++++++++++++- .../src/utils/walletFundedTokenListing.ts | 17 +- 11 files changed, 501 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/common/HoBackingSummaryCard.tsx create mode 100644 frontend/src/components/wallet/TokenLiquidityStack.tsx diff --git a/frontend/src/components/common/HoBackingSummaryCard.tsx b/frontend/src/components/common/HoBackingSummaryCard.tsx new file mode 100644 index 0000000..c7c25f1 --- /dev/null +++ b/frontend/src/components/common/HoBackingSummaryCard.tsx @@ -0,0 +1,81 @@ +import EntityBadge from '@/components/common/EntityBadge' +import type { TokenLiquidityMarketView } from '@/utils/tokenMarket' +import { + formatCashInBankLabel, + formatHoLiquidityAuditFreshness, + formatHoReserveCoverageLabel, + formatSupplyNostroMatchLabel, + formatTotalSupplyHoBackingLabel, + resolveDisplayedCashInBankUsd, +} from '@/utils/tokenMarket' +import { formatUsdValue } from '@/utils/displayCurrency' + +type HoBackingSummaryCardProps = { + market: TokenLiquidityMarketView & { totalSupplyUsd?: number } +} + +export default function HoBackingSummaryCard({ market }: HoBackingSummaryCardProps) { + const coverage = market.hoReserveCoverage + const nostroMatch = market.supplyNostroMatch + + return ( +
+
+
+ HO backing & audit +
+
{formatHoLiquidityAuditFreshness(market)}
+
+
+
+
Nostro settlement (GL 13010)
+
+ {formatCashInBankLabel( + resolveDisplayedCashInBankUsd(market), + market.cashInBankAudit, + market.supplyNostroMatch, + )} +
+ {nostroMatch ? ( +
+ +
+ ) : null} + {market.totalSupplyUsd != null ? ( +
+ {formatTotalSupplyHoBackingLabel(market.totalSupplyUsd, market.supplyNostroMatch)} +
+ ) : null} +

+ Per-token supply vs 1.2× external nostro cash. Large c* supply can break while reserve economics remain sufficient. +

+
+
+
Reserve coverage (GL 1000+1050)
+ {coverage ? ( + <> +
{formatHoReserveCoverageLabel(coverage)}
+ +
+ Reserve assets {formatUsdValue(coverage.reserveAssetsUsd)} · required{' '} + {formatUsdValue(coverage.requiredReserveUsd)} · aggregate issued c*{' '} + {formatUsdValue(coverage.issuedCStarUsd)} +
+ + ) : ( +
Reserve coverage unavailable
+ )} +

+ M0 reserve stock vs 1.2× aggregate Chain 138 issued c*. Not correspondent nostro cash and not GL 2100 M1 book money. +

+
+
+
+ ) +} diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 9e78663..cadf886 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -36,6 +36,7 @@ import { resolveHomePriceFeedAddresses, } from '@/utils/featuredTokens' import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh' +import TokenLiquidityStack from '@/components/wallet/TokenLiquidityStack' type HomeStats = ExplorerStats @@ -933,13 +934,12 @@ export default function Home({ {formatUsd(token.market?.priceUsd)} -
- Liq {formatUsd(token.market?.liquidityUsd)} +
+ {token.market?.lastUpdated ? ( - <> - {' '} - · - +
+ +
) : null}
diff --git a/frontend/src/components/wallet/TokenLiquidityStack.tsx b/frontend/src/components/wallet/TokenLiquidityStack.tsx new file mode 100644 index 0000000..278a839 --- /dev/null +++ b/frontend/src/components/wallet/TokenLiquidityStack.tsx @@ -0,0 +1,52 @@ +import type { TokenAggregationHoReserveCoverage } from '@/services/api/tokenAggregation' +import type { TokenLiquidityMarketView } from '@/utils/tokenMarket' +import { + formatCashInBankAuditLabel, + formatCashInBankLabel, + formatCashLiquidityLabel, + formatHoLiquidityAuditFreshness, + formatHoReserveCoverageLabel, + resolveDisplayedCashInBankUsd, + resolveDisplayedCashLiquidityUsd, +} from '@/utils/tokenMarket' + +type TokenLiquidityStackProps = { + market: TokenLiquidityMarketView + className?: string + /** Include supply nostro detail on the cash-in-bank line (default false for compact stack). */ + verboseCashInBank?: boolean +} + +export default function TokenLiquidityStack({ + market, + className = 'space-y-1 text-xs text-gray-500 dark:text-gray-400', + verboseCashInBank = false, +}: TokenLiquidityStackProps) { + const cashInBankUsd = resolveDisplayedCashInBankUsd(market) + const coverage = market.hoReserveCoverage + + return ( +
+
Cash liq. {formatCashLiquidityLabel(resolveDisplayedCashLiquidityUsd(market))}
+
+ Cash-in-bank{' '} + {verboseCashInBank + ? formatCashInBankLabel(cashInBankUsd, market.cashInBankAudit, market.supplyNostroMatch) + : cashInBankUsd != null + ? `${formatCashInBankLabel(cashInBankUsd, market.cashInBankAudit)} · GL 13010 nostro` + : 'Cash-in-bank unavailable'} +
+ {coverage ? ( +
+ Reserve coverage {formatHoReserveCoverageLabel(coverage)} +
+ ) : null} +
{formatHoLiquidityAuditFreshness(market)}
+
+ ) +} + +export function formatHoReserveCoverageShort(coverage: TokenAggregationHoReserveCoverage | undefined): string { + if (!coverage) return 'Reserve coverage unavailable' + return formatHoReserveCoverageLabel(coverage) +} diff --git a/frontend/src/components/wallet/WalletFundedTokenListing.tsx b/frontend/src/components/wallet/WalletFundedTokenListing.tsx index 5dff5bf..16d0f51 100644 --- a/frontend/src/components/wallet/WalletFundedTokenListing.tsx +++ b/frontend/src/components/wallet/WalletFundedTokenListing.tsx @@ -1,18 +1,10 @@ +import TokenLiquidityStack from '@/components/wallet/TokenLiquidityStack' import type { FundedWalletTokenRow } from '@/utils/walletFundedTokenListing' import { formatFundedRowBalanceUsd, formatFundedRowUnitPrice, } from '@/utils/walletFundedTokenListing' -function formatLiquidityUsd(value: number | undefined): string { - if (value == null || !Number.isFinite(value)) return '—' - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: value >= 100 ? 0 : 2, - }).format(value) -} - type WalletFundedTokenListingProps = { rows: FundedWalletTokenRow[] walletAddress?: string | null @@ -34,7 +26,7 @@ export default function WalletFundedTokenListing({
Funded Chain 138 holdings

Native ETH appears first with the canonical ETH logo. ERC-20 rows use explorer market-batch pricing (unit price, - balance USD, visible liquidity). MetaMask may still show "-" for custom tokens until upstream price + balance USD, cash pool liquidity). MetaMask may still show "-" for custom tokens until upstream price providers list Chain 138 assets.

@@ -65,7 +57,7 @@ export default function WalletFundedTokenListing({ Balance Unit price Balance USD - Liquidity + Cash & reserve MetaMask @@ -101,7 +93,9 @@ export default function WalletFundedTokenListing({ {row.balanceLabel} {formatFundedRowUnitPrice(row)} {formatFundedRowBalanceUsd(row)} - {formatLiquidityUsd(row.liquidityUsd)} + + + {row.kind === 'native' ? 'Automatic on Chain 138' : 'EIP-747 add'} diff --git a/frontend/src/pages/addresses/[address].tsx b/frontend/src/pages/addresses/[address].tsx index 71788e5..0a14c7c 100644 --- a/frontend/src/pages/addresses/[address].tsx +++ b/frontend/src/pages/addresses/[address].tsx @@ -48,6 +48,7 @@ import { buildCanonicalAddressSet, isCanonicalTokenAddress, } from '@/utils/canonicalTokens' +import TokenLiquidityStack from '@/components/wallet/TokenLiquidityStack' import { estimateTokenBalanceUsd, formatBalanceUsdLabel, @@ -536,9 +537,7 @@ export default function AddressDetailPage({ serverRegistryLabel = null }: { serv return (
{formatUsd(market.priceUsd)}
-
- Liq. {formatUsd(market.liquidityUsd)} -
+
) }, @@ -637,9 +636,7 @@ export default function AddressDetailPage({ serverRegistryLabel = null }: { serv return (
{formatUsd(market.priceUsd)}
-
- Liq. {formatUsd(market.liquidityUsd)} -
+
) }, diff --git a/frontend/src/pages/search/index.tsx b/frontend/src/pages/search/index.tsx index f808207..ab20be4 100644 --- a/frontend/src/pages/search/index.tsx +++ b/frontend/src/pages/search/index.tsx @@ -28,6 +28,7 @@ import { fetchPublicJson } from '@/utils/publicExplorer' import { fetchTokenListForSurface } from '@/services/api/tokenListSurfaces' import { useUiMode } from '@/components/common/UiModeContext' import MarketEvidenceNote from '@/components/common/MarketEvidenceNote' +import TokenLiquidityStack from '@/components/wallet/TokenLiquidityStack' import { externalChainExplorerUrl, fetchCwRegistry, @@ -721,9 +722,9 @@ export default function SearchPage({
{market ? (
-
+
Live price: {formatUsd(market.priceUsd)} - Visible liquidity: {formatUsd(market.liquidityUsd)} +
diff --git a/frontend/src/pages/tokens/[address].tsx b/frontend/src/pages/tokens/[address].tsx index b217719..7d43013 100644 --- a/frontend/src/pages/tokens/[address].tsx +++ b/frontend/src/pages/tokens/[address].tsx @@ -14,7 +14,9 @@ import PostureBadge from '@/components/common/PostureBadge' import GruStandardsCard from '@/components/common/GruStandardsCard' import TokenSigningSurfaceCard from '@/components/common/TokenSigningSurfaceCard' import MarketEvidenceNote from '@/components/common/MarketEvidenceNote' +import HoBackingSummaryCard from '@/components/common/HoBackingSummaryCard' import AddTokenToWalletButton from '@/components/wallet/AddTokenToWalletButton' +import TokenLiquidityStack from '@/components/wallet/TokenLiquidityStack' import PaginationControls from '@/components/common/PaginationControls' import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs' import { useDetailTabQuery } from '@/utils/useDetailTabQuery' @@ -25,7 +27,14 @@ import { useDisplayCurrency } from '@/components/common/DisplayCurrencyContext' import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru' import { getGruExplorerMetadata } from '@/services/api/gruExplorerData' import { contractsApi, type ContractProfile } from '@/services/api/contracts' +import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation' import { formatUsdValue, getSecondaryDisplayValue } from '@/utils/displayCurrency' +import { + formatCashInBankLabel, + formatHoReserveCoverageLabel, + formatTotalSupplyHoBackingLabel, + resolveDisplayedCashInBankUsd, +} from '@/utils/tokenMarket' function isValidAddress(value: string) { return /^0x[a-fA-F0-9]{40}$/.test(value) @@ -59,6 +68,7 @@ export default function TokenDetailPage() { const [pools, setPools] = useState([]) const [gruProfile, setGruProfile] = useState(null) const [contractProfile, setContractProfile] = useState(null) + const [aggregationMarket, setAggregationMarket] = useState(null) const [loading, setLoading] = useState(true) const [holderPage, setHolderPage] = useState(1) const [transferPage, setTransferPage] = useState(1) @@ -68,13 +78,14 @@ export default function TokenDetailPage() { const loadToken = useCallback(async () => { setLoading(true) try { - const [tokenResult, provenanceResult, holdersResult, transfersResult, poolsResult, contractResult] = await Promise.all([ + const [tokenResult, provenanceResult, holdersResult, transfersResult, poolsResult, contractResult, aggregationResult] = await Promise.all([ tokensApi.getSafe(address), tokensApi.getProvenanceSafe(address), tokensApi.getHoldersSafe(address, 1, 10), tokensApi.getTransfersSafe(address, 1, 10), tokensApi.getRelatedPoolsSafe(address), contractsApi.getProfileSafe(address), + tokenAggregationApi.getTokenSafe(138, address), ]) setToken(tokenResult.ok ? tokenResult.data : null) @@ -82,6 +93,7 @@ export default function TokenDetailPage() { setHolders(holdersResult.ok ? holdersResult.data : []) setTransfers(transfersResult.ok ? transfersResult.data : []) setPools(poolsResult.ok ? poolsResult.data : []) + setAggregationMarket(aggregationResult.ok ? aggregationResult.data : null) const resolvedContractProfile = contractResult.ok ? contractResult.data : null setContractProfile(resolvedContractProfile) if (tokenResult.ok && tokenResult.data) { @@ -103,6 +115,7 @@ export default function TokenDetailPage() { setPools([]) setGruProfile(null) setContractProfile(null) + setAggregationMarket(null) } finally { setLoading(false) } @@ -117,6 +130,7 @@ export default function TokenDetailPage() { setLoading(false) setToken(null) setContractProfile(null) + setAggregationMarket(null) return } void loadToken() @@ -196,6 +210,7 @@ export default function TokenDetailPage() { () => getGruExplorerMetadata({ address: token?.address || address, symbol: token?.symbol }), [address, token?.address, token?.symbol], ) + const aggregationMarketData = aggregationMarket?.market ?? null const tabs = useMemo[]>( () => [ { id: 'intelligence', label: 'Intelligence' }, @@ -441,9 +456,53 @@ export default function TokenDetailPage() { {token.total_supply && ( - {renderAmountWithDisplayCurrency(token.total_supply, token.decimals, token.symbol)} +
+
{renderAmountWithDisplayCurrency(token.total_supply, token.decimals, token.symbol)}
+ {aggregationMarketData?.totalSupplyUsd != null ? ( +
+ {formatTotalSupplyHoBackingLabel( + aggregationMarketData.totalSupplyUsd, + aggregationMarketData.supplyNostroMatch, + )} +
+ ) : null} +
)} + {aggregationMarketData?.hoReserveCoverage ? ( + +
+
{formatHoReserveCoverageLabel(aggregationMarketData.hoReserveCoverage)}
+
+ Aggregate issued c* vs HO GL {aggregationMarketData.hoReserveCoverage.reserveAssetGlCodes?.join('+') ?? '1000+1050'} reserve assets (M0). Not correspondent nostro cash. +
+ {aggregationMarketData.hoReserveCoverage.reserveAssetsUsd != null ? ( +
+ Reserve assets {formatUsdValue(aggregationMarketData.hoReserveCoverage.reserveAssetsUsd)} · required{' '} + {formatUsdValue(aggregationMarketData.hoReserveCoverage.requiredReserveUsd)} · issued c*{' '} + {formatUsdValue(aggregationMarketData.hoReserveCoverage.issuedCStarUsd)} +
+ ) : null} +
+
+ ) : null} + {aggregationMarketData?.cashInBankUsd != null ? ( + +
+
+ {formatCashInBankLabel( + resolveDisplayedCashInBankUsd(aggregationMarketData), + aggregationMarketData.cashInBankAudit, + aggregationMarketData.supplyNostroMatch, + )} +
+
+ Head Office external nostro (GL 13010). Total supply USD should equal{' '} + {aggregationMarketData.supplyNostroMatch?.multiplier ?? 1.2}× this amount. +
+
+
+ ) : null} {token.holders != null && ( {formatInteger(token.holders)} )} @@ -476,6 +535,11 @@ export default function TokenDetailPage() { /> + {aggregationMarketData?.hoReserveCoverage || aggregationMarketData?.cashInBankUsd != null ? ( +
+ +
+ ) : null}
@@ -485,6 +549,15 @@ export default function TokenDetailPage() {
24h volume: {formatUsd(token.volume_24h)}
Market cap: {formatUsd(token.circulating_market_cap)}
Visible liquidity: {formatUsd(token.liquidity_usd)}
+ + {aggregationMarketData?.supplyNostroMatch ? ( +
+ {formatTotalSupplyHoBackingLabel( + aggregationMarketData.totalSupplyUsd, + aggregationMarketData.supplyNostroMatch, + )} +
+ ) : null}
Valuation source: {token.price_source === 'token-aggregation' ? 'live token aggregation' : token.price_source || 'unavailable'}
Market snapshot: {token.market_updated_at ? formatTimestamp(token.market_updated_at) : 'Unavailable'}
{formatUsd(market.priceUsd)}
-
Liquidity
-
{formatUsd(market.liquidityUsd)}
+
Cash & reserve
+
+ +
diff --git a/frontend/src/services/api/tokenAggregation.ts b/frontend/src/services/api/tokenAggregation.ts index e025f25..ad587c4 100644 --- a/frontend/src/services/api/tokenAggregation.ts +++ b/frontend/src/services/api/tokenAggregation.ts @@ -1,14 +1,66 @@ import { resolveExplorerApiBase } from '../../../libs/frontend-api-client/api-base' +export type TokenAggregationHoReserveCoverageStatus = 'sufficient' | 'insufficient' | 'pending' | 'unavailable' + +export interface TokenAggregationHoReserveCoverage { + status: TokenAggregationHoReserveCoverageStatus + multiplier: number + headOfficeId: number + reserveAssetGlCodes: string[] + reserveAssetsUsd: number + issuedCStarUsd: number + requiredReserveUsd: number + coverageRatio: number + formula: string + policyId: string + policyRef: string + generatedAt: string + tokenCount?: number + error?: string +} + export interface TokenAggregationMarketSnapshot { priceUsd?: number volume24h?: number liquidityUsd?: number + cashLiquidityUsd?: number + cashInBankUsd?: number + cashInBankAudit?: TokenAggregationCashInBankAudit + totalSupplyUsd?: number + supplyNostroMatch?: TokenAggregationSupplyNostroMatch + hoReserveCoverage?: TokenAggregationHoReserveCoverage lastUpdated?: string | null pricingKind?: 'spot' | 'lp-share' isCanonicalClone?: boolean } +export type TokenAggregationSupplyNostroMatchStatus = 'matched' | 'break' | 'pending' | 'unavailable' + +export interface TokenAggregationSupplyNostroMatch { + status: TokenAggregationSupplyNostroMatchStatus + multiplier: number + toleranceBps: number + headOfficeId?: number + externalNostroGlCode?: string + formula?: string + totalSupplyUsd?: number + externalNostroCashUsd?: number + expectedSupplyUsd?: number + deltaUsd?: number + deltaBps?: number +} + +export type TokenAggregationCashInBankAuditStatus = 'aligned' | 'breaks' | 'pending' | 'unavailable' + +export interface TokenAggregationCashInBankAudit { + status: TokenAggregationCashInBankAuditStatus + breaksCount: number + tripleStateAligned?: boolean + policyId: string + policyRef: string + error?: string +} + export interface TokenAggregationTokenSnapshot { chainId: number address: string @@ -79,6 +131,12 @@ interface RawTokenMarketBatchResponse { decimals?: number | string | null priceUsd?: number | string | null liquidityUsd?: number | string | null + cashLiquidityUsd?: number | string | null + cashInBankUsd?: number | string | null + cashInBankAudit?: TokenAggregationCashInBankAudit | null + totalSupplyUsd?: number | string | null + supplyNostroMatch?: TokenAggregationSupplyNostroMatch | null + hoReserveCoverage?: TokenAggregationHoReserveCoverage | null volume24h?: number | string | null lastUpdated?: string | null source?: string | null @@ -99,6 +157,12 @@ interface RawTokenAggregationTokenResponse { priceUsd?: number | string | null volume24h?: number | string | null liquidityUsd?: number | string | null + cashLiquidityUsd?: number | string | null + cashInBankUsd?: number | string | null + cashInBankAudit?: TokenAggregationCashInBankAudit | null + totalSupplyUsd?: number | string | null + supplyNostroMatch?: TokenAggregationSupplyNostroMatch | null + hoReserveCoverage?: TokenAggregationHoReserveCoverage | null lastUpdated?: string | null } | null } | null @@ -179,6 +243,9 @@ function normalizeTokenSnapshot(raw: RawTokenAggregationTokenResponse): TokenAgg decimals, priceUsd, ) + const cashLiquidityUsd = toNumber(token.market?.cashLiquidityUsd) + const cashInBankUsd = toNumber(token.market?.cashInBankUsd) + const totalSupplyUsd = toNumber(token.market?.totalSupplyUsd) return { chainId: toNumber(token.chainId) ?? 138, @@ -192,6 +259,12 @@ function normalizeTokenSnapshot(raw: RawTokenAggregationTokenResponse): TokenAgg priceUsd, volume24h: toNumber(token.market.volume24h), liquidityUsd, + cashLiquidityUsd, + cashInBankUsd, + cashInBankAudit: token.market.cashInBankAudit || undefined, + totalSupplyUsd, + supplyNostroMatch: token.market.supplyNostroMatch || undefined, + hoReserveCoverage: token.market.hoReserveCoverage || undefined, lastUpdated: token.market.lastUpdated || null, } : null, @@ -263,16 +336,25 @@ export const tokenAggregationApi = { if (!snapshot.address) return [] const priceUsd = toNumber(snapshot.priceUsd) const liquidityUsd = toNumber(snapshot.liquidityUsd) + const cashLiquidityUsd = toNumber(snapshot.cashLiquidityUsd) + const cashInBankUsd = toNumber(snapshot.cashInBankUsd) + const totalSupplyUsd = toNumber(snapshot.totalSupplyUsd) const entry: TokenAggregationTokenSnapshot = { chainId, address: snapshot.address, name: snapshot.name || undefined, symbol: snapshot.symbol || undefined, decimals: toNumber(snapshot.decimals), - market: priceUsd != null || liquidityUsd != null || snapshot.pricingKind + market: priceUsd != null || liquidityUsd != null || cashLiquidityUsd != null || cashInBankUsd != null || totalSupplyUsd != null || snapshot.hoReserveCoverage || snapshot.pricingKind ? { priceUsd, liquidityUsd, + cashLiquidityUsd, + cashInBankUsd, + cashInBankAudit: snapshot.cashInBankAudit || undefined, + totalSupplyUsd, + supplyNostroMatch: snapshot.supplyNostroMatch || undefined, + hoReserveCoverage: snapshot.hoReserveCoverage || undefined, volume24h: toNumber(snapshot.volume24h), lastUpdated: snapshot.lastUpdated || null, pricingKind: snapshot.pricingKind || undefined, diff --git a/frontend/src/utils/tokenMarket.ts b/frontend/src/utils/tokenMarket.ts index 45238cf..b7c4b4b 100644 --- a/frontend/src/utils/tokenMarket.ts +++ b/frontend/src/utils/tokenMarket.ts @@ -1,14 +1,36 @@ import type { AddressTokenBalance } from '@/services/api/addresses' -import type { TokenAggregationMarketSnapshot, TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation' +import type { TokenAggregationCashInBankAudit, TokenAggregationHoReserveCoverage, TokenAggregationMarketSnapshot, TokenAggregationSupplyNostroMatch, TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation' export interface ResolvedTokenMarket { priceUsd?: number liquidityUsd?: number + cashLiquidityUsd?: number + cashInBankUsd?: number + cashInBankAudit?: TokenAggregationCashInBankAudit + totalSupplyUsd?: number + supplyNostroMatch?: TokenAggregationSupplyNostroMatch + hoReserveCoverage?: TokenAggregationHoReserveCoverage + lastUpdated?: string | null source?: 'token-aggregation' | 'blockscout' | 'derived' pricingKind?: 'spot' | 'lp-share' isCanonicalClone?: boolean } +/** Wallet liquidity stack + HO audit freshness (API market or resolved balance). */ +export type TokenLiquidityMarketView = Pick< + TokenAggregationMarketSnapshot, + | 'cashLiquidityUsd' + | 'cashInBankUsd' + | 'cashInBankAudit' + | 'supplyNostroMatch' + | 'hoReserveCoverage' + | 'lastUpdated' +> + +function normalizeLastUpdated(value: string | null | undefined): string | undefined { + return value ?? undefined +} + export function isLikelyLpReceiptSymbol(symbol: string | undefined): boolean { if (!symbol) return false const upper = symbol.toUpperCase() @@ -63,6 +85,13 @@ export function resolveTokenMarketForBalance( return { priceUsd: aggregationPrice, liquidityUsd: aggregationMarket?.liquidityUsd, + cashLiquidityUsd: aggregationMarket?.cashLiquidityUsd, + cashInBankUsd: aggregationMarket?.cashInBankUsd, + cashInBankAudit: aggregationMarket?.cashInBankAudit, + totalSupplyUsd: aggregationMarket?.totalSupplyUsd, + supplyNostroMatch: aggregationMarket?.supplyNostroMatch, + hoReserveCoverage: aggregationMarket?.hoReserveCoverage, + lastUpdated: normalizeLastUpdated(aggregationMarket?.lastUpdated ?? aggregationMarket?.hoReserveCoverage?.generatedAt), source: 'token-aggregation', pricingKind, isCanonicalClone, @@ -73,6 +102,13 @@ export function resolveTokenMarketForBalance( return { priceUsd: blockscoutPrice, liquidityUsd: aggregationMarket?.liquidityUsd, + cashLiquidityUsd: aggregationMarket?.cashLiquidityUsd, + cashInBankUsd: aggregationMarket?.cashInBankUsd, + cashInBankAudit: aggregationMarket?.cashInBankAudit, + totalSupplyUsd: aggregationMarket?.totalSupplyUsd, + supplyNostroMatch: aggregationMarket?.supplyNostroMatch, + hoReserveCoverage: aggregationMarket?.hoReserveCoverage, + lastUpdated: normalizeLastUpdated(aggregationMarket?.lastUpdated ?? aggregationMarket?.hoReserveCoverage?.generatedAt), source: 'blockscout', pricingKind, isCanonicalClone, @@ -82,12 +118,146 @@ export function resolveTokenMarketForBalance( return { priceUsd: undefined, liquidityUsd: aggregationMarket?.liquidityUsd, + cashLiquidityUsd: aggregationMarket?.cashLiquidityUsd, + cashInBankUsd: aggregationMarket?.cashInBankUsd, + cashInBankAudit: aggregationMarket?.cashInBankAudit, + totalSupplyUsd: aggregationMarket?.totalSupplyUsd, + supplyNostroMatch: aggregationMarket?.supplyNostroMatch, + hoReserveCoverage: aggregationMarket?.hoReserveCoverage, + lastUpdated: normalizeLastUpdated(aggregationMarket?.lastUpdated ?? aggregationMarket?.hoReserveCoverage?.generatedAt), source: 'derived', pricingKind, isCanonicalClone, } } +/** Public-facing cash pool depth — never fall back to full DEX pool TVL. */ +export function resolveDisplayedCashLiquidityUsd( + market: Pick, +): number | undefined { + const value = market.cashLiquidityUsd + return value != null && value > 0 ? value : undefined +} + +export function formatCashLiquidityLabel(value: number | undefined): string { + if (value == null) return 'Cash liq. unavailable' + return formatUsd(value) +} + +export function resolveDisplayedCashInBankUsd( + market: Pick, +): number | undefined { + const value = market.cashInBankUsd + return value != null && value > 0 ? value : undefined +} + +export function formatCashInBankAuditLabel( + audit: TokenAggregationCashInBankAudit | undefined, +): string { + if (!audit) return 'audit pending' + switch (audit.status) { + case 'aligned': + return 'audit aligned' + case 'breaks': + return audit.breaksCount > 0 ? `audit breaks (${audit.breaksCount})` : 'audit breaks' + case 'pending': + return 'audit pending' + case 'unavailable': + return 'audit unavailable' + default: + return 'audit pending' + } +} + +export function formatSupplyNostroMatchLabel( + match: TokenAggregationSupplyNostroMatch | undefined, +): string { + if (!match) return 'supply match pending' + const ho = match.headOfficeId ?? 1 + switch (match.status) { + case 'matched': + return `HO-${ho} supply matched (${match.multiplier}× nostro)` + case 'break': + return `HO-${ho} supply break (${match.multiplier}× rule)` + case 'pending': + return 'supply match pending' + case 'unavailable': + return 'supply match unavailable' + default: + return 'supply match pending' + } +} + +/** Explicit Total supply vs 1.2× HO cash-in-bank backing line. */ +export function formatTotalSupplyHoBackingLabel( + totalSupplyUsd: number | undefined, + match: TokenAggregationSupplyNostroMatch | undefined, +): string { + if (totalSupplyUsd == null) return 'Total supply USD unavailable' + if (!match || match.expectedSupplyUsd == null) { + return `Total supply ${formatUsd(totalSupplyUsd)} · HO backing check pending` + } + const ho = match.headOfficeId ?? 1 + const mult = match.multiplier ?? 1.2 + const status = + match.status === 'matched' ? 'matched' : match.status === 'break' ? 'break' : match.status + return `Total supply ${formatUsd(totalSupplyUsd)} · expected ${formatUsd(match.expectedSupplyUsd)} (${mult}× HO-${ho} nostro ${formatUsd(match.externalNostroCashUsd ?? 0)}) · ${status}` +} + +export function formatHoReserveCoverageLabel( + coverage: TokenAggregationHoReserveCoverage | undefined, +): string { + if (!coverage || !(coverage.coverageRatio > 0)) { + return coverage?.status === 'pending' ? 'pending' : 'unavailable' + } + const mult = coverage.multiplier ?? 1.2 + const ratio = coverage.coverageRatio.toFixed(2) + const gl = coverage.reserveAssetGlCodes?.join('+') ?? '1000+1050' + const statusSuffix = + coverage.status === 'sufficient' + ? '' + : coverage.status === 'insufficient' + ? ' · insufficient' + : ` · ${coverage.status}` + return `${ratio}× (${mult}× rule · HO GL ${gl})${statusSuffix}` +} + +/** Fineract audit freshness for HO nostro / reserve lines. */ +export function resolveHoLiquidityGeneratedAt( + market: Pick, +): string | undefined { + const candidates = [ + market.hoReserveCoverage?.generatedAt, + normalizeLastUpdated(market.lastUpdated), + ].filter(Boolean) as string[] + if (candidates.length === 0) return undefined + return candidates.sort().reverse()[0] +} + +export function formatHoLiquidityAuditFreshness( + market: Pick, +): string { + const at = resolveHoLiquidityGeneratedAt(market) + if (!at) return 'Fineract audit pending' + const ageMs = Date.now() - new Date(at).getTime() + if (!Number.isFinite(ageMs) || ageMs < 0) return 'Fineract audit pending' + const ageMin = Math.round(ageMs / 60_000) + if (ageMin < 1) return 'Fineract audit · just now' + if (ageMin < 60) return `Fineract audit · ${ageMin}m ago` + const ageH = Math.round(ageMin / 60) + return `Fineract audit · ${ageH}h ago` +} + +export function formatCashInBankLabel( + value: number | undefined, + audit: TokenAggregationCashInBankAudit | undefined, + supplyNostroMatch?: TokenAggregationSupplyNostroMatch, +): string { + if (value == null) return 'Cash-in-bank unavailable' + const supplyLabel = formatSupplyNostroMatchLabel(supplyNostroMatch) + return `${formatUsd(value)} (${formatCashInBankAuditLabel(audit)} · ${supplyLabel})` +} + export function estimateTokenBalanceUsd( rawAmount: string, decimals: number, diff --git a/frontend/src/utils/walletFundedTokenListing.ts b/frontend/src/utils/walletFundedTokenListing.ts index 4c65186..b1bf3c6 100644 --- a/frontend/src/utils/walletFundedTokenListing.ts +++ b/frontend/src/utils/walletFundedTokenListing.ts @@ -1,5 +1,5 @@ import { getNativeAssetMarketSafe, estimateNativeUsdValue } from '@/services/api/nativeAssetPricing' -import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation' +import { tokenAggregationApi, type TokenAggregationCashInBankAudit, type TokenAggregationHoReserveCoverage, type TokenAggregationSupplyNostroMatch, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation' import { formatTokenAmount } from '@/utils/format' import { estimateTokenBalanceUsd } from '@/utils/tokenMarket' import { @@ -33,6 +33,11 @@ export type FundedWalletTokenRow = { priceUsd?: number balanceUsd?: number liquidityUsd?: number + cashLiquidityUsd?: number + cashInBankUsd?: number + cashInBankAudit?: TokenAggregationCashInBankAudit + supplyNostroMatch?: TokenAggregationSupplyNostroMatch + hoReserveCoverage?: TokenAggregationHoReserveCoverage pricingKind?: string marketSource?: string metaMaskAddable: boolean @@ -147,6 +152,11 @@ export async function enrichFundedWalletTokenRowsWithMarket( priceUsd: nativePriceUsd, balanceUsd: balanceUsdText != null ? Number(balanceUsdText) : undefined, liquidityUsd: nativeMarket.data?.market?.liquidityUsd, + cashLiquidityUsd: nativeMarket.data?.market?.cashLiquidityUsd, + cashInBankUsd: nativeMarket.data?.market?.cashInBankUsd, + cashInBankAudit: nativeMarket.data?.market?.cashInBankAudit, + supplyNostroMatch: nativeMarket.data?.market?.supplyNostroMatch, + hoReserveCoverage: nativeMarket.data?.market?.hoReserveCoverage, pricingKind: nativeMarket.data?.market?.pricingKind, marketSource: nativeMarket.ok && nativeMarket.data?.market ? 'token-aggregation' : undefined, } @@ -161,6 +171,11 @@ export async function enrichFundedWalletTokenRowsWithMarket( priceUsd, balanceUsd, liquidityUsd: market?.liquidityUsd, + cashLiquidityUsd: market?.cashLiquidityUsd, + cashInBankUsd: market?.cashInBankUsd, + cashInBankAudit: market?.cashInBankAudit, + supplyNostroMatch: market?.supplyNostroMatch, + hoReserveCoverage: market?.hoReserveCoverage, pricingKind: market?.pricingKind, marketSource: market ? 'token-aggregation' : undefined, }