feat(buyer): show live wallet balance on the Wallet tab

Adds GET /v1/wallet/balance (sums the buyer wallet UTxOs via
queryUtxosByAddress from Ogmios) and renders it on the Wallet page as AP3X
with a UTxO count and a refresh button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-06-08 17:47:19 +02:00
parent 5d2397d79b
commit 4434c787be
2 changed files with 105 additions and 1 deletions

View File

@@ -379,6 +379,37 @@ export function createApp(deps: AppDeps): Express {
} }
}); });
// ── GET /v1/wallet/balance — live buyer-wallet balance from Ogmios.
// Sums lovelace across all UTxOs at the buyer's address. The Wallet page
// renders this as AP3X (lovelace / 1e6).
app.get("/v1/wallet/balance", async (_req: Request, res: Response) => {
if (!resolved) {
return jsonError(
res,
503,
"service_unavailable",
"buyer-app booted without chain deps; /v1/wallet/balance disabled",
);
}
try {
const utxos = await resolved.chain.queryUtxosByAddress(resolved.walletKey.address);
let lovelace = 0n;
for (const u of utxos) lovelace += u.lovelace;
return res.status(200).json({
address: resolved.walletKey.address,
lovelace: lovelace.toString(),
utxo_count: utxos.length,
});
} catch (err) {
return jsonError(
res,
502,
"chain_unreachable",
err instanceof Error ? err.message : String(err),
);
}
});
// ── /v1/indexer/* — generic GET passthrough to the internal indexer. // ── /v1/indexer/* — generic GET passthrough to the internal indexer.
// The SPA's SDK is configured with `indexerUrl = ${origin}/v1/indexer`, // The SPA's SDK is configured with `indexerUrl = ${origin}/v1/indexer`,
// so calls like `/suppliers`, `/escrows?buyer=…`, `/capabilities` land // so calls like `/suppliers`, `/escrows?buyer=…`, `/capabilities` land

View File

@@ -1,16 +1,89 @@
/** /**
* buyer/src/ui/pages/Wallet.tsx — wallet address + balance view. * buyer/src/ui/pages/Wallet.tsx — wallet address + live balance view.
*
* Balance is fetched from /v1/wallet/balance, which sums the buyer wallet's
* UTxOs live from Ogmios, and rendered as AP3X (lovelace / 1e6).
*/ */
import { useEffect, useState } from "react";
import { useMarketplace } from "../state/MarketplaceContext.js"; import { useMarketplace } from "../state/MarketplaceContext.js";
function formatAp3x(lovelace: string): string {
return (Number(lovelace) / 1e6).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
});
}
export default function Wallet() { export default function Wallet() {
const marketplace = useMarketplace(); const marketplace = useMarketplace();
const walletKey = marketplace.getWalletKey(); const walletKey = marketplace.getWalletKey();
const [lovelace, setLovelace] = useState<string | null>(null);
const [utxoCount, setUtxoCount] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch("/v1/wallet/balance")
.then(async (r) => {
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { message?: string; error?: string };
throw new Error(j.message ?? j.error ?? `${r.status} ${r.statusText}`);
}
return r.json() as Promise<{ lovelace: string; utxo_count: number }>;
})
.then((j) => {
if (!cancelled) {
setLovelace(j.lovelace);
setUtxoCount(j.utxo_count);
}
})
.catch((e: Error) => {
if (!cancelled) setError(e.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [reloadKey]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-2xl font-semibold">Wallet</h1> <h1 className="text-2xl font-semibold">Wallet</h1>
<dl className="space-y-2"> <dl className="space-y-2">
<div>
<dt className="text-sm text-gray-500">Balance</dt>
<dd className="flex items-baseline gap-3" data-testid="wallet-balance">
{loading ? (
<span className="text-gray-400">Loading…</span>
) : error ? (
<span className="text-sm text-red-600">{error}</span>
) : (
<>
<span className="text-2xl font-semibold">{formatAp3x(lovelace ?? "0")} AP3X</span>
{utxoCount !== null && (
<span className="text-xs text-gray-400">({utxoCount} UTxO{utxoCount === 1 ? "" : "s"})</span>
)}
</>
)}
<button
type="button"
onClick={() => setReloadKey((k) => k + 1)}
disabled={loading}
className="ml-2 rounded border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50"
data-testid="wallet-balance-refresh"
>
Refresh
</button>
</dd>
</div>
<div> <div>
<dt className="text-sm text-gray-500">Address</dt> <dt className="text-sm text-gray-500">Address</dt>
<dd className="font-mono text-sm break-all">{walletKey.address}</dd> <dd className="font-mono text-sm break-all">{walletKey.address}</dd>