From c0d4488483073d8ac5a8ed25286def42c737f2e2 Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 14:25:38 -0700 Subject: [PATCH] feat: build per-token volume chart --- app/analytics/page.tsx | 30 +- app/governance/page.tsx | 18 +- src/components/TokenSelector.tsx | 4 +- src/components/charts/PerTokenVolumeChart.tsx | 193 ++++++++++ src/utils/__tests__/per-token-volume.test.ts | 97 +++++ src/utils/federation.ts | 3 +- src/utils/per-token-volume.ts | 361 ++++++++++++++++++ src/utils/soroban.ts | 49 ++- 8 files changed, 730 insertions(+), 25 deletions(-) create mode 100644 src/components/charts/PerTokenVolumeChart.tsx create mode 100644 src/utils/__tests__/per-token-volume.test.ts create mode 100644 src/utils/per-token-volume.ts diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx index 0461c12..7e45a93 100644 --- a/app/analytics/page.tsx +++ b/app/analytics/page.tsx @@ -18,12 +18,12 @@ "use client"; import { useEffect, useState, useCallback, useRef } from "react"; -import type { Metadata } from "next"; import { NETWORK_NAME } from "@/constants"; -import { getAllInvoices, Invoice } from "@/utils/soroban"; +import { getAllInvoices, getContractStats, Invoice, type ProtocolContractStats } from "@/utils/soroban"; import AmountHistogram from "@/components/charts/AmountHistogram"; import FundingChart from "@/components/charts/FundingChart"; import DefaultRateChart from "@/components/charts/DefaultRateChart"; +import PerTokenVolumeChart from "@/components/charts/PerTokenVolumeChart"; import { ExportButton } from "@/components/ExportButton"; import AnimatedNumber from "@/components/AnimatedNumber"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; @@ -82,6 +82,8 @@ export interface AnalyticsPayload { daily: DailyBucket[]; /** Full list of invoices for distribution analysis */ invoices: Invoice[]; + /** Raw get_contract_stats() result for contract-native protocol metrics */ + contractStats: ProtocolContractStats | null; /** ISO-8601 timestamp of when this data was last indexed */ indexed_at: string; } @@ -122,6 +124,7 @@ function generateMockPayload(): AnalyticsPayload { }, daily, invoices: [], // Real invoices will be fetched in the hook + contractStats: null, indexed_at: new Date().toISOString(), }; } @@ -167,11 +170,13 @@ function useAnalyticsPolling(): UseAnalyticsReturn { const fetch_ = useCallback(async () => { try { - const [payload, invoices] = await Promise.all([ + const [payload, invoices, contractStats] = await Promise.all([ fetchAnalytics(), getAllInvoices(), + getContractStats(), ]); payload.invoices = invoices; + payload.contractStats = contractStats; setData(payload); setLoadState("success"); setLastUpdated(new Date()); @@ -187,7 +192,7 @@ function useAnalyticsPolling(): UseAnalyticsReturn { // Initial fetch useEffect(() => { - fetch_(); + void Promise.resolve().then(fetch_); }, [fetch_]); // Polling every 5 minutes @@ -375,7 +380,7 @@ export default function AnalyticsPage() { ); } - const { summary, daily, indexed_at } = data!; + const { summary, indexed_at } = data!; const defaultRate = formatPercent( summary.total_defaulted, @@ -525,6 +530,21 @@ export default function AnalyticsPage() { + {/* ── Per-token protocol volume ────────────────────────────────── */} +
+ + Token Volume + + + +
+ {/* ── Time-series charts ────────────────────────────────────────── */}
{ - const data = await fetchProposals(); - setProposals(data); - setLoading(false); + fetchProposals().then((data) => { + setProposals(data); + setLoading(false); + }); }, []); useEffect(() => { - load(); + void load(); // Refresh every 30 s for real-time vote counts const interval = setInterval(load, 30_000); return () => clearInterval(interval); @@ -208,10 +210,14 @@ export default function GovernancePage() { useEffect(() => { if (!isConnected || !address) { - setVotingPower(0); + Promise.resolve().then(() => { + setVotingPower(0); + }); return; } - getVotingPower(address).then(setVotingPower); + getVotingPower(address).then((power) => { + setVotingPower(power); + }); }, [address, isConnected]); const sorted = useMemo( diff --git a/src/components/TokenSelector.tsx b/src/components/TokenSelector.tsx index a157f15..7b56e7f 100644 --- a/src/components/TokenSelector.tsx +++ b/src/components/TokenSelector.tsx @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string { return token.name ?? token.symbol; } -function getTokenLogo(token: TokenLike): string { +function getTokenLogo(token: Pick & Partial>): string { return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`; } -function getTokenIconLabel(token: TokenLike): string { +function getTokenIconLabel(token: Pick & Partial>): string { return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK"); } diff --git a/src/components/charts/PerTokenVolumeChart.tsx b/src/components/charts/PerTokenVolumeChart.tsx new file mode 100644 index 0000000..7d7307a --- /dev/null +++ b/src/components/charts/PerTokenVolumeChart.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + type TooltipProps, +} from "recharts"; +import type { Invoice, ProtocolContractStats } from "@/utils/soroban"; +import { + buildPerTokenVolumeData, + type TokenSymbol, + type TokenVolumeRange, +} from "@/utils/per-token-volume"; + +const TOKEN_COLORS: Record = { + USDC: "#2563eb", + EURC: "#f59e0b", + XLM: "#111827", +}; + +const RANGES: TokenVolumeRange[] = [30, 90]; + +const CHART_TICK_STYLE = { + fill: "var(--color-on-surface-variant, #64748b)", + fontSize: 11, + fontFamily: "inherit", +}; + +function formatUsd(value: number): string { + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`; + if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`; + return `$${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`; +} + +function TokenTooltip({ active, payload, label }: TooltipProps) { + if (!active || !payload?.length) return null; + + const total = payload.reduce((sum, entry) => sum + Number(entry.value ?? 0), 0); + + return ( +
+

+ Week of {label} +

+
+ {payload.map((entry) => ( +
+
+
+ + {formatUsd(Number(entry.value ?? 0))} + +
+ ))} +
+
+ Token total + {formatUsd(total)} +
+
+
+ ); +} + +export default function PerTokenVolumeChart({ + stats, + invoices, +}: { + stats?: ProtocolContractStats | null; + invoices: Invoice[]; +}) { + const [range, setRange] = useState(30); + const { buckets, summary } = useMemo( + () => buildPerTokenVolumeData({ stats, invoices, rangeDays: range }), + [invoices, range, stats], + ); + + return ( +
+
+
+

+ Volume by Token +

+

+ Weekly protocol volume stacked by supported token. +

+
+ +
+ {RANGES.map((days) => ( + + ))} +
+
+ +
+
+

+ USD-equivalent +

+

+ {formatUsd(summary.totalUsd)} +

+
+ {(["USDC", "EURC", "XLM"] as TokenSymbol[]).map((symbol) => ( +
+
+
+

+ {formatUsd(summary.totals[symbol])} +

+
+ ))} +
+ +
+ {buckets.length === 0 ? ( +
+ +

+ No token volume in this period +

+
+ ) : ( + + + + + formatUsd(value)} + /> + } /> + + + + + + )} +
+
+ ); +} diff --git a/src/utils/__tests__/per-token-volume.test.ts b/src/utils/__tests__/per-token-volume.test.ts new file mode 100644 index 0000000..1498cf0 --- /dev/null +++ b/src/utils/__tests__/per-token-volume.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { TESTNET_EURC_TOKEN_ID, TESTNET_XLM_TOKEN_ID } from "@/constants"; +import type { Invoice } from "../soroban"; +import { buildPerTokenVolumeData } from "../per-token-volume"; + +describe("buildPerTokenVolumeData", () => { + it("builds weekly stacked USD-equivalent rows from contract stats", () => { + const result = buildPerTokenVolumeData({ + now: new Date("2026-05-20T12:00:00Z"), + rangeDays: 30, + stats: { + oracle_prices: { EURC: 1.1, XLM: 0.2 }, + weekly_per_token_volume: [ + { + week_start: "2026-05-03", + volumes: { + USDC: "100000000", + EURC: "100000000", + XLM: "1000000000", + }, + }, + { + week_start: "2026-03-01", + volumes: { USDC: "50000000" }, + }, + ], + }, + }); + + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]).toMatchObject({ + weekStart: "2026-05-03", + USDC: 100, + EURC: 11, + XLM: 20, + totalUsd: 131, + }); + expect(result.summary.totalUsd).toBe(131); + }); + + it("falls back to all-time per-token totals when weekly stats are unavailable", () => { + const result = buildPerTokenVolumeData({ + now: new Date("2026-05-20T12:00:00Z"), + rangeDays: 90, + stats: { + token_volumes: [ + { token: "USDC", total_volume: 250000000n }, + { token: TESTNET_EURC_TOKEN_ID, total_volume: 100000000n }, + { token: TESTNET_XLM_TOKEN_ID, total_volume: 2000000000n }, + ], + }, + }); + + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0].label).toBe("All-time"); + expect(result.summary.totals.USDC).toBe(250); + expect(result.summary.totals.EURC).toBe(10.8); + expect(result.summary.totals.XLM).toBe(30); + }); + + it("derives weekly rows from funded and paid invoices when stats are absent", () => { + const fundedAt = Math.floor(new Date("2026-05-13T12:00:00Z").getTime() / 1000); + const invoices: Invoice[] = [ + { + id: 1n, + status: "Funded", + freelancer: "GFREELANCER", + payer: "GPAYER", + amount: 75000000n, + due_date: 0n, + funded_at: BigInt(fundedAt), + discount_rate: 500, + token: "USDC", + }, + { + id: 2n, + status: "Pending", + freelancer: "GFREELANCER", + payer: "GPAYER", + amount: 75000000n, + due_date: 0n, + discount_rate: 500, + token: "USDC", + }, + ]; + + const result = buildPerTokenVolumeData({ + now: new Date("2026-05-20T12:00:00Z"), + rangeDays: 30, + invoices, + }); + + expect(result.buckets).toHaveLength(1); + expect(result.summary.totalUsd).toBe(75); + expect(result.summary.totals.USDC).toBe(75); + }); +}); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..b1d734b 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -11,7 +11,8 @@ export async function resolveFederatedAddress(address: string): Promise try { const account = await horizonServer.getAccount(address); - const homeDomain = account.home_domain ?? (account as any).homeDomain; + const accountWithHomeDomain = account as { home_domain?: string; homeDomain?: string }; + const homeDomain = accountWithHomeDomain.home_domain ?? accountWithHomeDomain.homeDomain; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`); diff --git a/src/utils/per-token-volume.ts b/src/utils/per-token-volume.ts new file mode 100644 index 0000000..42816a4 --- /dev/null +++ b/src/utils/per-token-volume.ts @@ -0,0 +1,361 @@ +import { + TESTNET_EURC_TOKEN_ID, + TESTNET_USDC_TOKEN_ID, + TESTNET_XLM_TOKEN_ID, +} from "@/constants"; +import type { Invoice, ProtocolContractStats } from "./soroban"; + +export type TokenSymbol = "USDC" | "EURC" | "XLM"; +export type TokenVolumeRange = 30 | 90; + +export interface TokenVolumeBucket { + weekStart: string; + label: string; + USDC: number; + EURC: number; + XLM: number; + totalUsd: number; +} + +export interface TokenVolumeSummary { + totalUsd: number; + totals: Record; +} + +export interface TokenVolumeResult { + buckets: TokenVolumeBucket[]; + summary: TokenVolumeSummary; +} + +const TOKEN_SYMBOLS: TokenSymbol[] = ["USDC", "EURC", "XLM"]; + +const TOKEN_DECIMALS: Record = { + USDC: 6, + EURC: 7, + XLM: 7, +}; + +const FALLBACK_USD_PRICES: Record = { + USDC: 1, + EURC: 1.08, + XLM: 0.15, +}; + +const TOKEN_ID_TO_SYMBOL: Record = { + [TESTNET_USDC_TOKEN_ID]: "USDC", + [TESTNET_EURC_TOKEN_ID]: "EURC", + [TESTNET_XLM_TOKEN_ID]: "XLM", +}; + +const WEEKLY_FIELD_NAMES = [ + "weekly_per_token_volume", + "weekly_token_volume", + "token_weekly_volume", + "per_token_weekly_volume", + "per_token_volume_by_week", + "per_token_volumes_by_week", +]; + +const TOTAL_FIELD_NAMES = [ + "per_token_volume", + "per_token_volumes", + "token_volume", + "token_volumes", + "volume_by_token", +]; + +export function buildPerTokenVolumeData({ + stats, + invoices = [], + rangeDays, + now = new Date(), +}: { + stats?: ProtocolContractStats | null; + invoices?: Invoice[]; + rangeDays: TokenVolumeRange; + now?: Date; +}): TokenVolumeResult { + const prices = readOraclePrices(stats); + const cutoff = getCutoffDate(now, rangeDays); + const rows = + readWeeklyBuckets(stats, cutoff, prices) ?? + readAllTimeTotals(stats, now, prices) ?? + readInvoiceBuckets(invoices, cutoff, prices); + + return { + buckets: rows, + summary: summarizeBuckets(rows), + }; +} + +function readWeeklyBuckets( + stats: ProtocolContractStats | null | undefined, + cutoff: Date, + prices: Record, +): TokenVolumeBucket[] | null { + if (!stats) return null; + + for (const field of WEEKLY_FIELD_NAMES) { + const raw = stats[field]; + const entries = normalizeEntries(raw); + if (!entries.length) continue; + + const buckets = entries + .map((entry) => bucketFromEntry(entry, prices)) + .filter((bucket): bucket is TokenVolumeBucket => Boolean(bucket)) + .filter((bucket) => new Date(bucket.weekStart) >= cutoff) + .sort((a, b) => a.weekStart.localeCompare(b.weekStart)); + + if (buckets.length) return buckets; + } + + return null; +} + +function readAllTimeTotals( + stats: ProtocolContractStats | null | undefined, + now: Date, + prices: Record, +): TokenVolumeBucket[] | null { + if (!stats) return null; + + for (const field of TOTAL_FIELD_NAMES) { + const raw = stats[field]; + const volumes = readVolumeRecord(raw); + if (!volumes) continue; + + return [ + finalizeBucket({ + weekStart: startOfWeek(now).toISOString().slice(0, 10), + label: "All-time", + volumes, + prices, + }), + ]; + } + + return null; +} + +function readInvoiceBuckets( + invoices: Invoice[], + cutoff: Date, + prices: Record, +): TokenVolumeBucket[] { + const byWeek = new Map>(); + + invoices + .filter((invoice) => invoice.status === "Funded" || invoice.status === "Paid") + .forEach((invoice) => { + const timestamp = invoice.funded_at ?? invoice.due_date; + if (!timestamp) return; + + const date = new Date(Number(timestamp) * 1000); + if (date < cutoff) return; + + const symbol = normalizeTokenSymbol(invoice.token); + const weekStart = startOfWeek(date).toISOString().slice(0, 10); + const volumes = byWeek.get(weekStart) ?? emptyVolumes(); + volumes[symbol] += toNativeAmount(invoice.amount, symbol); + byWeek.set(weekStart, volumes); + }); + + return Array.from(byWeek.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([weekStart, volumes]) => + finalizeBucket({ + weekStart, + label: formatWeekLabel(weekStart), + volumes, + prices, + }), + ); +} + +function bucketFromEntry( + entry: Record, + prices: Record, +): TokenVolumeBucket | null { + const weekStart = readWeekStart(entry); + if (!weekStart) return null; + + const volumes = readVolumeRecord(entry.volumes ?? entry.tokens ?? entry) ?? emptyVolumes(); + + return finalizeBucket({ + weekStart, + label: String(entry.label ?? formatWeekLabel(weekStart)), + volumes, + prices, + }); +} + +function finalizeBucket({ + weekStart, + label, + volumes, + prices, +}: { + weekStart: string; + label: string; + volumes: Record; + prices: Record; +}): TokenVolumeBucket { + const bucket = { + weekStart, + label, + USDC: volumes.USDC * prices.USDC, + EURC: volumes.EURC * prices.EURC, + XLM: volumes.XLM * prices.XLM, + totalUsd: TOKEN_SYMBOLS.reduce( + (sum, symbol) => sum + volumes[symbol] * prices[symbol], + 0, + ), + }; + + return bucket; +} + +function summarizeBuckets(buckets: TokenVolumeBucket[]): TokenVolumeSummary { + return buckets.reduce( + (summary, bucket) => ({ + totalUsd: summary.totalUsd + bucket.totalUsd, + totals: { + USDC: summary.totals.USDC + bucket.USDC, + EURC: summary.totals.EURC + bucket.EURC, + XLM: summary.totals.XLM + bucket.XLM, + }, + }), + { totalUsd: 0, totals: emptyVolumes() }, + ); +} + +function normalizeEntries(raw: unknown): Record[] { + if (Array.isArray(raw)) { + return raw.filter(isRecord); + } + + if (isRecord(raw)) { + return Object.entries(raw).map(([key, value]) => + isRecord(value) ? { weekStart: key, ...value } : { weekStart: key, volumes: value }, + ); + } + + return []; +} + +function readVolumeRecord(raw: unknown): Record | null { + if (!raw) return null; + + const volumes = emptyVolumes(); + + if (Array.isArray(raw)) { + raw.forEach((entry) => { + if (!isRecord(entry)) return; + const symbol = normalizeTokenSymbol(entry.symbol ?? entry.token ?? entry.token_id); + volumes[symbol] += toNativeAmount( + entry.volume ?? entry.amount ?? entry.total_volume ?? entry.value ?? 0, + symbol, + ); + }); + + return hasVolume(volumes) ? volumes : null; + } + + if (!isRecord(raw)) return null; + + Object.entries(raw).forEach(([key, value]) => { + const symbol = normalizeTokenSymbol(key); + volumes[symbol] += toNativeAmount(value, symbol); + }); + + return hasVolume(volumes) ? volumes : null; +} + +function readOraclePrices(stats: ProtocolContractStats | null | undefined): Record { + const prices = { ...FALLBACK_USD_PRICES }; + const raw = stats?.oracle_prices ?? stats?.prices ?? stats?.token_prices; + if (!isRecord(raw)) return prices; + + Object.entries(raw).forEach(([key, value]) => { + const symbol = normalizeTokenSymbol(key); + const numeric = toNumber(value); + if (numeric > 0) prices[symbol] = numeric; + }); + + return prices; +} + +function readWeekStart(entry: Record): string | null { + const raw = + entry.weekStart ?? + entry.week_start ?? + entry.week ?? + entry.date ?? + entry.start_date ?? + entry.timestamp ?? + entry.ledger_time; + + if (typeof raw === "number" || typeof raw === "bigint") { + return startOfWeek(new Date(Number(raw) * 1000)).toISOString().slice(0, 10); + } + + if (typeof raw === "string") { + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw; + const date = new Date(raw); + if (!Number.isNaN(date.getTime())) return startOfWeek(date).toISOString().slice(0, 10); + } + + return null; +} + +function normalizeTokenSymbol(value: unknown): TokenSymbol { + const text = String(value ?? "USDC").trim().toUpperCase(); + if (text in TOKEN_ID_TO_SYMBOL) return TOKEN_ID_TO_SYMBOL[text]; + if (text.includes("EURC")) return "EURC"; + if (text.includes("XLM") || text === "NATIVE") return "XLM"; + return "USDC"; +} + +function toNativeAmount(value: unknown, symbol: TokenSymbol): number { + const numeric = toNumber(value); + if (!Number.isFinite(numeric)) return 0; + return numeric / 10 ** TOKEN_DECIMALS[symbol]; +} + +function toNumber(value: unknown): number { + if (typeof value === "bigint") return Number(value); + if (typeof value === "number") return value; + if (typeof value === "string") return Number(value); + return 0; +} + +function startOfWeek(date: Date): Date { + const start = new Date(date); + start.setHours(0, 0, 0, 0); + start.setDate(start.getDate() - start.getDay()); + return start; +} + +function getCutoffDate(now: Date, rangeDays: TokenVolumeRange): Date { + const cutoff = new Date(now); + cutoff.setHours(0, 0, 0, 0); + cutoff.setDate(cutoff.getDate() - rangeDays); + return cutoff; +} + +function formatWeekLabel(weekStart: string): string { + const date = new Date(weekStart); + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +function emptyVolumes(): Record { + return { USDC: 0, EURC: 0, XLM: 0 }; +} + +function hasVolume(volumes: Record): boolean { + return TOKEN_SYMBOLS.some((symbol) => volumes[symbol] > 0); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/utils/soroban.ts b/src/utils/soroban.ts index c9da60d..4fd317a 100644 --- a/src/utils/soroban.ts +++ b/src/utils/soroban.ts @@ -27,6 +27,10 @@ const POLL_ATTEMPTS = 20; const ACCEPTED_SEND_STATUSES = new Set(["PENDING", "DUPLICATE"]); const DEFAULT_TOKEN_ALLOWANCE_LEDGER_BUFFER = 20_000; +function getSimulationError(sim: unknown): string { + return String((sim as { error?: unknown }).error ?? "Unknown simulation error"); +} + // ─── Types ──────────────────────────────────────────────────────────────────── export interface Invoice { @@ -73,6 +77,8 @@ export interface ReputationEvent { score?: number; } +export type ProtocolContractStats = Record; + // ─── Private helpers ────────────────────────────────────────────────────────── const KNOWN_TOKEN_METADATA: Record> = { @@ -396,6 +402,27 @@ export async function getTopPayers(limit = 50): Promise { } } +export async function getContractStats(): Promise { + try { + const callResult = await server.simulateTransaction( + buildReadTransaction(CONTRACT_ID, "get_contract_stats", []) + ); + + if (!rpc.Api.isSimulationSuccess(callResult) || !callResult.result?.retval) { + return null; + } + + const native = scValToNative(callResult.result.retval); + if (!native || typeof native !== "object" || Array.isArray(native)) { + return null; + } + + return native as ProtocolContractStats; + } catch { + return null; + } +} + // ─── Write: fund invoice ────────────────────────────────────────────────────── export async function fundInvoice(funder: string, invoice_id: bigint) { @@ -580,7 +607,7 @@ export async function submitInvoice( const sim = await server.simulateTransaction(tx); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } // Extract the predicted invoice ID from simulation retval @@ -589,18 +616,18 @@ export async function submitInvoice( const raw = scValToNative(sim.result!.retval); // Contract returns Result — unwrap Ok variant if (raw && typeof raw === "object" && "ok" in raw) { - invoiceId = BigInt((raw as any).ok); + invoiceId = BigInt((raw as { ok: bigint | number | string }).ok); } else if (raw && typeof raw === "object" && "Ok" in raw) { - invoiceId = BigInt((raw as any).Ok); + invoiceId = BigInt((raw as { Ok: bigint | number | string }).Ok); } else { - invoiceId = BigInt(raw as any); + invoiceId = BigInt(raw as bigint | number | string); } - } catch (_) { + } catch { // If we can't parse it, proceed without the ID — it'll be shown after poll } const finalTx = rpc.assembleTransaction(tx, sim).build(); - return { tx: finalTx as any, invoiceId }; + return { tx: finalTx, invoiceId }; } export interface UpdateInvoiceArgs { @@ -644,17 +671,17 @@ export async function updateInvoice( const sim = await server.simulateTransaction(tx); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } const finalTx = rpc.assembleTransaction(tx, sim).build(); - return { tx: finalTx as any }; + return { tx: finalTx }; } export async function cancelInvoice( freelancer: string, invoiceId: bigint -): Promise<{ tx: any }> { +): Promise<{ tx: Transaction }> { // Use a default sequence number / account for preparing or real one if needed let account: Account; try { @@ -677,11 +704,11 @@ export async function cancelInvoice( const sim = await server.simulateTransaction(txUrl); if (!rpc.Api.isSimulationSuccess(sim)) { - throw new Error(`Simulation failed: ${(sim as any).error}`); + throw new Error(`Simulation failed: ${getSimulationError(sim)}`); } const finalTx = rpc.assembleTransaction(txUrl, sim).build(); - return { tx: finalTx as any }; + return { tx: finalTx }; } export async function submitInvoiceTransaction({