Show HO cash liquidity, nostro, and reserve coverage in explorer wallets.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 42s
Validate Explorer / frontend (push) Failing after 57s
Validate Explorer / smoke-e2e (push) Has been skipped

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 <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-06-24 01:24:43 -07:00
parent 0cb31cfa9d
commit 391e84797f
11 changed files with 501 additions and 33 deletions

View File

@@ -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 (
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
HO backing &amp; audit
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{formatHoLiquidityAuditFreshness(market)}</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div className="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div className="font-semibold text-gray-900 dark:text-white">Nostro settlement (GL 13010)</div>
<div>
{formatCashInBankLabel(
resolveDisplayedCashInBankUsd(market),
market.cashInBankAudit,
market.supplyNostroMatch,
)}
</div>
{nostroMatch ? (
<div className="flex flex-wrap items-center gap-2">
<EntityBadge
label={formatSupplyNostroMatchLabel(nostroMatch)}
tone={nostroMatch.status === 'matched' ? 'success' : nostroMatch.status === 'break' ? 'warning' : 'neutral'}
/>
</div>
) : null}
{market.totalSupplyUsd != null ? (
<div className="text-xs text-gray-500 dark:text-gray-400">
{formatTotalSupplyHoBackingLabel(market.totalSupplyUsd, market.supplyNostroMatch)}
</div>
) : null}
<p className="text-xs text-gray-500 dark:text-gray-400">
Per-token supply vs 1.2× external nostro cash. Large c* supply can break while reserve economics remain sufficient.
</p>
</div>
<div className="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div className="font-semibold text-gray-900 dark:text-white">Reserve coverage (GL 1000+1050)</div>
{coverage ? (
<>
<div>{formatHoReserveCoverageLabel(coverage)}</div>
<EntityBadge
label={coverage.status === 'sufficient' ? 'Reserve sufficient' : coverage.status}
tone={coverage.status === 'sufficient' ? 'success' : coverage.status === 'insufficient' ? 'warning' : 'neutral'}
/>
<div className="text-xs text-gray-500 dark:text-gray-400">
Reserve assets {formatUsdValue(coverage.reserveAssetsUsd)} · required{' '}
{formatUsdValue(coverage.requiredReserveUsd)} · aggregate issued c*{' '}
{formatUsdValue(coverage.issuedCStarUsd)}
</div>
</>
) : (
<div className="text-gray-500">Reserve coverage unavailable</div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
M0 reserve stock vs 1.2× aggregate Chain 138 issued c*. Not correspondent nostro cash and not GL 2100 M1 book money.
</p>
</div>
</div>
</div>
)
}

View File

@@ -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)}
</div>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Liq {formatUsd(token.market?.liquidityUsd)}
<div className="mt-1">
<TokenLiquidityStack market={token.market ?? {}} />
{token.market?.lastUpdated ? (
<>
{' '}
· <ClientRelativeTime value={token.market.lastUpdated} suffix=" ago" />
</>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
<ClientRelativeTime value={token.market.lastUpdated} suffix=" ago" />
</div>
) : null}
</div>
</Link>

View File

@@ -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 (
<div className={className}>
<div>Cash liq. {formatCashLiquidityLabel(resolveDisplayedCashLiquidityUsd(market))}</div>
<div>
Cash-in-bank{' '}
{verboseCashInBank
? formatCashInBankLabel(cashInBankUsd, market.cashInBankAudit, market.supplyNostroMatch)
: cashInBankUsd != null
? `${formatCashInBankLabel(cashInBankUsd, market.cashInBankAudit)} · GL 13010 nostro`
: 'Cash-in-bank unavailable'}
</div>
{coverage ? (
<div title="OMNL Fineract book reserve assets (M0), not correspondent nostro cash.">
Reserve coverage {formatHoReserveCoverageLabel(coverage)}
</div>
) : null}
<div className="text-[11px] opacity-80">{formatHoLiquidityAuditFreshness(market)}</div>
</div>
)
}
export function formatHoReserveCoverageShort(coverage: TokenAggregationHoReserveCoverage | undefined): string {
if (!coverage) return 'Reserve coverage unavailable'
return formatHoReserveCoverageLabel(coverage)
}

