From 63bde8d501d9417a6dcc2d8a9a4a4fc47608c67e Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 11:31:57 -0700 Subject: [PATCH] feat: add invoice cancellation UI --- app/freelancer/page.tsx | 78 +++++++++--- app/governance/page.tsx | 16 ++- app/pay/[id]/__tests__/PayInvoice.test.tsx | 86 +++++++++---- app/pay/[id]/page.tsx | 44 ++++++- src/components/CancelInvoiceButton.tsx | 120 ++++++++++++++++++ src/components/TokenSelector.tsx | 4 +- .../__tests__/CancelInvoiceButton.test.tsx | 111 ++++++++++++++++ src/utils/evidence.ts | 2 +- src/utils/federation.ts | 8 +- 9 files changed, 407 insertions(+), 62 deletions(-) create mode 100644 src/components/CancelInvoiceButton.tsx create mode 100644 src/components/__tests__/CancelInvoiceButton.test.tsx diff --git a/app/freelancer/page.tsx b/app/freelancer/page.tsx index 795bc79..19bc13a 100644 --- a/app/freelancer/page.tsx +++ b/app/freelancer/page.tsx @@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef, Suspense } fr import { useTranslation } from "react-i18next"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; +import CancelInvoiceButton from "@/components/CancelInvoiceButton"; import InvoiceFilterBar from "@/components/InvoiceFilterBar"; import { useWallet } from "@/context/WalletContext"; import { useToast } from "@/context/ToastContext"; @@ -21,13 +22,19 @@ import { } from "@/utils/format"; import { rpc, TransactionBuilder } from "@stellar/stellar-sdk"; import { RPC_URL, NETWORK_PASSPHRASE } from "@/constants"; -import SkeletonRow, { FREELANCER_COLUMNS } from "@/components/SkeletonRow"; import { ExportButton } from "@/components/ExportButton"; -import { EmptyState } from "@/components/EmptyState"; -import { FreelancerEmptyIllustration } from "@/components/illustrations/EmptyIllustrations"; const server = new rpc.Server(RPC_URL); +interface SendTransactionResult { + status?: string; + hash?: string; +} + +interface TransactionStatusResult { + status?: string; +} + // ─── Types ──────────────────────────────────────────────────────────────────── type Screen = "submit" | "my-invoices"; @@ -55,6 +62,8 @@ function StatusBadge({ status }: { status: string }) { Funded: "bg-[#dbeafe] text-[#1d4ed8] dark:bg-[#1e3a8a]/30 dark:text-[#93c5fd]", Paid: "bg-[#dcfce7] text-[#15803d] dark:bg-[#14532d]/30 dark:text-[#86efac]", + Cancelled: + "bg-surface-container text-on-surface-variant", Defaulted: "bg-error-container text-on-error-container", }; @@ -95,7 +104,7 @@ function FreelancerPageContent() { const [invoices, setInvoices] = useState([]); const [loadingInvoices, setLoadingInvoices] = useState(false); - const refreshIntervalRef = useRef | null>(null); + const refreshIntervalRef = useRef(null); const { filters, setFilters, @@ -119,16 +128,28 @@ function FreelancerPageContent() { useEffect(() => { if (screen === "my-invoices") { - fetchMyInvoices(); - refreshIntervalRef.current = setInterval(fetchMyInvoices, 30_000); + const timeout = window.setTimeout(() => { + void fetchMyInvoices(); + }, 0); + refreshIntervalRef.current = window.setInterval(() => { + void fetchMyInvoices(); + }, 30_000); + return () => { + window.clearTimeout(timeout); + if (refreshIntervalRef.current) { + window.clearInterval(refreshIntervalRef.current); + } + }; } return () => { if (refreshIntervalRef.current) { - clearInterval(refreshIntervalRef.current); + window.clearInterval(refreshIntervalRef.current); } }; }, [screen, fetchMyInvoices]); + const [minDueDate] = useState(() => new Date(Date.now() + 86_400_000).toISOString().slice(0, 10)); + const filteredInvoices = useMemo( () => applyInvoiceFilters(invoices, filters, { @@ -193,12 +214,14 @@ function FreelancerPageContent() { TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE) ); - if ((sendResult as any).status === "PENDING") { - let txStatus = await server.getTransaction((sendResult as any).hash); + const sent = sendResult as SendTransactionResult; + + if (sent.status === "PENDING" && sent.hash) { + let txStatus = await server.getTransaction(sent.hash) as TransactionStatusResult; let tries = 0; - while ((txStatus as any).status === "NOT_FOUND" && tries < 20) { + while (txStatus.status === "NOT_FOUND" && tries < 20) { await new Promise((r) => setTimeout(r, 1500)); - txStatus = await server.getTransaction((sendResult as any).hash); + txStatus = await server.getTransaction(sent.hash) as TransactionStatusResult; tries++; } @@ -208,16 +231,16 @@ function FreelancerPageContent() { updateToast(toastId, { type: "success", title: t("freelancer.toast.submitted"), - txHash: (sendResult as any).hash, + txHash: sent.hash, }); } else { - throw new Error(`Transaction rejected: ${(sendResult as any).status}`); + throw new Error(`Transaction rejected: ${sent.status ?? "unknown"}`); } - } catch (err: any) { + } catch (err) { updateToast(toastId, { type: "error", title: t("freelancer.toast.submissionFailed"), - message: err?.message ?? t("freelancer.toast.unknownError"), + message: err instanceof Error ? err.message : t("freelancer.toast.unknownError"), }); } finally { setIsSubmitting(false); @@ -232,8 +255,17 @@ function FreelancerPageContent() { t("freelancer.invoices.headers.discount"), t("freelancer.invoices.headers.dueDate"), t("freelancer.invoices.headers.status"), + "Actions", ]; + const markInvoiceCancelled = (invoiceId: bigint) => { + setInvoices((current) => + current.map((invoice) => + invoice.id === invoiceId ? { ...invoice, status: "Cancelled" } : invoice, + ), + ); + }; + return ( <> @@ -431,9 +463,7 @@ function FreelancerPageContent() { id="field-due-date" type="date" value={form.dueDate} - min={new Date(Date.now() + 86_400_000) - .toISOString() - .slice(0, 10)} + min={minDueDate} onChange={(e) => setForm({ ...form, dueDate: e.target.value }) } @@ -647,7 +677,7 @@ function FreelancerPageContent() { {loadingInvoices ? ( @@ -659,7 +689,7 @@ function FreelancerPageContent() { ) : invoices.length === 0 ? (
@@ -706,6 +736,14 @@ function FreelancerPageContent() { + + markInvoiceCancelled(inv.id)} + /> + )) )} diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 87a40dd..b9baf1a 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -23,6 +23,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) { Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" }, Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" }, Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" }, + Vetoed: { color: "bg-orange-500/15 text-orange-500 border-orange-500/30", icon: "block" }, }; const { color, icon } = config[status]; return ( @@ -121,7 +122,7 @@ function ProposalCard({ proposal }: { proposal: Proposal }) { // ─── Filter tab ─────────────────────────────────────────────────────────────── -const FILTERS: Array = ["All", "Active", "Passed", "Failed", "Executed", "Pending"]; +const FILTERS: Array = ["All", "Active", "Passed", "Failed", "Executed", "Pending", "Vetoed"]; function FilterTabs({ active, @@ -172,10 +173,17 @@ export default function GovernancePage() { }, []); useEffect(() => { - load(); + const timeout = window.setTimeout(() => { + void load(); + }, 0); // Refresh every 30 s for real-time vote counts - const interval = setInterval(load, 30_000); - return () => clearInterval(interval); + const interval = window.setInterval(() => { + void load(); + }, 30_000); + return () => { + window.clearTimeout(timeout); + window.clearInterval(interval); + }; }, [load]); const filtered = diff --git a/app/pay/[id]/__tests__/PayInvoice.test.tsx b/app/pay/[id]/__tests__/PayInvoice.test.tsx index f4c9ee7..7edb89b 100644 --- a/app/pay/[id]/__tests__/PayInvoice.test.tsx +++ b/app/pay/[id]/__tests__/PayInvoice.test.tsx @@ -1,26 +1,32 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import PayInvoicePage from '../page'; -import * as soroban from '../../../../utils/soroban'; -import { useWallet } from '../../../../context/WalletContext'; -import { useToast } from '../../../../context/ToastContext'; +import * as soroban from '@/utils/soroban'; +import { useWallet } from '@/context/WalletContext'; +import { useToast } from '@/context/ToastContext'; // Mock context and utils -vi.mock('../../../../context/WalletContext', () => ({ +vi.mock('@/context/WalletContext', () => ({ useWallet: vi.fn(), })); -vi.mock('../../../../context/ToastContext', () => ({ +vi.mock('@/context/ToastContext', () => ({ useToast: vi.fn(), })); -vi.mock('../../../../utils/soroban', () => ({ +vi.mock('@/utils/soroban', () => ({ getInvoice: vi.fn(), markPaid: vi.fn(), + cancelInvoice: vi.fn(), submitSignedTransaction: vi.fn(), })); describe('PayInvoicePage', () => { + type ResolvedParamsPromise = Promise<{ id: string }> & { _resolvedValue: { id: string } }; + + const makeParams = (id: string): ResolvedParamsPromise => + Object.assign(Promise.resolve({ id }), { _resolvedValue: { id } }); + const mockInvoice = { id: 1n, freelancer: 'GFREELANCER', @@ -37,18 +43,17 @@ describe('PayInvoicePage', () => { beforeEach(() => { vi.clearAllMocks(); - (useToast as any).mockReturnValue(mockToast); - (soroban.getInvoice as any).mockResolvedValue(mockInvoice); + vi.mocked(useToast).mockReturnValue(mockToast); + vi.mocked(soroban.getInvoice).mockResolvedValue(mockInvoice); }); it('should render invoice summary without wallet connection', async () => { - (useWallet as any).mockReturnValue({ + vi.mocked(useWallet).mockReturnValue({ address: null, connect: vi.fn(), - }); + } as ReturnType); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; + const params = makeParams('1'); render(); await waitFor(() => { @@ -58,13 +63,12 @@ describe('PayInvoicePage', () => { }); it('should show warning if connected wallet is not the payer', async () => { - (useWallet as any).mockReturnValue({ + vi.mocked(useWallet).mockReturnValue({ address: 'GWRONGWALLET', connect: vi.fn(), - }); + } as ReturnType); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; + const params = makeParams('1'); render(); await waitFor(() => { @@ -74,17 +78,16 @@ describe('PayInvoicePage', () => { }); it('should show confirmation if invoice is already paid', async () => { - (soroban.getInvoice as any).mockResolvedValue({ + vi.mocked(soroban.getInvoice).mockResolvedValue({ ...mockInvoice, status: 'Paid', }); - (useWallet as any).mockReturnValue({ + vi.mocked(useWallet).mockReturnValue({ address: 'GPAYER', - }); + } as ReturnType); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; + const params = makeParams('1'); render(); await waitFor(() => { @@ -95,16 +98,15 @@ describe('PayInvoicePage', () => { it('should call markPaid when Settle button is clicked', async () => { const mockSignTx = vi.fn(); - (useWallet as any).mockReturnValue({ + vi.mocked(useWallet).mockReturnValue({ address: 'GPAYER', signTx: mockSignTx, - }); + } as ReturnType); - (soroban.markPaid as any).mockResolvedValue('mock-tx'); - (soroban.submitSignedTransaction as any).mockResolvedValue({ txHash: 'hash123' }); + vi.mocked(soroban.markPaid).mockResolvedValue('mock-tx' as Awaited>); + vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: 'hash123' }); - const params = Promise.resolve({ id: '1' }) as any; - params._resolvedValue = { id: '1' }; + const params = makeParams('1'); render(); await waitFor(() => { @@ -119,4 +121,32 @@ describe('PayInvoicePage', () => { expect(mockToast.updateToast).toHaveBeenCalledWith('toast-id', expect.objectContaining({ type: 'success' })); }); }); + + it('should let the submitter cancel a pending invoice optimistically', async () => { + vi.mocked(soroban.getInvoice).mockResolvedValue({ + ...mockInvoice, + status: 'Pending', + }); + const mockSignTx = vi.fn(); + vi.mocked(useWallet).mockReturnValue({ + address: 'GFREELANCER', + signTx: mockSignTx, + } as ReturnType); + + vi.mocked(soroban.cancelInvoice).mockResolvedValue({ tx: 'cancel-tx' }); + vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: 'cancel-hash' }); + + const params = makeParams('1'); + render(); + + await screen.findByText('Cancel Invoice'); + fireEvent.click(screen.getByText('Cancel Invoice')); + fireEvent.click(screen.getByText('Confirm Cancel')); + + await waitFor(() => { + expect(soroban.cancelInvoice).toHaveBeenCalledWith('GFREELANCER', 1n); + expect(soroban.submitSignedTransaction).toHaveBeenCalledWith({ tx: 'cancel-tx', signTx: mockSignTx }); + expect(screen.getByText('Invoice Cancelled')).toBeInTheDocument(); + }); + }); }); diff --git a/app/pay/[id]/page.tsx b/app/pay/[id]/page.tsx index 668b449..e3c80d9 100644 --- a/app/pay/[id]/page.tsx +++ b/app/pay/[id]/page.tsx @@ -7,8 +7,9 @@ import { formatAddress } from "@/utils/format"; import { formatUsdcFromStroops } from "@/utils/invoiceSubmission"; import { useWallet } from "@/context/WalletContext"; import { useToast } from "@/context/ToastContext"; -import { TESTNET_USDC_TOKEN_ID, NETWORK_NAME } from "@/constants"; +import { NETWORK_NAME } from "@/constants"; import ActivityFeed from "@/components/ActivityFeed"; +import CancelInvoiceButton from "@/components/CancelInvoiceButton"; type LoadState = "loading" | "success" | "error"; @@ -37,7 +38,10 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin }, [invoiceId]); useEffect(() => { - fetchInvoice(); + const timeout = window.setTimeout(() => { + void fetchInvoice(); + }, 0); + return () => window.clearTimeout(timeout); }, [fetchInvoice]); const handlePay = async () => { @@ -60,13 +64,13 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin }); // Refresh invoice state - fetchInvoice(); - } catch (err: any) { + void fetchInvoice(); + } catch (err) { console.error(err); updateToast(toastId, { type: "error", title: "Payment Failed", - message: err.message || "An unexpected error occurred during payment." + message: err instanceof Error ? err.message : "An unexpected error occurred during payment." }); } finally { setIsPaying(false); @@ -94,7 +98,9 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin } const isPayer = address === invoice.payer; + const isSubmitter = address === invoice.freelancer; const isPaid = invoice.status === "Paid"; + const isCancelled = invoice.status === "Cancelled"; return (
@@ -119,7 +125,7 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
)} - {address && !isPayer && !isPaid && ( + {address && !isPayer && !isPaid && !isCancelled && (
warning
@@ -129,6 +135,16 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
)} + {isCancelled && ( +
+ block +
+

Invoice cancelled

+

This invoice was cancelled by the submitter and can no longer be funded or settled.

+
+
+ )} + {/* ── Invoice Summary Card ───────────────────────────────────────── */}
@@ -186,6 +202,10 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin

Settlement Complete

+ ) : isCancelled ? ( +
+

Invoice Cancelled

+
) : isPayer ? (
diff --git a/src/components/CancelInvoiceButton.tsx b/src/components/CancelInvoiceButton.tsx new file mode 100644 index 0000000..5510242 --- /dev/null +++ b/src/components/CancelInvoiceButton.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import { useToast } from "@/context/ToastContext"; +import { useWallet } from "@/context/WalletContext"; +import { cancelInvoice, submitSignedTransaction } from "@/utils/soroban"; + +interface CancelInvoiceButtonProps { + invoiceId: bigint; + freelancer: string; + status: string; + onCancelled: () => void; + className?: string; +} + +export default function CancelInvoiceButton({ + invoiceId, + freelancer, + status, + onCancelled, + className = "", +}: CancelInvoiceButtonProps) { + const { address, signTx } = useWallet(); + const { addToast, updateToast } = useToast(); + const [isOpen, setIsOpen] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + + const isSubmitter = address === freelancer; + const canCancel = isSubmitter && status === "Pending"; + + if (!canCancel) return null; + + const handleCancel = async () => { + setIsCancelling(true); + const toastId = addToast({ + type: "pending", + title: "Cancelling invoice...", + message: "Please sign the cancellation transaction.", + }); + + try { + const { tx } = await cancelInvoice(freelancer, invoiceId); + const { txHash } = await submitSignedTransaction({ tx, signTx }); + onCancelled(); + setIsOpen(false); + updateToast(toastId, { + type: "success", + title: "Invoice Cancelled", + message: `Invoice #${invoiceId.toString()} was cancelled.`, + txHash, + }); + } catch (error) { + updateToast(toastId, { + type: "error", + title: "Cancellation Failed", + message: error instanceof Error ? error.message : "Unable to cancel this invoice.", + }); + } finally { + setIsCancelling(false); + } + }; + + return ( + <> + + + {isOpen && ( +
+
+
+ warning +
+

+ Cancel Invoice #{invoiceId.toString()} +

+

+ Are you sure? This cannot be undone. +

+
+
+ +
+ + +
+
+
+ )} + + ); +} diff --git a/src/components/TokenSelector.tsx b/src/components/TokenSelector.tsx index a157f15..e52bb44 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): string { return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`; } -function getTokenIconLabel(token: TokenLike): string { +function getTokenIconLabel(token: Pick): string { return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK"); } diff --git a/src/components/__tests__/CancelInvoiceButton.test.tsx b/src/components/__tests__/CancelInvoiceButton.test.tsx new file mode 100644 index 0000000..50e752a --- /dev/null +++ b/src/components/__tests__/CancelInvoiceButton.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CancelInvoiceButton from "../CancelInvoiceButton"; +import { useToast } from "@/context/ToastContext"; +import { useWallet } from "@/context/WalletContext"; +import { cancelInvoice, submitSignedTransaction } from "@/utils/soroban"; + +vi.mock("@/context/ToastContext", () => ({ + useToast: vi.fn(), +})); + +vi.mock("@/context/WalletContext", () => ({ + useWallet: vi.fn(), +})); + +vi.mock("@/utils/soroban", () => ({ + cancelInvoice: vi.fn(), + submitSignedTransaction: vi.fn(), +})); + +describe("CancelInvoiceButton", () => { + const toast = { + addToast: vi.fn(() => "toast-id"), + updateToast: vi.fn(), + }; + const onCancelled = vi.fn(); + const signTx = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useToast).mockReturnValue(toast); + vi.mocked(useWallet).mockReturnValue({ + address: "GFREELANCER", + isConnected: true, + isInstalled: true, + error: null, + networkMismatch: false, + connect: vi.fn(), + disconnect: vi.fn(), + signTx, + }); + vi.mocked(cancelInvoice).mockResolvedValue({ tx: "prepared-tx" }); + vi.mocked(submitSignedTransaction).mockResolvedValue({ txHash: "hash-123" }); + }); + + it("only renders for the submitter while the invoice is pending", () => { + const { rerender } = render( + , + ); + expect(screen.getByRole("button", { name: /cancel invoice/i })).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByRole("button", { name: /cancel invoice/i })).not.toBeInTheDocument(); + + vi.mocked(useWallet).mockReturnValue({ + address: "GOTHER", + isConnected: true, + isInstalled: true, + error: null, + networkMismatch: false, + connect: vi.fn(), + disconnect: vi.fn(), + signTx, + }); + rerender( + , + ); + expect(screen.queryByRole("button", { name: /cancel invoice/i })).not.toBeInTheDocument(); + }); + + it("confirms, submits cancellation, and reports success", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /cancel invoice/i })); + expect(screen.getByText("Are you sure? This cannot be undone.")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /confirm cancel/i })); + + await waitFor(() => { + expect(cancelInvoice).toHaveBeenCalledWith("GFREELANCER", 7n); + expect(submitSignedTransaction).toHaveBeenCalledWith({ tx: "prepared-tx", signTx }); + expect(onCancelled).toHaveBeenCalledOnce(); + expect(toast.updateToast).toHaveBeenCalledWith( + "toast-id", + expect.objectContaining({ type: "success", title: "Invoice Cancelled", txHash: "hash-123" }), + ); + }); + }); + + it("shows the failure reason when cancellation fails", async () => { + vi.mocked(cancelInvoice).mockRejectedValue(new Error("contract rejected")); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /cancel invoice/i })); + fireEvent.click(screen.getByRole("button", { name: /confirm cancel/i })); + + await waitFor(() => { + expect(onCancelled).not.toHaveBeenCalled(); + expect(toast.updateToast).toHaveBeenCalledWith( + "toast-id", + expect.objectContaining({ type: "error", title: "Cancellation Failed", message: "contract rejected" }), + ); + }); + }); +}); diff --git a/src/utils/evidence.ts b/src/utils/evidence.ts index 55396ad..c661f8f 100644 --- a/src/utils/evidence.ts +++ b/src/utils/evidence.ts @@ -6,7 +6,7 @@ export async function hashEvidence(text: string): Promise { if (typeof crypto !== "undefined" && crypto.subtle) { const encoded = new TextEncoder().encode(normalized); - const digest = await crypto.subtle.digest("SHA-256", encoded); + const digest = await crypto.subtle.digest("SHA-256", encoded.buffer as ArrayBuffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); diff --git a/src/utils/federation.ts b/src/utils/federation.ts index 11922eb..d1b78c2 100644 --- a/src/utils/federation.ts +++ b/src/utils/federation.ts @@ -4,6 +4,11 @@ import { RPC_URL } from "@/constants"; const horizonServer = new rpc.Server(RPC_URL); const federationCache = new Map(); +interface AccountHomeDomain { + home_domain?: string; + homeDomain?: string; +} + export async function resolveFederatedAddress(address: string): Promise { if (!address) return address; const cached = federationCache.get(address); @@ -11,7 +16,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 { home_domain: homeDomainSnake, homeDomain: homeDomainCamel } = account as AccountHomeDomain; + const homeDomain = homeDomainSnake ?? homeDomainCamel; if (!homeDomain) return address; const stellarTomlResponse = await fetch(`https://${homeDomain}/.well-known/stellar.toml`);