View File

@@ -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({
<div className="text-sm font-semibold text-gray-900 dark:text-white">Funded Chain 138 holdings</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
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 &quot;-&quot; for custom tokens until upstream price
balance USD, cash pool liquidity). MetaMask may still show &quot;-&quot; for custom tokens until upstream price
providers list Chain 138 assets.
</p>
@@ -65,7 +57,7 @@ export default function WalletFundedTokenListing({
<th className="px-2 py-2 font-semibold">Balance</th>
<th className="px-2 py-2 font-semibold">Unit price</th>
<th className="px-2 py-2 font-semibold">Balance USD</th>
<th className="px-2 py-2 font-semibold">Liquidity</th>
<th className="px-2 py-2 font-semibold">Cash &amp; reserve</th>
<th className="px-2 py-2 font-semibold">MetaMask</th>
</tr>
</thead>
@@ -101,7 +93,9 @@ export default function WalletFundedTokenListing({
<td className="px-2 py-3 font-medium text-gray-900 dark:text-white">{row.balanceLabel}</td>
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatFundedRowUnitPrice(row)}</td>
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatFundedRowBalanceUsd(row)}</td>
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatLiquidityUsd(row.liquidityUsd)}</td>
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">
<TokenLiquidityStack market={row} />
</td>
<td className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400">
{row.kind === 'native' ? 'Automatic on Chain 138' : 'EIP-747 add'}
</td>

View File

@@ -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 (
<div className="space-y-1 text-sm">
<div>{formatUsd(market.priceUsd)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Liq. {formatUsd(market.liquidityUsd)}
</div>
<TokenLiquidityStack market={market} />
</div>
)
},
@@ -637,9 +636,7 @@ export default function AddressDetailPage({ serverRegistryLabel = null }: { serv
return (
<div className="space-y-1 text-sm">
<div>{formatUsd(market.priceUsd)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Liq. {formatUsd(market.liquidityUsd)}
</div>
<TokenLiquidityStack market={market} />
</div>
)
},

View File

@@ -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({
<Address address={result.data.address} truncate showCopy={false} />
{market ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
<div className="flex flex-wrap gap-x-3 gap-y-1">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span>Live price: {formatUsd(market.priceUsd)}</span>
<span>Visible liquidity: {formatUsd(market.liquidityUsd)}</span>
<TokenLiquidityStack market={market} className="space-y-0.5 text-sm text-gray-500 dark:text-gray-400" />
</div>
<MarketEvidenceNote lastUpdated={market.lastUpdated} compact />
</div>

View File

@@ -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<MissionControlLiquidityPool[]>([])
const [gruProfile, setGruProfile] = useState<GruStandardsProfile | null>(null)
const [contractProfile, setContractProfile] = useState<ContractProfile | null>(null)
const [aggregationMarket, setAggregationMarket] = useState<TokenAggregationTokenSnapshot | null>(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<SectionTab<'intelligence' | 'standards' | 'holders' | 'transfers' | 'liquidity'>[]>(
() => [
{ id: 'intelligence', label: 'Intelligence' },
@@ -441,9 +456,53 @@ export default function TokenDetailPage() {
</DetailRow>
{token.total_supply && (
<DetailRow label="Total Supply">
{renderAmountWithDisplayCurrency(token.total_supply, token.decimals, token.symbol)}
<div className="space-y-1">
<div>{renderAmountWithDisplayCurrency(token.total_supply, token.decimals, token.symbol)}</div>
{aggregationMarketData?.totalSupplyUsd != null ? (
<div className="text-xs text-gray-500 dark:text-gray-400">
{formatTotalSupplyHoBackingLabel(
aggregationMarketData.totalSupplyUsd,
aggregationMarketData.supplyNostroMatch,
)}
</div>
) : null}
</div>
</DetailRow>
)}
{aggregationMarketData?.hoReserveCoverage ? (
<DetailRow label="HO reserve coverage">
<div className="space-y-1 text-sm">
<div>{formatHoReserveCoverageLabel(aggregationMarketData.hoReserveCoverage)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Aggregate issued c* vs HO GL {aggregationMarketData.hoReserveCoverage.reserveAssetGlCodes?.join('+') ?? '1000+1050'} reserve assets (M0). Not correspondent nostro cash.
</div>
{aggregationMarketData.hoReserveCoverage.reserveAssetsUsd != null ? (
<div className="text-xs text-gray-500 dark:text-gray-400">
Reserve assets {formatUsdValue(aggregationMarketData.hoReserveCoverage.reserveAssetsUsd)} · required{' '}
{formatUsdValue(aggregationMarketData.hoReserveCoverage.requiredReserveUsd)} · issued c*{' '}
{formatUsdValue(aggregationMarketData.hoReserveCoverage.issuedCStarUsd)}
</div>
) : null}
</div>
</DetailRow>
) : null}
{aggregationMarketData?.cashInBankUsd != null ? (
<DetailRow label="HO cash-in-bank">
<div className="space-y-1 text-sm">
<div>
{formatCashInBankLabel(
resolveDisplayedCashInBankUsd(aggregationMarketData),
aggregationMarketData.cashInBankAudit,
aggregationMarketData.supplyNostroMatch,
)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Head Office external nostro (GL 13010). Total supply USD should equal{' '}
{aggregationMarketData.supplyNostroMatch?.multiplier ?? 1.2}× this amount.
</div>
</div>
</DetailRow>
) : null}
{token.holders != null && (
<DetailRow label="Holders">{formatInteger(token.holders)}</DetailRow>
)}
@@ -476,6 +535,11 @@ export default function TokenDetailPage() {
/>
<TabPanel idPrefix={TOKEN_DETAIL_TABS_ID} tabId="intelligence" activeTab={activeTab}>
{aggregationMarketData?.hoReserveCoverage || aggregationMarketData?.cashInBankUsd != null ? (
<div className="mb-6">
<HoBackingSummaryCard market={aggregationMarketData} />
</div>
) : null}
<Card title="Token Intelligence">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -485,6 +549,15 @@ export default function TokenDetailPage() {
<div>24h volume: {formatUsd(token.volume_24h)}</div>
<div>Market cap: {formatUsd(token.circulating_market_cap)}</div>
<div>Visible liquidity: {formatUsd(token.liquidity_usd)}</div>
<TokenLiquidityStack market={aggregationMarketData ?? {}} verboseCashInBank />
{aggregationMarketData?.supplyNostroMatch ? (
<div>
{formatTotalSupplyHoBackingLabel(
aggregationMarketData.totalSupplyUsd,
aggregationMarketData.supplyNostroMatch,
)}
</div>
) : null}
<div>Valuation source: {token.price_source === 'token-aggregation' ? 'live token aggregation' : token.price_source || 'unavailable'}</div>
<div>Market snapshot: {token.market_updated_at ? formatTimestamp(token.market_updated_at) : 'Unavailable'}</div>
<MarketEvidenceNote

View File

@@ -17,6 +17,7 @@ import {
sortIndexedTokensCanonicalFirst,
} from '@/utils/canonicalTokens'
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
import TokenLiquidityStack from '@/components/wallet/TokenLiquidityStack'
import { fetchTokenListForSurface } from '@/services/api/tokenListSurfaces'
import { selectCuratedFeaturedTokens } from '@/utils/featuredTokens'
import { formatInteger } from '@/utils/format'
@@ -282,8 +283,10 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.priceUsd)}</div>
</div>
<div className="rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Liquidity</div>
<div className="mt-1 font-semibold text-gray-950 dark:text-white">{formatUsd(market.liquidityUsd)}</div>
<div className="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Cash &amp; reserve</div>
<div className="mt-1">
<TokenLiquidityStack market={market} />
</div>
</div>
<div className="col-span-2">
<MarketEvidenceNote lastUpdated={market.lastUpdated} compact />

View File

@@ -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,

View File

@@ -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<ResolvedTokenMarket, 'cashLiquidityUsd'>,
): 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<ResolvedTokenMarket, 'cashInBankUsd'>,
): 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<TokenLiquidityMarketView, 'hoReserveCoverage' | 'lastUpdated'>,
): 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<TokenLiquidityMarketView, 'hoReserveCoverage' | 'lastUpdated'>,
): 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,

View File

@@ -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,
}